diff --git a/Taskfile.yml b/Taskfile.yml index 581df46..eda0991 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -13,6 +13,11 @@ tasks: cmd: go build -o ./build/softplayer silent: true + unit: + desc: Run unit tests + cmd: go test ./... + silent: true + run-migrations-dev: desc: Execute database migrations env: @@ -27,7 +32,15 @@ tasks: SOFTPLAYER_DB_CONNECTION_STRING: postgres://softplayer:qwertyu9@localhost:30432/softplayer?sslmode=disable SOFTPLAYER_REDIS_HOST: localhost:30379 cmd: go run main.go server --dev-mode --reflection - + psql: + desc: Connect to the dev database + env: + PGDATABASE: softplayer + PGUSER: softplayer + PGPORT: "30432" + PGHOST: localhost + PGPASSWORD: qwertyu9 + cmd: psql deploy-local-env: desc: Run a kind cluster and deploy deps deps: diff --git a/api/v1/accounts.go b/api/v1/accounts.go index e268f3a..95de164 100644 --- a/api/v1/accounts.go +++ b/api/v1/accounts.go @@ -48,16 +48,23 @@ func (a *AccountsServer) RefreshToken(ctx context.Context, in *empty.Empty) (*em return nil, status.Error(codes.Unauthenticated, "Invalid session") } - accessToken, _, err := a.authorizationCtrl.GenerateToken(session.UserID, controllers.TokenTypeAccess) + accessToken, _, err := a.authorizationCtrl.GenerateToken(&controllers.JWTData{ + UserID: claims.UserID, + TokenType: controllers.TokenTypeAccess, + TokenAud: controllers.TokenAudWeb, + }) if err != nil { return nil, status.Error(codes.Aborted, "Couldn't generate an access token") } - refreshToken, tokenID, err := a.authorizationCtrl.GenerateToken(session.UserID, controllers.TokenTypeRefresh) + refreshToken, tokenID, err := a.authorizationCtrl.GenerateToken(&controllers.JWTData{ + UserID: claims.UserID, + TokenType: controllers.TokenTypeRefresh, + TokenAud: controllers.TokenAudWeb, + }) if err != nil { return nil, status.Error(codes.Aborted, "Couldn't generate an access token") } - newSession := &controllers.Session{UserID: session.UserID} if err := a.authorizationCtrl.SaveSession(ctx, tokenID, newSession); err != nil { diff --git a/api/v1/public_accounts.go b/api/v1/public_accounts.go index 9afd2dd..8ec563e 100644 --- a/api/v1/public_accounts.go +++ b/api/v1/public_accounts.go @@ -34,12 +34,20 @@ func (a *PublicAccountService) SignIn(ctx context.Context, in *accounts.SignInRe if err != nil { return nil, status.Error(codes.Aborted, "Couldn't create a user") } - accessToken, _, err := a.authorizationCtrl.GenerateToken(id, controllers.TokenTypeAccess) + accessToken, _, err := a.authorizationCtrl.GenerateToken(&controllers.JWTData{ + UserID: id, + TokenType: controllers.TokenTypeAccess, + TokenAud: controllers.TokenAudWeb, + }) if err != nil { return nil, status.Error(codes.Aborted, "Couldn't generate an access token") } - refreshToken, tokenID, err := a.authorizationCtrl.GenerateToken(id, controllers.TokenTypeRefresh) + refreshToken, tokenID, err := a.authorizationCtrl.GenerateToken(&controllers.JWTData{ + UserID: id, + TokenType: controllers.TokenTypeRefresh, + TokenAud: controllers.TokenAudWeb, + }) if err != nil { return nil, status.Error(codes.Aborted, "Couldn't generate an access token") } @@ -70,12 +78,20 @@ func (a *PublicAccountService) SignUp(ctx context.Context, in *accounts.SignUpRe return nil, status.Error(codes.Aborted, "Couldn't create a user") } - accessToken, _, err := a.authorizationCtrl.GenerateToken(id, controllers.TokenTypeAccess) + accessToken, _, err := a.authorizationCtrl.GenerateToken(&controllers.JWTData{ + UserID: id, + TokenType: controllers.TokenTypeAccess, + TokenAud: controllers.TokenAudWeb, + }) if err != nil { return nil, status.Error(codes.Aborted, "Couldn't generate an access token") } - refreshToken, tokenID, err := a.authorizationCtrl.GenerateToken(id, controllers.TokenTypeRefresh) + refreshToken, tokenID, err := a.authorizationCtrl.GenerateToken(&controllers.JWTData{ + UserID: id, + TokenType: controllers.TokenTypeRefresh, + TokenAud: controllers.TokenAudWeb, + }) if err != nil { return nil, status.Error(codes.Aborted, "Couldn't generate an access token") } diff --git a/api/v1/public_tokens.go b/api/v1/public_tokens.go new file mode 100644 index 0000000..91e6075 --- /dev/null +++ b/api/v1/public_tokens.go @@ -0,0 +1,64 @@ +package v1 + +import ( + "context" + "errors" + + "gitea.badhouseplants.net/softplayer/softplayer-backend/internal/controllers" + tokens "gitea.badhouseplants.net/softplayer/softplayer-go-proto/pkg/tokens/v1" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/emptypb" +) + +// var _ tokens.PublicTokensServiceServer = (*PublicTokensServer)(nil) + +type PublicTokensServer struct { + tokens.UnimplementedPublicTokensServiceServer + tokenCtrl *controllers.TokenController + authorizationCtrl *controllers.AuthController +} + +func NewPublicTokensServer( + tokenCtrl *controllers.TokenController, + authorizationCtrl *controllers.AuthController, +) *PublicTokensServer { + return &PublicTokensServer{ + tokenCtrl: tokenCtrl, + authorizationCtrl: authorizationCtrl, + } +} + +func (srv *PublicTokensServer) AuthenticateWithToken(ctx context.Context, in *tokens.AuthenticateWithTokenRequest) (*emptypb.Empty, error) { + tokenAuthRes, err := srv.tokenCtrl.AuthenticateWithToken(ctx, in.TokenValue.Token) + if err != nil { + if errors.Is(err, controllers.ErrBadToken) { + return nil, status.Error(codes.Unauthenticated, "Token is not valid") + } + if errors.Is(err, controllers.ErrServerError) { + return nil, status.Error(codes.Internal, "Something is broken on our side") + } + return nil, status.Error(codes.Aborted, "Couldn't list tokens") + } + + jwtData := &controllers.JWTData{ + UserID: tokenAuthRes.UserID, + TokenType: controllers.TokenTypeAccess, + TokenAud: controllers.TokenAudToken, + Scope: tokenAuthRes.Scope, + } + accessToken, _, err := srv.authorizationCtrl.GenerateToken(jwtData) + if err != nil { + return nil, status.Error(codes.Aborted, "Couldn't generate an access token") + } + + header := metadata.New(map[string]string{ + "X-Access-Token": accessToken, + }) + if err := grpc.SetHeader(ctx, header); err != nil { + return nil, status.Error(codes.Aborted, "Couldn't set metadata") + } + return &emptypb.Empty{}, nil +} diff --git a/api/v1/test.go b/api/v1/test.go index c3739b0..94617f9 100644 --- a/api/v1/test.go +++ b/api/v1/test.go @@ -4,6 +4,8 @@ import ( "context" test "gitea.badhouseplants.net/softplayer/softplayer-go-proto/pkg/test/v1" + "google.golang.org/grpc" + "google.golang.org/protobuf/types/known/emptypb" ) func NewTestServer() *TestServer { @@ -18,6 +20,15 @@ func (t *TestServer) Pong(ctx context.Context, in *test.PongRequest) (*test.Pong return &test.PongResponse{}, nil } +func (t *TestServer) PongStream(in *emptypb.Empty, stream grpc.ServerStreamingServer[test.PongStreamResponse]) error { + for i := 0; i < 10; i++ { + if err := stream.Send(&test.PongStreamResponse{Dummy: "test"}); err != nil { + return err + } + } + return nil +} + func NewPublicTestServer() *PublicTestServer { return &PublicTestServer{} } @@ -29,3 +40,12 @@ type PublicTestServer struct { func (t *PublicTestServer) Ping(ctx context.Context, in *test.PingRequest) (*test.PingResponse, error) { return &test.PingResponse{}, nil } + +func (t *PublicTestServer) PingStream(in *emptypb.Empty, stream grpc.ServerStreamingServer[test.PingStreamResponse]) error { + for i := 0; i < 10; i++ { + if err := stream.Send(&test.PingStreamResponse{Dummy: "test"}); err != nil { + return err + } + } + return nil +} diff --git a/api/v1/tokens.go b/api/v1/tokens.go index 4c115eb..f6aec00 100644 --- a/api/v1/tokens.go +++ b/api/v1/tokens.go @@ -1,9 +1,262 @@ package v1 import ( + "context" + "errors" + + "gitea.badhouseplants.net/softplayer/softplayer-backend/internal/controllers" tokens "gitea.badhouseplants.net/softplayer/softplayer-go-proto/pkg/tokens/v1" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/emptypb" + "google.golang.org/protobuf/types/known/timestamppb" ) +// var _ tokens.TokensServiceServer = (*TokensServer)(nil) type TokensServer struct { tokens.UnimplementedTokensServiceServer + tokenCtrl *controllers.TokenController + authorizationCtrl *controllers.AuthController +} + +func NewTokensServer( + tokenCtrl *controllers.TokenController, + authorizationCtrl *controllers.AuthController, +) *TokensServer { + return &TokensServer{ + tokenCtrl: tokenCtrl, + authorizationCtrl: authorizationCtrl, + } +} + +// CreateToken implements [v1.TokensServiceServer]. +func (srv *TokensServer) CreateToken(ctx context.Context, in *tokens.CreateTokenRequest) (*tokens.CreateTokenResponse, error) { + claims, err := srv.authorizationCtrl.ClaimsFromContext(ctx) + if err != nil { + return nil, status.Error(codes.Aborted, "Context is invalid") + } + if claims.UserID == "" { + return nil, status.Error(codes.Aborted, "Context is invalid") + } + + if in.TokenPermissions == nil { + return nil, status.Error(codes.InvalidArgument, "Permissions must be set") + } + permissions := map[string][]string{} + for service, methods := range in.TokenPermissions.Permissions { + permissions[service] = methods.GetMethods() + } + + tokenData := &controllers.TokenData{ + Name: in.TokenMetadata.GetName(), + UserID: claims.UserID, + ExpiresAt: in.TokenMetadata.ExpiresAt.AsTime(), + Scopes: permissions, + } + + token, err := srv.tokenCtrl.Create(ctx, tokenData) + if err != nil { + if errors.Is(err, controllers.ErrServerError) { + return nil, status.Error(codes.Internal, "Something is broken on our side") + } + return nil, status.Error(codes.Aborted, "Couldn't create a token") + } + + return &tokens.CreateTokenResponse{ + TokenValue: &tokens.TokenValue{Token: token}, + }, nil +} + +// ForceTokenExpiration implements [v1.TokensServiceServer]. +func (srv *TokensServer) ForceTokenExpiration(ctx context.Context, in *tokens.ForceTokenExpirationRequest) (*emptypb.Empty, error) { + claims, err := srv.authorizationCtrl.ClaimsFromContext(ctx) + if err != nil { + return nil, status.Error(codes.Aborted, "Context is invalid") + } + if claims.UserID == "" { + return nil, status.Error(codes.Aborted, "Context is invalid") + } + + if err := srv.tokenCtrl.VerifyTokenOwner(ctx, claims.UserID, in.TokenUuid.Uuid); err != nil { + if errors.Is(err, controllers.ErrServerError) { + return nil, status.Error(codes.Internal, "Something is broken on our side") + } + return nil, status.Error(codes.Aborted, "User is now allowed to manipulate this token") + } + + if err := srv.tokenCtrl.ForceExpiration(ctx, in.TokenUuid.GetUuid()); err != nil { + if errors.Is(err, controllers.ErrServerError) { + return nil, status.Error(codes.Internal, "Something is broken on our side") + } + return nil, status.Error(codes.Aborted, "Couldn't create a token") + } + return &emptypb.Empty{}, nil +} + +// GetToken implements [v1.TokensServiceServer]. +func (srv *TokensServer) GetToken(ctx context.Context, in *tokens.GetTokenRequest) (*tokens.GetTokenResponse, error) { + claims, err := srv.authorizationCtrl.ClaimsFromContext(ctx) + if err != nil { + return nil, status.Error(codes.Aborted, "Context is invalid") + } + if claims.UserID == "" { + return nil, status.Error(codes.Aborted, "Context is invalid") + } + if err := srv.tokenCtrl.VerifyTokenOwner(ctx, claims.UserID, in.TokenUuid.Uuid); err != nil { + if errors.Is(err, controllers.ErrServerError) { + return nil, status.Error(codes.Internal, "Something is broken on our side") + } + return nil, status.Error(codes.Aborted, "User is now allowed to manipulate this token") + } + + token, err := srv.tokenCtrl.Get(ctx, in.TokenUuid.Uuid, claims.UserID) + if err != nil { + if errors.Is(err, controllers.ErrServerError) { + return nil, status.Error(codes.Internal, "Something is broken on our side") + } + return nil, status.Error(codes.Aborted, "Couldn't list tokens") + } + + return &tokens.GetTokenResponse{ + TokenUuid: &tokens.TokenUUID{ + Uuid: token.UUID, + }, + TokenMetadata: &tokens.TokenMetadata{ + Name: token.Name, + ExpiresAt: timestamppb.New(token.ExpiresAt), + LastUsedAt: timestamppb.New(token.LastUsedAt), + GeneratedAt: timestamppb.New(token.GeneratedAt), + CreatedAt: timestamppb.New(token.CreatedAt), + RevokedAt: timestamppb.New(token.RevokedAt), + }, + TokenPermissions: &tokens.TokenPermissions{}, + }, nil +} + +// ListTokens implements [v1.TokensServiceServer]. +func (srv *TokensServer) ListTokens(in *emptypb.Empty, stream grpc.ServerStreamingServer[tokens.ListTokensResponse]) error { + claims, err := srv.authorizationCtrl.ClaimsFromContext(stream.Context()) + if err != nil { + return status.Error(codes.Aborted, "Context is invalid") + } + if claims.UserID == "" { + return status.Error(codes.Aborted, "Context is invalid") + } + + tokensRes, err := srv.tokenCtrl.List(stream.Context(), claims.UserID) + if err != nil { + if errors.Is(err, controllers.ErrServerError) { + return status.Error(codes.Internal, "Something is broken on our side") + } + return status.Error(codes.Aborted, "Couldn't list tokens") + } + + for _, tokenRes := range tokensRes { + if err := stream.Send(&tokens.ListTokensResponse{ + TokenUuid: &tokens.TokenUUID{ + Uuid: tokenRes.UUID, + }, + TokenMetadata: &tokens.TokenMetadata{ + Name: tokenRes.Name, + ExpiresAt: timestamppb.New(tokenRes.ExpiresAt), + LastUsedAt: timestamppb.New(tokenRes.LastUsedAt), + GeneratedAt: timestamppb.New(tokenRes.GeneratedAt), + CreatedAt: timestamppb.New(tokenRes.CreatedAt), + RevokedAt: timestamppb.New(tokenRes.RevokedAt), + }, + }); err != nil { + return status.Error(codes.Aborted, "Couldn't send data") + } + } + return nil +} + +// RegenerateToken implements [v1.TokensServiceServer]. +func (srv *TokensServer) RegenerateToken(ctx context.Context, in *tokens.RegenerateTokenRequest) (*tokens.RegenerateTokenResponse, error) { + claims, err := srv.authorizationCtrl.ClaimsFromContext(ctx) + if err != nil { + return nil, status.Error(codes.Aborted, "Context is invalid") + } + if claims.UserID == "" { + return nil, status.Error(codes.Aborted, "Context is invalid") + } + if err := srv.tokenCtrl.VerifyTokenOwner(ctx, claims.UserID, in.TokenUuid.Uuid); err != nil { + if errors.Is(err, controllers.ErrServerError) { + return nil, status.Error(codes.Internal, "Something is broken on our side") + } + return nil, status.Error(codes.Aborted, "User is now allowed to manipulate this token") + } + + tokenVal, err := srv.tokenCtrl.Regenerate(ctx, in.TokenUuid.GetUuid()) + if err != nil { + if errors.Is(err, controllers.ErrServerError) { + return nil, status.Error(codes.Internal, "Something is broken on our side") + } + return nil, status.Error(codes.Aborted, "Couldn't list tokens") + } + return &tokens.RegenerateTokenResponse{ + TokenValue: &tokens.TokenValue{ + Token: tokenVal, + }, + }, nil +} + +// UpdateToken implements [v1.TokensServiceServer]. +func (srv *TokensServer) UpdateToken(ctx context.Context, in *tokens.UpdateTokenRequest) (*tokens.UpdateTokenResponse, error) { + claims, err := srv.authorizationCtrl.ClaimsFromContext(ctx) + if err != nil { + return nil, status.Error(codes.Aborted, "Context is invalid") + } + if claims.UserID == "" { + return nil, status.Error(codes.Aborted, "Context is invalid") + } + + if err := srv.tokenCtrl.VerifyTokenOwner(ctx, claims.UserID, in.TokenUuid.Uuid); err != nil { + if errors.Is(err, controllers.ErrServerError) { + return nil, status.Error(codes.Internal, "Something is broken on our side") + } + return nil, status.Error(codes.Aborted, "User is now allowed to manipulate this token") + } + if in.TokenPermissions == nil { + return nil, status.Error(codes.InvalidArgument, "Permissions must be set") + } + + permissions := map[string][]string{} + for service, methods := range in.TokenPermissions.Permissions { + permissions[service] = methods.GetMethods() + } + tokenData := &controllers.TokenData{ + Name: in.TokenMetadata.Name, + Scopes: permissions, + } + if err := srv.tokenCtrl.Update(ctx, tokenData); err != nil { + if errors.Is(err, controllers.ErrServerError) { + return nil, status.Error(codes.Internal, "Something is broken on our side") + } + return nil, status.Error(codes.Aborted, "Couldn't list tokens") + } + return &tokens.UpdateTokenResponse{ + TokenUuid: &tokens.TokenUUID{}, + TokenMetadata: &tokens.TokenMetadata{}, + TokenPermissions: &tokens.TokenPermissions{}, + }, nil +} + +// ListPermissions implements [v1.TokensServiceServer]. +func (srv *TokensServer) ListPermissions(in *emptypb.Empty, stream grpc.ServerStreamingServer[tokens.ListPermissionsResponse]) error { + data := srv.tokenCtrl.ListPermissions(stream.Context()) + for key, data := range data { + result := &tokens.ListPermissionsResponse{ + Permissions: &tokens.TokenPermissions{ + Permissions: map[string]*tokens.MethodList{ + key: {Methods: data}, + }, + }, + } + if err := stream.Send(result); err != nil { + return status.Error(codes.Aborted, "Couldn't send data to the client") + } + } + return nil } diff --git a/cmd/server.go b/cmd/server.go index 9b9dfc9..a158f07 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -13,6 +13,7 @@ import ( "gitea.badhouseplants.net/softplayer/softplayer-backend/internal/helpers/logger" accounts "gitea.badhouseplants.net/softplayer/softplayer-go-proto/pkg/accounts/v1" test "gitea.badhouseplants.net/softplayer/softplayer-go-proto/pkg/test/v1" + tokens "gitea.badhouseplants.net/softplayer/softplayer-go-proto/pkg/tokens/v1" grpc_zap "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap" "github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors" "github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/auth" @@ -66,7 +67,7 @@ func (cmd *Server) Run(ctx context.Context) error { Addr: cmd.RedisHost, }) - authInterceptor := controllers.NewAuthController( + authController := controllers.NewAuthController( []byte(cmd.JWTSecret), cmd.AccessTokenTTL, cmd.RefrestTokenTTL, @@ -78,17 +79,29 @@ func (cmd *Server) Run(ctx context.Context) error { grpc_zap.UnaryServerInterceptor(logger.SetupLogger("info")), // jwtVerifier.JWTAuthInterceptor, selector.UnaryServerInterceptor( - auth.UnaryServerInterceptor(authInterceptor.AuthInterceptorFN), + auth.UnaryServerInterceptor(authController.AuthInterceptorFN), + selector.MatchFunc(selectorRequireAuth), + ), + ), + grpc.ChainStreamInterceptor( + grpc_zap.StreamServerInterceptor(logger.SetupLogger("info")), + selector.StreamServerInterceptor( + auth.StreamServerInterceptor(authController.AuthInterceptorFN), selector.MatchFunc(selectorRequireAuth), ), ), - grpc.StreamInterceptor(grpc_zap.StreamServerInterceptor(logger.SetupLogger("info"))), ) if cmd.Reflection { reflection.Register(grpcServer) } + tokenCtrl := &controllers.TokenController{ + DB: db, + HashCost: cmd.HashCost, + Redis: rdb, + } + accountCtrl := &controllers.AccountController{ HashCost: cmd.HashCost, DB: db, @@ -99,10 +112,18 @@ func (cmd *Server) Run(ctx context.Context) error { Redis: rdb, } - accounts.RegisterPublicAccountsServiceServer(grpcServer, v1.NewPublicAccountServer(accountCtrl, authInterceptor)) - accounts.RegisterAccountsServiceServer(grpcServer, v1.NewAccountServer(accountCtrl, authInterceptor)) + // Services that should be accessible for tokens should go here + accounts.RegisterAccountsServiceServer(grpcServer, v1.NewAccountServer(accountCtrl, authController)) test.RegisterTestServiceServer(grpcServer, v1.NewTestServer()) test.RegisterPublicTestServiceServer(grpcServer, v1.NewPublicTestServer()) + tokens.RegisterTokensServiceServer(grpcServer, v1.NewTokensServer(tokenCtrl, authController)) + tokens.RegisterPublicTokensServiceServer(grpcServer, v1.NewPublicTokensServer(tokenCtrl, authController)) + accounts.RegisterPublicAccountsServiceServer(grpcServer, v1.NewPublicAccountServer(accountCtrl, authController)) + + info := grpcServer.GetServiceInfo() + tokenCtrl.SetGRPCInfo(info) + tokenCtrl.SetRules() + if err := grpcServer.Serve(lis); err != nil { return err } @@ -116,8 +137,20 @@ func selectorRequireAuth(ctx context.Context, callMeta interceptors.CallMeta) bo if len(serviceParts) == 0 { return false } - serviceName := serviceParts[len(serviceParts)-1] fmt.Println(serviceName) - return !strings.HasPrefix(serviceName, "Public") + + if strings.HasPrefix(serviceName, "Public") { + return false + } + + if strings.Contains(serviceName, "ServerReflection") { + return false + } + + if strings.Contains(callMeta.Method, "AuthenticateWithToken") { + return false + } + + return true } diff --git a/go.mod b/go.mod index c7676d3..60f6fdf 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,8 @@ module gitea.badhouseplants.net/softplayer/softplayer-backend go 1.25.9 require ( - github.com/alecthomas/assert/v2 v2.11.0 github.com/alecthomas/kong v1.15.0 + github.com/db-operator/can-haz-password v0.1.1 github.com/go-logr/logr v1.4.3 github.com/go-logr/zapr v1.3.0 github.com/golang-jwt/jwt/v5 v5.2.2 @@ -20,11 +20,10 @@ require ( ) require ( - github.com/alecthomas/repr v0.5.2 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect - github.com/hexops/gotextdiff v1.0.3 // indirect + github.com/geozelot/intree v1.0.0 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/lib/pq v1.10.9 // indirect github.com/mattn/go-isatty v0.0.20 // indirect @@ -40,7 +39,7 @@ require ( ) require ( - gitea.badhouseplants.net/softplayer/softplayer-go-proto v0.0.0-20260511094640-0f4959475dc9 + gitea.badhouseplants.net/softplayer/softplayer-go-proto v0.0.0-20260515083721-50411957979f github.com/golang/protobuf v1.5.4 golang.org/x/net v0.49.0 // indirect golang.org/x/sys v0.40.0 // indirect diff --git a/go.sum b/go.sum index 7c3a9e9..cb2407e 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,8 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMT cloud.google.com/go v0.121.6 h1:waZiuajrI28iAf40cWgycWNgaXPO06dupuS+sgibK6c= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= -gitea.badhouseplants.net/softplayer/softplayer-go-proto v0.0.0-20260511094640-0f4959475dc9 h1:gVB4z3qZXriL8xfJnY8hEqiBTGHGeeWO5E3GY/7aNRM= -gitea.badhouseplants.net/softplayer/softplayer-go-proto v0.0.0-20260511094640-0f4959475dc9/go.mod h1:AgOh1lkPHyRgBf3/s1btKcAqke/33LbKYarTD13qeAg= +gitea.badhouseplants.net/softplayer/softplayer-go-proto v0.0.0-20260515083721-50411957979f h1:o+GpCFtuw59LrWw9ZkWQOXhQxJjLaJYM+uZm0gdtrRI= +gitea.badhouseplants.net/softplayer/softplayer-go-proto v0.0.0-20260515083721-50411957979f/go.mod h1:AgOh1lkPHyRgBf3/s1btKcAqke/33LbKYarTD13qeAg= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= @@ -33,6 +33,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/db-operator/can-haz-password v0.1.1 h1:g0OC+9e4L751aWS9h02p/G4WrAaWgbR9LFjKLLC/hnA= +github.com/db-operator/can-haz-password v0.1.1/go.mod h1:0iO+taMqfWqNF3ltVhDWfIghD4VkXnnaQf06xiEIX8M= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4= @@ -51,6 +53,8 @@ github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1m github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/geozelot/intree v1.0.0 h1:xUyiXMt0wD9zbPMOjy2rVShiUc3PGMPddPuTmi+Jy2s= +github.com/geozelot/intree v1.0.0/go.mod h1:JrqfsNwe17AgzOM023tCXPyUB89NhaZAb8o5rzfZQ7Q= github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= diff --git a/internal/controllers/authorization.go b/internal/controllers/authorization.go index 7a6be33..07f14a3 100644 --- a/internal/controllers/authorization.go +++ b/internal/controllers/authorization.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "slices" "strings" "time" @@ -22,6 +23,8 @@ type TokenType string const ( TokenTypeAccess TokenType = "access" TokenTypeRefresh TokenType = "refresh" + TokenAudToken string = "token" + TokenAudWeb string = "web" ) var ( @@ -33,6 +36,7 @@ type Claims struct { UserID string `json:"user_id"` TokenID string `json:"token_id"` TokenType TokenType `json:"token_type"` + Scope string `json:"scope,omitempty"` jwt.RegisteredClaims } @@ -56,6 +60,13 @@ func NewAuthController(jwtSecret []byte, accessTTL, refreshTTL time.Duration, re } } +type JWTData struct { + UserID string + TokenType TokenType + TokenAud string + Scope string +} + // Write claims into context func (a *AuthController) WithClaims(ctx context.Context, claims *Claims) context.Context { return context.WithValue(ctx, claimsContextKey, claims) @@ -87,15 +98,43 @@ func (a *AuthController) AuthInterceptorFN(ctx context.Context) (context.Context } } + // If it's a cli token, we need to check the scope + if slices.Contains(claims.Audience, TokenAudToken) { + currentMethod, ok := grpc.Method(ctx) + if !ok { + return nil, errors.New("unknown method") + } + + scopeMap := map[string][]string{} + if err := json.Unmarshal([]byte(claims.Scope), &scopeMap); err != nil { + return nil, ErrServerError + } + allowed := isAllowed(scopeMap, currentMethod) + if !allowed { + return nil, errors.New("not authorized") + } + } + ctx = a.WithClaims(ctx, claims) return ctx, nil } +func isAllowed(scope map[string][]string, currentMethod string) bool { + for service, methods := range scope { + for _, method := range methods { + if fmt.Sprintf("/%s/%s", service, method) == currentMethod { + return true + } + } + } + return false +} + // Generate JWT token -func (a *AuthController) GenerateToken(userID string, tokenType TokenType) (token, tokenID string, err error) { +func (a *AuthController) GenerateToken(data *JWTData) (token, tokenID string, err error) { var expiresAt time.Time notBefore := time.Now() - switch tokenType { + switch data.TokenType { case TokenTypeAccess: expiresAt = time.Now().Add(a.accessTTL) case TokenTypeRefresh: @@ -103,25 +142,25 @@ func (a *AuthController) GenerateToken(userID string, tokenType TokenType) (toke default: return "", "", ErrUnknownTokenType } - if tokenType != TokenTypeAccess && tokenType != TokenTypeRefresh { - return "", "", ErrUnknownTokenType - } tokenID = uuid.New().String() + claims := Claims{ - UserID: userID, + UserID: data.UserID, TokenID: tokenID, - TokenType: tokenType, + TokenType: data.TokenType, + Scope: data.Scope, RegisteredClaims: jwt.RegisteredClaims{ Issuer: "", - Subject: "", - Audience: jwt.ClaimStrings{}, + Subject: data.UserID, + Audience: jwt.ClaimStrings{data.TokenAud}, ExpiresAt: jwt.NewNumericDate(expiresAt), NotBefore: jwt.NewNumericDate(notBefore), IssuedAt: jwt.NewNumericDate(time.Now()), - ID: userID, + ID: tokenID, }, } + tokenJwt := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) token, err = tokenJwt.SignedString(a.jwtSecret) if err != nil { diff --git a/internal/controllers/authorization_test.go b/internal/controllers/authorization_test.go index face84a..567014a 100644 --- a/internal/controllers/authorization_test.go +++ b/internal/controllers/authorization_test.go @@ -16,16 +16,26 @@ var ( ) func TestGenerateInvalidTokenType(t *testing.T) { + data := &controllers.JWTData{ + UserID: testUserID, + TokenType: "invalid_type", + } + authCtrl := controllers.NewAuthController([]byte("test"), testAccessTTL, testRefreshTTL, nil) - token, _, err := authCtrl.GenerateToken(testUserID, "invalid_type") + + token, _, err := authCtrl.GenerateToken(data) assert.Equal(t, "", token) assert.ErrorIs(t, controllers.ErrUnknownTokenType, err) } func TestGenerateValidateAccessToken(t *testing.T) { + data := &controllers.JWTData{ + UserID: testUserID, + TokenType: controllers.TokenTypeAccess, + } authCtrl := controllers.NewAuthController([]byte("test"), testAccessTTL, testRefreshTTL, nil) now := time.Now() - token, _, err := authCtrl.GenerateToken(testUserID, controllers.TokenTypeAccess) + token, _, err := authCtrl.GenerateToken(data) assert.NoError(t, err) assert.NotEmpty(t, token) @@ -40,9 +50,13 @@ func TestGenerateValidateAccessToken(t *testing.T) { } func TestGenerateValidateRefreshToken(t *testing.T) { + data := &controllers.JWTData{ + UserID: testUserID, + TokenType: controllers.TokenTypeRefresh, + } authCtrl := controllers.NewAuthController([]byte("test"), testAccessTTL, testRefreshTTL, nil) now := time.Now() - token, _, err := authCtrl.GenerateToken(testUserID, controllers.TokenTypeRefresh) + token, _, err := authCtrl.GenerateToken(data) assert.NoError(t, err) assert.NotEmpty(t, token) diff --git a/internal/controllers/tokens.go b/internal/controllers/tokens.go index 5789702..7a4d772 100644 --- a/internal/controllers/tokens.go +++ b/internal/controllers/tokens.go @@ -2,52 +2,389 @@ package controllers import ( "context" + "crypto/sha256" "database/sql" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "regexp" + "testing" "time" + + "gitea.badhouseplants.net/softplayer/softplayer-backend/internal/helpers/logger" + "gitea.badhouseplants.net/softplayer/softplayer-backend/internal/helpers/token" + "github.com/google/uuid" + "github.com/redis/go-redis/v9" + "github.com/stretchr/testify/assert" + "google.golang.org/grpc" ) type TokenController struct { - DB *sql.DB + DB *sql.DB + HashCost int16 + ServiceInfo map[string]grpc.ServiceInfo + rules []rule + Redis *redis.Client } +// Services that are not available for tokens +var DisabledServicesRegex = []string{".*Accounts.*", ".*Tokens.*"} + +// Errors +var ( + ErrServerError = errors.New("internal server error") + ErrUserTokenMismatch = errors.New("user doesn't match the token") + ErrBadToken = errors.New("bad token") +) + type TokenData struct { - UUID string - UserID string - CreatedAt time.Time - LastUserAt time.Time - RevokedAt time.Time - ExpiredAt time.Time - Scopes *Scopes + UUID string + Name string + UserID string + CreatedAt time.Time + LastUsedAt time.Time + RevokedAt time.Time + ExpiresAt time.Time + GeneratedAt time.Time + Scopes map[string][]string } type Scopes struct{} +// Set the grpc info, must happen after all the service are initialized +func (ctrl *TokenController) SetGRPCInfo(info map[string]grpc.ServiceInfo) { + ctrl.ServiceInfo = info +} + +func (ctrl *TokenController) SetRules() { + rules := []rule{ + {re: regexp.MustCompile(`.*Tokens.*`)}, + {re: regexp.MustCompile(`.*Accounts.*`)}, + {re: regexp.MustCompile(`.*Reflection.*`)}, + } + ctrl.rules = rules +} + +// Each token operation must first verify that the current user +// is allowed to manipulate the token. +func (ctrl *TokenController) VerifyTokenOwner(ctx context.Context, userID, tokenID string) error { + log := logger.FromContext(ctx).WithValues("uuid", tokenID, "user_id", userID) + log.V(2).Info("Verifying the token owner") + + // First try to get from the redis + redisKey := fmt.Sprintf("token:%s", tokenID) + realUserID := ctrl.Redis.Get(ctx, redisKey).Val() + // If not found in cache, get from postgres + if realUserID == "" { + query := "SELECT user_id FROM tokens WHERE uuid = $1;" + if err := ctrl.DB.QueryRowContext(ctx, query, tokenID).Scan( + &realUserID, + ); err != nil { + log.Error(err, "Couldn't get user_id for a token") + return ErrServerError + } + } + + if realUserID != userID { + return ErrUserTokenMismatch + } + err := ctrl.Redis.Set(ctx, redisKey, realUserID, time.Hour) + if err != nil { + log.Info("Couldn't write to cache", "error", err) + } + return nil +} + // Create a new token, store its hash in the database and return the token value -func (ctrl *TokenController) Create(ctx context.Context) (string, error) { - return "", nil +func (ctrl *TokenController) Create(ctx context.Context, data *TokenData) (string, error) { + id := uuid.NewString() + log := logger.FromContext(ctx).WithValues("uuid", id) + log.V(2).Info("Creating a new token") + + tokenValue, err := token.GenerateToken() + if err != nil { + log.Error(err, "Couldn't create a token") + return "", ErrServerError + } + + tokenHash := hashSHA256(tokenValue) + + scopesJson, err := json.Marshal(data.Scopes) + if err != nil { + log.Error(err, "Couldn't marshal permissions into json") + return "", ErrServerError + } + + query := ` + INSERT INTO tokens (uuid, description, token_hash, user_id, scopes, created_at, generated_at, expires_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)` + + if _, err := ctrl.DB.Query( + query, + id, + data.Name, + tokenHash, + data.UserID, + scopesJson, + time.Now(), + time.Now(), + data.ExpiresAt, + ); err != nil { + log.Error(err, "Couldn't insert a token in the database") + return "", ErrServerError + } + + return tokenValue, nil } // Update token name or permissions, other changes are ignored by this method -func (ctrl *TokenController) Update(ctx context.Context) (string, error) { - return "", nil +func (ctrl *TokenController) Update(ctx context.Context, data *TokenData) error { + log := logger.FromContext(ctx).WithValues("uuid", data.UUID) + log.V(2).Info("Updating a token") + + scopesJson, err := json.Marshal(data.Scopes) + if err != nil { + log.Error(err, "Couldn't marshal permissions into json") + return ErrServerError + } + + query := "UPDATE tokens SET description = $1, scopes = $2 WHERE uuid = $3;" + if _, err := ctrl.DB.Query(query, data.Name, scopesJson, data.UUID); err != nil { + log.Error(err, "Couldn't update a token in the database") + return ErrServerError + } + + return nil } // ForceExpiration of a token, so it can no longer be used -func (ctrl *TokenController) ForceExpiration(ctx context.Context) error { +func (ctrl *TokenController) ForceExpiration(ctx context.Context, id string) error { + log := logger.FromContext(ctx).WithValues("uuid", id) + log.V(2).Info("Forcing a token expiration") + + query := "UPDATE tokens SET revoked_at = $1 WHERE uuid = $2;" + if _, err := ctrl.DB.Query(query, time.Now(), id); err != nil { + log.Error(err, "Couldn't update a token in the database") + return ErrServerError + } + return nil } // Regenerate a token and get a new value -func (ctrl *TokenController) Regenerate(ctx context.Context) (string, error) { +func (ctrl *TokenController) Regenerate(ctx context.Context, id string) (string, error) { + log := logger.FromContext(ctx).WithValues("uuid", id) + log.V(2).Info("Regenerating a token") + + tokenValue, err := token.GenerateToken() + if err != nil { + log.Error(err, "Couldn't create a token") + return "", ErrServerError + } + + tokenHash := hashSHA256(tokenValue) + + query := ` + UPDATE tokens + SET + token_hash = $1, + generated_at = $2, + expires_at = NOW() + (expires_at - generated_at), + WHERE uuid = $3;` + + if _, err := ctrl.DB.Query(query, tokenHash, time.Now(), id); err != nil { + log.Error(err, "Couldn't insert a token in the database") + return "", ErrServerError + } + return "", nil } // Get an existing token data -func (ctrl *TokenController) Get(ctx context.Context, uuid string) error { - return nil +func (ctrl *TokenController) Get(ctx context.Context, id, userID string) (*TokenData, error) { + log := logger.FromContext(ctx).WithValues("uuid", id, "user_id", userID) + log.V(2).Info("Regenerating a token") + query := ` + SELECT uuid, description, generated_at, expires_at, last_used_at, revoked_at, created_at + FROM tokens + WHERE uuid = $1 AND user_id = $2` + token := &TokenData{} + var generatedAt sql.NullTime + var expiresAt sql.NullTime + var revokedAt sql.NullTime + var lastUsedAt sql.NullTime + var createdAt sql.NullTime + + if err := ctrl.DB.QueryRowContext(ctx, query, id, userID).Scan( + &token.UUID, + &token.Name, + &generatedAt, + &expiresAt, + &lastUsedAt, + &revokedAt, + &createdAt, + ); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, err + } + log.Error(err, "Couldn't find a token") + return nil, ErrServerError + } + token.GeneratedAt = generatedAt.Time + token.ExpiresAt = expiresAt.Time + token.RevokedAt = revokedAt.Time + token.LastUsedAt = lastUsedAt.Time + token.CreatedAt = createdAt.Time + + return token, nil } // List all available token -func (ctrl *TokenController) List(ctx context.Context) error { - return nil +func (ctrl *TokenController) List(ctx context.Context, userID string) ([]TokenData, error) { + log := logger.FromContext(ctx).WithValues("user_id", userID) + log.V(2).Info("Regenerating a token") + + result := []TokenData{} + + query := ` + SELECT uuid, description, generated_at, expires_at, revoked_at, last_used_at, created_at + FROM tokens + WHERE user_id = $1` + + rows, err := ctrl.DB.QueryContext(ctx, query, userID) + if err != nil { + log.Error(err, "Couldn't list tokens") + return nil, ErrServerError + } + + defer rows.Close() + + if err := rows.Err(); err != nil { + log.Error(err, "Couldn't list tokens") + return nil, ErrServerError + } + + for rows.Next() { + var t TokenData + + var generatedAt sql.NullTime + var expiresAt sql.NullTime + var revokedAt sql.NullTime + var lastUsedAt sql.NullTime + var createdAt sql.NullTime + + err := rows.Scan( + &t.UUID, + &t.Name, + &generatedAt, + &expiresAt, + &revokedAt, + &lastUsedAt, + &createdAt, + ) + + t.GeneratedAt = generatedAt.Time + t.ExpiresAt = expiresAt.Time + t.RevokedAt = revokedAt.Time + t.LastUsedAt = lastUsedAt.Time + t.CreatedAt = createdAt.Time + + if err != nil { + log.Error(err, "Couldn't write token into a struct") + return nil, err + } + + result = append(result, t) + } + + return result, nil +} + +// Lis all available permissions +func (ctrl *TokenController) ListPermissions(ctx context.Context) (result map[string][]string) { + result = map[string][]string{} + for key, val := range ctrl.ServiceInfo { + if shouldSkip(key, ctrl.rules) { + continue + } + var services []string + for _, svc := range val.Methods { + services = append(services, svc.Name) + } + result[key] = services + } + return +} + +type rule struct { + re *regexp.Regexp +} + +func shouldSkip(s string, rules []rule) bool { + for _, r := range rules { + if r.re.MatchString(s) { + return true + } + } + return false +} + +type TokenAuthResult struct { + UserID string + Scope string +} + +func (ctrl *TokenController) AuthenticateWithToken(ctx context.Context, token string) (*TokenAuthResult, error) { + log := logger.FromContext(ctx) + log.V(2).Info("Authenticating with a token") + + query := ` + SELECT user_id, scopes, expires_at, revoked_at + FROM tokens + WHERE token_hash = $1` + + var userID string + var expiresAt sql.NullTime + var revokedAt sql.NullTime + var scope string + if err := ctrl.DB.QueryRowContext(ctx, query, hashSHA256(token)).Scan( + &userID, + &scope, + &expiresAt, + &revokedAt, + ); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, err + } + log.Error(err, "Couldn't find a token") + return nil, ErrServerError + } + + if revokedAt.Valid { + return nil, ErrBadToken + } + + if expiresAt.Time.Before(time.Now()) { + return nil, ErrBadToken + } + + result := &TokenAuthResult{ + UserID: userID, + Scope: scope, + } + + return result, nil +} + +func hashSHA256(s string) string { + hash := sha256.Sum256([]byte(s)) + return hex.EncodeToString(hash[:]) +} + +func TestUnitHashPersistence(t *testing.T) { + password := "qwertyu9" + hash1 := hashSHA256(password) + hash2 := hashSHA256(password) + assert.Equal(t, hash1, hash2) } diff --git a/internal/helpers/hash/hash_test.go b/internal/helpers/hash/hash_test.go index 2714721..5130f34 100644 --- a/internal/helpers/hash/hash_test.go +++ b/internal/helpers/hash/hash_test.go @@ -4,17 +4,17 @@ import ( "testing" "gitea.badhouseplants.net/softplayer/softplayer-backend/internal/helpers/hash" - "github.com/alecthomas/assert/v2" + "github.com/stretchr/testify/assert" ) -func TestHashValid(t *testing.T) { +func TestUnitHashValid(t *testing.T) { password := "qwertyu9" hpass, err := hash.HashPassword(password, 10) assert.NoError(t, err) assert.NoError(t, hash.CheckPasswordHash(password, hpass)) } -func TestHashInvalid(t *testing.T) { +func TestUnitHashInvalid(t *testing.T) { password := "qwertyu9" invhash := "qwertyu9" assert.Error(t, hash.CheckPasswordHash(password, invhash)) diff --git a/internal/helpers/token/token.go b/internal/helpers/token/token.go new file mode 100644 index 0000000..b37d7d4 --- /dev/null +++ b/internal/helpers/token/token.go @@ -0,0 +1,45 @@ +// Package token should be used to generate secure tokens +package token + +import ( + "fmt" + + "github.com/db-operator/can-haz-password/password" +) + +const ( + TokenPrefix = "sft" +) + +// GenerateToken generates secure password string +func GenerateToken() (string, error) { + generator := password.NewGenerator(newTokenRule()) + password, err := generator.Generate() + if err != nil { + return "", err + } + return fmt.Sprintf("%s_%s", TokenPrefix, password), nil +} + +// Minimum length of 20 characters, maximum length of 30 characters. +// Varied composition including special characters and uppercase and lowercase letters. +// Excludes consecutive dashes (for hybris compatibility) and uses only url safe special characters. +type tokenRule struct{} + +func newTokenRule() *tokenRule { + return &tokenRule{} +} + +func (r *tokenRule) Config() *password.Configuration { + return &password.Configuration{ + Length: 40, + CharacterClasses: []password.CharacterClassConfiguration{ // codespell:ignore + {Characters: password.LowercaseCharacters + password.UppercaseCharacters, Minimum: 10}, + {Characters: password.DigitCharacters, Minimum: 8}, + }, + } +} + +func (r *tokenRule) Valid(password []rune) bool { + return true +} diff --git a/internal/helpers/token/token_test.go b/internal/helpers/token/token_test.go new file mode 100644 index 0000000..6b851b3 --- /dev/null +++ b/internal/helpers/token/token_test.go @@ -0,0 +1,16 @@ +package token_test + +import ( + "strings" + "testing" + + "gitea.badhouseplants.net/softplayer/softplayer-backend/internal/helpers/token" + "github.com/stretchr/testify/assert" +) + +func TestUnitTokenGeneration(t *testing.T) { + token, err := token.GenerateToken() + assert.NoError(t, err) + assert.Len(t, token, 44) + assert.True(t, strings.HasPrefix(token, "sft_")) +} diff --git a/migrations/20260510174348_tokens_init.up.sql b/migrations/20260510174348_tokens_init.up.sql index aeeb408..eb4f1e6 100644 --- a/migrations/20260510174348_tokens_init.up.sql +++ b/migrations/20260510174348_tokens_init.up.sql @@ -1,9 +1,11 @@ CREATE TABLE IF NOT EXISTS tokens ( uuid UUID PRIMARY KEY, + description TEXT NOT NULL, token_hash TEXT NOT NULL, user_id UUID NOT NULL, scopes JSONB NOT NULL DEFAULT '[]', created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + generated_at TIMESTAMPTZ NOT NULL DEFAULT now(), last_used_at TIMESTAMPTZ, revoked_at TIMESTAMPTZ, expires_at TIMESTAMPTZ diff --git a/migrations/20260510175121_accounts_timestamptz.down.sql b/migrations/20260510175121_accounts_timestamptz.down.sql deleted file mode 100644 index 56a57eb..0000000 --- a/migrations/20260510175121_accounts_timestamptz.down.sql +++ /dev/null @@ -1,3 +0,0 @@ -ALTER TABLE accounts -ALTER COLUMN created_at TYPE TIMESTAMP -USING created_at AT TIME ZONE 'UTC'; diff --git a/migrations/20260510175121_accounts_timestamptz.up.sql b/migrations/20260510175121_accounts_timestamptz.up.sql deleted file mode 100644 index 3862462..0000000 --- a/migrations/20260510175121_accounts_timestamptz.up.sql +++ /dev/null @@ -1,3 +0,0 @@ -ALTER TABLE accounts -ALTER COLUMN created_at TYPE TIMESTAMPTZ -USING created_at AT TIME ZONE 'UTC';