From 3933fe5b116c220ecd933472026a816538fb8127 Mon Sep 17 00:00:00 2001 From: Xin Feng <126309503+danielxfeng@users.noreply.github.com> Date: Tue, 27 Jan 2026 10:08:26 +0200 Subject: [PATCH 01/21] chore/backend: add redis packages, and env --- backend/.env.sample | 3 +++ backend/go.mod | 6 ++++++ backend/go.sum | 6 ++++++ 3 files changed, 15 insertions(+) diff --git a/backend/.env.sample b/backend/.env.sample index 7061c89..fdab70f 100644 --- a/backend/.env.sample +++ b/backend/.env.sample @@ -1,6 +1,9 @@ # Db address DB_ADDRESS=data/sqlite3.db +# Redis address, leave empty to disable Redis +REDIS_URL=rediss://example-redis.upstash.io + # JWT JWT_SECRET=not-dev-secret USER_TOKEN_EXPIRY=3600 diff --git a/backend/go.mod b/backend/go.mod index 1cec820..9f0d88b 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -22,6 +22,11 @@ require ( gorm.io/gorm v1.31.1 ) +require ( + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect +) + require ( cloud.google.com/go/compute/metadata v0.9.0 // indirect github.com/KyleBanks/depth v1.2.1 // indirect @@ -63,6 +68,7 @@ require ( github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/quic-go v0.58.0 // indirect + github.com/redis/go-redis/v9 v9.17.3 github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect diff --git a/backend/go.sum b/backend/go.sum index 771bd38..1a28d33 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -12,11 +12,15 @@ github.com/bytedance/sonic v1.14.2 h1:k1twIoe97C1DtYUo+fZQy865IuHia4PR5RPiuGPPII github.com/bytedance/sonic v1.14.2/go.mod h1:T80iDELeHiHKSc0C9tubFygiuXoGzrkjKzX2quAx980= github.com/bytedance/sonic/loader v0.4.0 h1:olZ7lEqcxtZygCK9EKYKADnpQoYkRQxaeY2NYzevs+o= github.com/bytedance/sonic/loader v0.4.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= @@ -125,6 +129,8 @@ github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= github.com/quic-go/quic-go v0.58.0 h1:ggY2pvZaVdB9EyojxL1p+5mptkuHyX5MOSv4dgWF4Ug= github.com/quic-go/quic-go v0.58.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/redis/go-redis/v9 v9.17.3 h1:fN29NdNrE17KttK5Ndf20buqfDZwGNgoUr9qjl1DQx4= +github.com/redis/go-redis/v9 v9.17.3/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/samber/slog-gin v1.19.0 h1:zwlPQhwvi3o1lufsfVSoyCaFHMWfUCsJD0mcb+1P6WQ= From b0e61072c1179113e1c799e19d35d545e867df81 Mon Sep 17 00:00:00 2001 From: Xin Feng <126309503+danielxfeng@users.noreply.github.com> Date: Tue, 27 Jan 2026 10:09:53 +0200 Subject: [PATCH 02/21] feat/backend: add config, and init redis --- backend/internal/config/config.go | 4 +++ backend/internal/db/redis.go | 45 +++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) create mode 100644 backend/internal/db/redis.go diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 482fdbe..a7216fd 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -17,6 +17,8 @@ type Config struct { FrontendUrl string TwoFaUrlPrefix string TwoFaTokenExpiry int + RedisURL string + IsRedisEnabled bool } var Cfg *Config @@ -55,5 +57,7 @@ func LoadConfig() { FrontendUrl: getEnvStrOrDefault("FRONTEND_URL", "http://localhost:5173"), TwoFaUrlPrefix: getEnvStrOrDefault("TWO_FA_URL_PREFIX", "otpauth://totp/Transcendence?secret="), TwoFaTokenExpiry: getEnvIntOrDefault("TWO_FA_TOKEN_EXPIRY", 600), + RedisURL: getEnvStrOrDefault("REDIS_URL", ""), + IsRedisEnabled: getEnvStrOrDefault("REDIS_URL", "") != "", } } diff --git a/backend/internal/db/redis.go b/backend/internal/db/redis.go new file mode 100644 index 0000000..df69801 --- /dev/null +++ b/backend/internal/db/redis.go @@ -0,0 +1,45 @@ +package db + +import ( + "context" + + "github.com/paularynty/transcendence/auth-service-go/internal/config" + "github.com/paularynty/transcendence/auth-service-go/internal/util" + "github.com/redis/go-redis/v9" +) + +var Redis *redis.Client + +// ConnectOptionalRedis connects to Redis and sets the global Redis client. +// +// If Redis is disabled via configuration, or if the connection attempt fails, +// Redis remains nil and config.Cfg.IsRedisEnabled is set to false. +func ConnectOptionalRedis(redisURL string) { + if !config.Cfg.IsRedisEnabled { + util.Logger.Info("redis is disabled by config") + return + } + + opt, err := redis.ParseURL(redisURL) + + if err != nil { + util.Logger.Error("failed to parse redis url", "err", err) + config.Cfg.IsRedisEnabled = false + return + } + + client := redis.NewClient(opt) + + ctx := context.Background() + + _, err = client.Ping(ctx).Result() + if err != nil { + util.Logger.Error("failed to connect to redis", "err", err) + config.Cfg.IsRedisEnabled = false + return + } + + Redis = client + + util.Logger.Info("connected to redis") +} \ No newline at end of file From e9f787b91796bcf3370c9c965563138ed7b8efab Mon Sep 17 00:00:00 2001 From: Xin Feng <126309503+danielxfeng@users.noreply.github.com> Date: Tue, 27 Jan 2026 10:42:42 +0200 Subject: [PATCH 03/21] fix/backend: panic when redis cannot be inited --- backend/internal/db/redis.go | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/backend/internal/db/redis.go b/backend/internal/db/redis.go index df69801..bcbbcce 100644 --- a/backend/internal/db/redis.go +++ b/backend/internal/db/redis.go @@ -10,11 +10,7 @@ import ( var Redis *redis.Client -// ConnectOptionalRedis connects to Redis and sets the global Redis client. -// -// If Redis is disabled via configuration, or if the connection attempt fails, -// Redis remains nil and config.Cfg.IsRedisEnabled is set to false. -func ConnectOptionalRedis(redisURL string) { +func ConnectRedis(redisURL string) { if !config.Cfg.IsRedisEnabled { util.Logger.Info("redis is disabled by config") return @@ -23,9 +19,7 @@ func ConnectOptionalRedis(redisURL string) { opt, err := redis.ParseURL(redisURL) if err != nil { - util.Logger.Error("failed to parse redis url", "err", err) - config.Cfg.IsRedisEnabled = false - return + panic("failed to parse redis url, err: " + err.Error()) } client := redis.NewClient(opt) @@ -34,9 +28,7 @@ func ConnectOptionalRedis(redisURL string) { _, err = client.Ping(ctx).Result() if err != nil { - util.Logger.Error("failed to connect to redis", "err", err) - config.Cfg.IsRedisEnabled = false - return + panic("failed to connect to redis: " + err.Error()) } Redis = client From fce4dcbaeb1929d64a2b74363fcfc90c7f4bc1d8 Mon Sep 17 00:00:00 2001 From: Xin Feng <126309503+danielxfeng@users.noreply.github.com> Date: Tue, 27 Jan 2026 12:29:40 +0200 Subject: [PATCH 04/21] feat/backend: integrate Redis for user token management and heartbeat --- backend/.env.sample | 4 +- backend/cmd/server/main.go | 3 + backend/internal/config/config.go | 54 ++++---- backend/internal/db/redis.go | 13 ++ backend/internal/routers/users_router.go | 2 +- backend/internal/service/friend_service.go | 3 +- backend/internal/service/helper.go | 144 ++++++++++++++++++++- backend/internal/service/user_service.go | 60 ++++++++- backend/internal/util/jwt/jwt.go | 9 +- 9 files changed, 253 insertions(+), 39 deletions(-) diff --git a/backend/.env.sample b/backend/.env.sample index fdab70f..e692901 100644 --- a/backend/.env.sample +++ b/backend/.env.sample @@ -6,9 +6,11 @@ REDIS_URL=rediss://example-redis.upstash.io # JWT JWT_SECRET=not-dev-secret -USER_TOKEN_EXPIRY=3600 +USER_TOKEN_EXPIRY=3600 # 1 hour TWO_FA_TOKEN_EXPIRY=600 OAUTH_STATE_TOKEN_EXPIRY=300 +# Sliding expiration is enabled under Redis mode, while this limits the longest possible expiry time. +USER_TOKEN_ABSOLUTE_EXPIRY=2592000 # 30 days # Google OAuth GOOGLE_CLIENT_ID=100000000-e3dsadsadsa321321.apps.googleusercontent.com diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index e1b550c..f5ea8b5 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -85,6 +85,9 @@ func main() { db.ConnectDB(config.Cfg.DbAddress) defer db.CloseDB() + db.ConnectRedis(config.Cfg.RedisURL) + defer db.CloseRedis() + // router r := SetupRouter(util.Logger) routers.UsersRouter(r.Group("/api/users")) diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index a7216fd..a2058ba 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -6,19 +6,20 @@ import ( ) type Config struct { - GinMode string - DbAddress string - JwtSecret string - UserTokenExpiry int - OauthStateTokenExpiry int - GoogleClientId string - GoogleClientSecret string - GoogleRedirectUri string - FrontendUrl string - TwoFaUrlPrefix string - TwoFaTokenExpiry int - RedisURL string - IsRedisEnabled bool + GinMode string + DbAddress string + JwtSecret string + UserTokenExpiry int + OauthStateTokenExpiry int + GoogleClientId string + GoogleClientSecret string + GoogleRedirectUri string + FrontendUrl string + TwoFaUrlPrefix string + TwoFaTokenExpiry int + RedisURL string + IsRedisEnabled bool + UserTokenAbsoluteExpiry int } var Cfg *Config @@ -46,18 +47,19 @@ func getEnvIntOrDefault(key string, defaultValue int) int { func LoadConfig() { Cfg = &Config{ - GinMode: getEnvStrOrDefault("GIN_MODE", "debug"), - DbAddress: getEnvStrOrDefault("DB_ADDRESS", "data/auth_service_db.sqlite"), - JwtSecret: getEnvStrOrDefault("JWT_SECRET", "test-secret"), - UserTokenExpiry: getEnvIntOrDefault("USER_TOKEN_EXPIRY", 3600), - OauthStateTokenExpiry: getEnvIntOrDefault("OAUTH_STATE_TOKEN_EXPIRY", 600), - GoogleClientId: getEnvStrOrDefault("GOOGLE_CLIENT_ID", "test-google-client-id"), - GoogleClientSecret: getEnvStrOrDefault("GOOGLE_CLIENT_SECRET", "test-google-client-secret"), - GoogleRedirectUri: getEnvStrOrDefault("GOOGLE_REDIRECT_URI", "test-google-redirect-uri"), - FrontendUrl: getEnvStrOrDefault("FRONTEND_URL", "http://localhost:5173"), - TwoFaUrlPrefix: getEnvStrOrDefault("TWO_FA_URL_PREFIX", "otpauth://totp/Transcendence?secret="), - TwoFaTokenExpiry: getEnvIntOrDefault("TWO_FA_TOKEN_EXPIRY", 600), - RedisURL: getEnvStrOrDefault("REDIS_URL", ""), - IsRedisEnabled: getEnvStrOrDefault("REDIS_URL", "") != "", + GinMode: getEnvStrOrDefault("GIN_MODE", "debug"), + DbAddress: getEnvStrOrDefault("DB_ADDRESS", "data/auth_service_db.sqlite"), + JwtSecret: getEnvStrOrDefault("JWT_SECRET", "test-secret"), + UserTokenExpiry: getEnvIntOrDefault("USER_TOKEN_EXPIRY", 3600), + OauthStateTokenExpiry: getEnvIntOrDefault("OAUTH_STATE_TOKEN_EXPIRY", 600), + GoogleClientId: getEnvStrOrDefault("GOOGLE_CLIENT_ID", "test-google-client-id"), + GoogleClientSecret: getEnvStrOrDefault("GOOGLE_CLIENT_SECRET", "test-google-client-secret"), + GoogleRedirectUri: getEnvStrOrDefault("GOOGLE_REDIRECT_URI", "test-google-redirect-uri"), + FrontendUrl: getEnvStrOrDefault("FRONTEND_URL", "http://localhost:5173"), + TwoFaUrlPrefix: getEnvStrOrDefault("TWO_FA_URL_PREFIX", "otpauth://totp/Transcendence?secret="), + TwoFaTokenExpiry: getEnvIntOrDefault("TWO_FA_TOKEN_EXPIRY", 600), + RedisURL: getEnvStrOrDefault("REDIS_URL", ""), + IsRedisEnabled: getEnvStrOrDefault("REDIS_URL", "") != "", + UserTokenAbsoluteExpiry: getEnvIntOrDefault("USER_TOKEN_ABSOLUTE_EXPIRY", 2592000), } } diff --git a/backend/internal/db/redis.go b/backend/internal/db/redis.go index bcbbcce..8d7fe24 100644 --- a/backend/internal/db/redis.go +++ b/backend/internal/db/redis.go @@ -34,4 +34,17 @@ func ConnectRedis(redisURL string) { Redis = client util.Logger.Info("connected to redis") +} + +func CloseRedis() { + if Redis == nil { + return + } + + err := Redis.Close() + if err != nil { + util.Logger.Error("failed to close redis connection", "error", err) + } else { + util.Logger.Info("redis connection closed") + } } \ No newline at end of file diff --git a/backend/internal/routers/users_router.go b/backend/internal/routers/users_router.go index facd551..9ca89f3 100644 --- a/backend/internal/routers/users_router.go +++ b/backend/internal/routers/users_router.go @@ -11,7 +11,7 @@ import ( ) func UsersRouter(r *gin.RouterGroup) { - h := &handler.UserHandler{Service: service.NewUserService(db.DB)} + h := &handler.UserHandler{Service: service.NewUserService(db.DB, db.Redis)} // Public endpoints r.POST("/", middleware.ValidateBody[dto.CreateUserRequest](), h.CreateUserHandler) diff --git a/backend/internal/service/friend_service.go b/backend/internal/service/friend_service.go index cd7ab7f..c0a41a1 100644 --- a/backend/internal/service/friend_service.go +++ b/backend/internal/service/friend_service.go @@ -3,7 +3,6 @@ package service import ( "context" "errors" - "time" model "github.com/paularynty/transcendence/auth-service-go/internal/db" "github.com/paularynty/transcendence/auth-service-go/internal/dto" @@ -31,7 +30,7 @@ func (s *UserService) GetUserFriends(ctx context.Context, userID uint) ([]dto.Fr return nil, err } - onlineStatus, err := gorm.G[model.HeartBeat](s.DB).Where("last_seen_at > ?", time.Now().Add(-2*time.Minute)).Find(ctx) + onlineStatus, err := s.getOnlineStatus(ctx) if err != nil { return nil, err } diff --git a/backend/internal/service/helper.go b/backend/internal/service/helper.go index cc91742..f37c15e 100644 --- a/backend/internal/service/helper.go +++ b/backend/internal/service/helper.go @@ -3,25 +3,35 @@ package service import ( "context" "fmt" + "strconv" "strings" "time" + "github.com/paularynty/transcendence/auth-service-go/internal/config" model "github.com/paularynty/transcendence/auth-service-go/internal/db" "github.com/paularynty/transcendence/auth-service-go/internal/dto" "github.com/paularynty/transcendence/auth-service-go/internal/util" "github.com/paularynty/transcendence/auth-service-go/internal/util/jwt" + "github.com/redis/go-redis/v9" "gorm.io/gorm" "gorm.io/gorm/clause" ) -func NewUserService(db *gorm.DB) *UserService { +const HeartBeatPrefix = "heartbeat:" + +func NewUserService(db *gorm.DB, redis *redis.Client) *UserService { if db == nil { panic("UserService: db is nil") } + if config.Cfg.IsRedisEnabled && redis == nil { + panic("UserService: redis is enabled but redis client is nil") + } + return &UserService{ - DB: db, + DB: db, + Redis: redis, } } @@ -87,7 +97,7 @@ func (os *onlineStatusChecker) isOnline(userID uint) bool { return exists } -func (s *UserService) updateHeartBeat(userID uint) { +func (s *UserService) updateHeartBeatByDB(userID uint) { go func() { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() @@ -106,7 +116,94 @@ func (s *UserService) updateHeartBeat(userID uint) { }() } -func (s *UserService) issueNewTokenForUser(ctx context.Context, userID uint, revokeAllTokens bool) (string, error) { +func (s *UserService) updateHeartBeatByRedis(userID uint) { + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + err := s.Redis.ZAdd(ctx, HeartBeatPrefix, redis.Z{ + Score: float64(time.Now().Unix()), + Member: userID, + }).Err() + + if err != nil { + util.Logger.Warn("failed to update heartbeat for user", fmt.Sprint(userID), err.Error()) + } + }() +} + +func (s *UserService) updateHeartBeat(userID uint) { + if config.Cfg.IsRedisEnabled { + s.updateHeartBeatByRedis(userID) + } else { + s.updateHeartBeatByDB(userID) + } +} + +func (s *UserService) getOnlineStatusByDB(ctx context.Context) ([]model.HeartBeat, error) { + onlineStatus, err := gorm.G[model.HeartBeat](s.DB).Where("last_seen_at > ?", time.Now().Add(-2*time.Minute)).Find(ctx) + if err != nil { + return nil, err + } + + return onlineStatus, nil +} + +func (s *UserService) clearExpiredHeartBeatsByRedis() { + + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + err := s.Redis.ZRemRangeByScore(ctx, HeartBeatPrefix, "-inf", strconv.FormatInt(time.Now().Add(-2*time.Minute).Unix(), 10)).Err() + if err != nil { + util.Logger.Warn("failed to clear expired heartbeats from redis", "err", err) + } + }() +} + +func (s *UserService) getOnlineStatusByRedis(ctx context.Context) ([]model.HeartBeat, error) { + heartBeats := make([]model.HeartBeat, 0) + + zs, err := s.Redis.ZRangeByScoreWithScores(ctx, HeartBeatPrefix, &redis.ZRangeBy{ + Min: strconv.FormatInt(time.Now().Add(-2*time.Minute).Unix(), 10), + Max: "+inf", + }).Result() + if err != nil { + return nil, err + } + + for _, z := range zs { + userID, err := strconv.ParseUint(fmt.Sprint(z.Member), 10, 64) + if err != nil { + return nil, err + } + + heartBeats = append(heartBeats, model.HeartBeat{ + UserID: uint(userID), + LastSeenAt: time.Unix(int64(z.Score), 0), + }) + } + + // Async clear expired heartbeats + s.clearExpiredHeartBeatsByRedis() + + return heartBeats, nil +} + +func (s *UserService) getOnlineStatus(ctx context.Context) ([]model.HeartBeat, error) { + if config.Cfg.IsRedisEnabled { + return s.getOnlineStatusByRedis(ctx) + } else { + return s.getOnlineStatusByDB(ctx) + } +} + +func buildTokenKey(userID uint, token string) string { + return fmt.Sprintf("user_token:%d:%s", userID, token) +} + +func (s *UserService) issueNewTokenForUserByDB(ctx context.Context, userID uint, revokeAllTokens bool) (string, error) { if revokeAllTokens { res := s.DB.WithContext(ctx).Exec("DELETE FROM tokens WHERE user_id = ?", userID) @@ -132,3 +229,42 @@ func (s *UserService) issueNewTokenForUser(ctx context.Context, userID uint, rev return token, nil } + +func (s *UserService) issueNewTokenForUserByRedis(ctx context.Context, userID uint, revokeAllTokens bool) (string, error) { + + if revokeAllTokens { + // A rough way to delete all tokens for the user + iter := s.Redis.Scan(ctx, 0, buildTokenKey(userID, "*"), 100).Iterator() + for iter.Next(ctx) { + err := s.Redis.Del(ctx, iter.Val()).Err() + if err != nil { + return "", err + } + } + if err := iter.Err(); err != nil { + return "", err + } + } + + token, err := jwt.SignUserToken(userID) + if err != nil { + return "", err + } + + err = s.Redis.Set(ctx, buildTokenKey(userID, token), "", time.Duration(config.Cfg.UserTokenExpiry)*time.Second).Err() + if err != nil { + return "", err + } + + s.updateHeartBeat(userID) + + return token, nil +} + +func (s *UserService) issueNewTokenForUser(ctx context.Context, userID uint, revokeAllTokens bool) (string, error) { + if config.Cfg.IsRedisEnabled { + return s.issueNewTokenForUserByRedis(ctx, userID, revokeAllTokens) + } else { + return s.issueNewTokenForUserByDB(ctx, userID, revokeAllTokens) + } +} diff --git a/backend/internal/service/user_service.go b/backend/internal/service/user_service.go index 39eea67..835411b 100644 --- a/backend/internal/service/user_service.go +++ b/backend/internal/service/user_service.go @@ -4,14 +4,17 @@ import ( "context" "errors" "strings" + "time" "golang.org/x/crypto/bcrypt" "gorm.io/gorm" + "github.com/paularynty/transcendence/auth-service-go/internal/config" model "github.com/paularynty/transcendence/auth-service-go/internal/db" "github.com/paularynty/transcendence/auth-service-go/internal/dto" "github.com/paularynty/transcendence/auth-service-go/internal/middleware" "github.com/paularynty/transcendence/auth-service-go/internal/util/jwt" + "github.com/redis/go-redis/v9" ) const TwoFAPrePrefix = "pre-" @@ -20,7 +23,8 @@ const MaxAvatarSize = 1 * 1024 * 1024 // 1 MB const BaseGoogleOAuthURL = "https://accounts.google.com/o/oauth2/v2/auth" type UserService struct { - DB *gorm.DB + DB *gorm.DB + Redis *redis.Client } func (s *UserService) CreateUser(ctx context.Context, request *dto.CreateUserRequest) (*dto.UserWithoutTokenResponse, error) { @@ -192,8 +196,8 @@ func (s *UserService) DeleteUser(ctx context.Context, userID uint) error { return nil } -func (s *UserService) LogoutUser(ctx context.Context, userID uint) error { - _, err := gorm.G[model.Token](s.DB.Unscoped()).Where("user_id = ?", userID).Delete(ctx) +func logoutUserByDB(ctx context.Context, db *gorm.DB, userID uint) error { + _, err := gorm.G[model.Token](db.Unscoped()).Where("user_id = ?", userID).Delete(ctx) if err != nil { return err } @@ -201,7 +205,31 @@ func (s *UserService) LogoutUser(ctx context.Context, userID uint) error { return nil } -func (s *UserService) ValidateUserToken(ctx context.Context, token string, userId uint) error { +func logoutUserByRedis(ctx context.Context, redis *redis.Client, userID uint) error { + + iter := redis.Scan(ctx, 0, buildTokenKey(userID, "*"), 100).Iterator() + for iter.Next(ctx) { + err := redis.Del(ctx, iter.Val()).Err() + if err != nil { + return err + } + } + if err := iter.Err(); err != nil { + return err + } + + return nil +} + +func (s *UserService) LogoutUser(ctx context.Context, userID uint) error { + if config.Cfg.IsRedisEnabled { + return logoutUserByRedis(ctx, s.Redis, userID) + } else { + return logoutUserByDB(ctx, s.DB, userID) + } +} + +func (s *UserService) validateUserTokenDB(ctx context.Context, token string, userId uint) error { modelToken, err := gorm.G[model.Token](s.DB).Where("token = ?", token).First(ctx) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { @@ -217,3 +245,27 @@ func (s *UserService) ValidateUserToken(ctx context.Context, token string, userI s.updateHeartBeat(userId) return nil } + +func (s *UserService) validateUserTokenRedis(ctx context.Context, token string, userId uint) error { + _, err := s.Redis.Get(ctx, buildTokenKey(userId, token)).Result() + if err != nil { + if errors.Is(err, redis.Nil) { + return middleware.NewAuthError(401, "invalid token") + } + return err + } + + // A rough way to implement sliding expiration + s.Redis.Expire(ctx, buildTokenKey(userId, token), time.Duration(config.Cfg.UserTokenExpiry)*time.Second) + + s.updateHeartBeat(userId) + return nil +} + +func (s *UserService) ValidateUserToken(ctx context.Context, token string, userId uint) error { + if config.Cfg.IsRedisEnabled { + return s.validateUserTokenRedis(ctx, token, userId) + } else { + return s.validateUserTokenDB(ctx, token, userId) + } +} diff --git a/backend/internal/util/jwt/jwt.go b/backend/internal/util/jwt/jwt.go index 1ea435e..8f76365 100644 --- a/backend/internal/util/jwt/jwt.go +++ b/backend/internal/util/jwt/jwt.go @@ -26,10 +26,17 @@ func generateRegisteredClaims(expiration int) libjwt.RegisteredClaims { } func SignUserToken(userID uint) (string, error) { + userTokenExpiry := config.Cfg.UserTokenExpiry + // For Redis mode, use absolute expiry to limit max token lifetime, + // because the actual expiry is managed in Redis with sliding expiration. + if config.Cfg.IsRedisEnabled { + userTokenExpiry = config.Cfg.UserTokenAbsoluteExpiry + } + claims := dto.UserJwtPayload{ UserID: userID, Type: UserTokenType, - RegisteredClaims: generateRegisteredClaims(config.Cfg.UserTokenExpiry), + RegisteredClaims: generateRegisteredClaims(userTokenExpiry), } token := libjwt.NewWithClaims(libjwt.SigningMethodHS256, claims) From 0b678341c87564d2f836dbe0e3bbef8feddda7a2 Mon Sep 17 00:00:00 2001 From: Xin Feng <126309503+danielxfeng@users.noreply.github.com> Date: Tue, 27 Jan 2026 12:33:33 +0200 Subject: [PATCH 05/21] fix/backend: fix the tests for redis --- .../routers/users_router_failure_test.go | 8 ++--- backend/internal/routers/users_router_test.go | 6 ++-- .../internal/service/friend_service_test.go | 6 ++-- .../service/google_oauth_service_test.go | 16 +++++----- backend/internal/service/helper_test.go | 6 ++-- .../internal/service/twofa_service_test.go | 32 +++++++++---------- backend/internal/service/user_service_test.go | 32 +++++++++---------- 7 files changed, 53 insertions(+), 53 deletions(-) diff --git a/backend/internal/routers/users_router_failure_test.go b/backend/internal/routers/users_router_failure_test.go index 1f61a93..0a767d9 100644 --- a/backend/internal/routers/users_router_failure_test.go +++ b/backend/internal/routers/users_router_failure_test.go @@ -146,7 +146,7 @@ func TestUsersRouter_LoginUser_Failures(t *testing.T) { // 3. Invalid Credentials // Create user - svc := service.NewUserService(model.DB) + svc := service.NewUserService(model.DB, nil) _, _ = svc.CreateUser(context.Background(), &dto.CreateUserRequest{ User: dto.User{UserName: dto.UserName{Username: "loginfail"}, Email: "fail@e.com"}, Password: dto.Password{Password: "correct"}, @@ -168,7 +168,7 @@ func TestUsersRouter_UpdateUser_Failures(t *testing.T) { router, cleanup := setupUsersRouterTestFailure(t) defer cleanup() - svc := service.NewUserService(model.DB) + svc := service.NewUserService(model.DB, nil) u, _ := svc.CreateUser(context.Background(), &dto.CreateUserRequest{ User: dto.User{UserName: dto.UserName{Username: "u1"}, Email: "u1@e.com"}, Password: dto.Password{Password: "pass"}, @@ -216,7 +216,7 @@ func TestUsersRouter_Friends_Failures(t *testing.T) { router, cleanup := setupUsersRouterTestFailure(t) defer cleanup() - svc := service.NewUserService(model.DB) + svc := service.NewUserService(model.DB, nil) u1, _ := svc.CreateUser(context.Background(), &dto.CreateUserRequest{ User: dto.User{UserName: dto.UserName{Username: "f1"}, Email: "f1@e.com"}, Password: dto.Password{Password: "pass"}, @@ -274,7 +274,7 @@ func TestUsersRouter_2FA_Failures(t *testing.T) { router, cleanup := setupUsersRouterTestFailure(t) defer cleanup() - svc := service.NewUserService(model.DB) + svc := service.NewUserService(model.DB, nil) u, _ := svc.CreateUser(context.Background(), &dto.CreateUserRequest{ User: dto.User{UserName: dto.UserName{Username: "2fafail"}, Email: "2fafail@e.com"}, Password: dto.Password{Password: "pass"}, diff --git a/backend/internal/routers/users_router_test.go b/backend/internal/routers/users_router_test.go index bfd97d6..338547b 100644 --- a/backend/internal/routers/users_router_test.go +++ b/backend/internal/routers/users_router_test.go @@ -237,7 +237,7 @@ func TestUsersRouter_UpdateUserPassword(t *testing.T) { router, cleanup := setupUsersRouterTestUnique(t) defer cleanup() - svc := service.NewUserService(model.DB) + svc := service.NewUserService(model.DB, nil) userResp, _ := svc.CreateUser(context.Background(), &dto.CreateUserRequest{ User: dto.User{UserName: dto.UserName{Username: "pw"}, Email: "pw@e.com"}, Password: dto.Password{Password: "oldpass"}, @@ -336,7 +336,7 @@ func TestUsersRouter_Friends(t *testing.T) { router, cleanup := setupUsersRouterTestUnique(t) defer cleanup() - svc := service.NewUserService(model.DB) + svc := service.NewUserService(model.DB, nil) u1, _ := svc.CreateUser(context.Background(), &dto.CreateUserRequest{ User: dto.User{UserName: dto.UserName{Username: "f1"}, Email: "f1@e.com"}, Password: dto.Password{Password: "p"}, @@ -382,7 +382,7 @@ func TestUsersRouter_2FA(t *testing.T) { router, cleanup := setupUsersRouterTestUnique(t) defer cleanup() - svc := service.NewUserService(model.DB) + svc := service.NewUserService(model.DB, nil) user, _ := svc.CreateUser(context.Background(), &dto.CreateUserRequest{ User: dto.User{UserName: dto.UserName{Username: "2fa"}, Email: "2fa@e.com"}, Password: dto.Password{Password: "pass"}, diff --git a/backend/internal/service/friend_service_test.go b/backend/internal/service/friend_service_test.go index 4a65641..8d16b2d 100644 --- a/backend/internal/service/friend_service_test.go +++ b/backend/internal/service/friend_service_test.go @@ -12,7 +12,7 @@ import ( func TestGetAllUsersLimitedInfo(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) ctx := context.Background() // Create users @@ -47,7 +47,7 @@ func TestGetAllUsersLimitedInfo(t *testing.T) { func TestAddNewFriend(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) ctx := context.Background() u1, _ := svc.CreateUser(ctx, &dto.CreateUserRequest{ @@ -119,7 +119,7 @@ func TestAddNewFriend(t *testing.T) { func TestGetUserFriends(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) ctx := context.Background() u1, _ := svc.CreateUser(ctx, &dto.CreateUserRequest{ diff --git a/backend/internal/service/google_oauth_service_test.go b/backend/internal/service/google_oauth_service_test.go index 67db420..87f373a 100644 --- a/backend/internal/service/google_oauth_service_test.go +++ b/backend/internal/service/google_oauth_service_test.go @@ -16,7 +16,7 @@ import ( func TestGetGoogleOAuthURL(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) ctx := context.Background() t.Run("Success", func(t *testing.T) { @@ -45,7 +45,7 @@ func TestGetGoogleOAuthURL(t *testing.T) { func TestHandleGoogleOAuthCallback_InvalidState(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) ctx := context.Background() // Helper to parse redirect URL @@ -80,7 +80,7 @@ func TestHandleGoogleOAuthCallback_InvalidState(t *testing.T) { func TestHandleGoogleOAuthCallback_Success(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) ctx := context.Background() // Mock dependencies @@ -142,7 +142,7 @@ func TestHandleGoogleOAuthCallback_Success(t *testing.T) { func TestHandleGoogleOAuthCallback_Errors(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) ctx := context.Background() origExchange := ExchangeCodeForTokens @@ -184,7 +184,7 @@ func TestHandleGoogleOAuthCallback_Errors(t *testing.T) { func TestLinkGoogleAccountToExistingUser(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) ctx := context.Background() u, _ := svc.CreateUser(ctx, &dto.CreateUserRequest{ @@ -247,7 +247,7 @@ func TestLinkGoogleAccountToExistingUser(t *testing.T) { func TestCreateNewUserFromGoogleInfo(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) ctx := context.Background() t.Run("Success", func(t *testing.T) { @@ -297,7 +297,7 @@ func TestCreateNewUserFromGoogleInfo(t *testing.T) { func TestHandleGoogleOAuthCallback_DBError(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) ctx := context.Background() origExchange := ExchangeCodeForTokens @@ -333,7 +333,7 @@ func TestHandleGoogleOAuthCallback_DBError(t *testing.T) { func TestHandleGoogleOAuthCallback_LinkError(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) ctx := context.Background() origExchange := ExchangeCodeForTokens diff --git a/backend/internal/service/helper_test.go b/backend/internal/service/helper_test.go index f37ee6e..863d331 100644 --- a/backend/internal/service/helper_test.go +++ b/backend/internal/service/helper_test.go @@ -61,7 +61,7 @@ func TestHelperFunctions(t *testing.T) { t.Run("UpdateHeartBeat", func(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) // Create user first to satisfy FK _, _ = svc.CreateUser(context.Background(), &dto.CreateUserRequest{ @@ -83,7 +83,7 @@ func TestHelperFunctions(t *testing.T) { t.Run("IssueNewTokenForUser", func(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) // Create user first _, _ = svc.CreateUser(context.Background(), &dto.CreateUserRequest{ @@ -113,7 +113,7 @@ func TestHelperFunctions(t *testing.T) { t.Run("IssueNewTokenForUser_DBError", func(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) sqlDB, _ := db.DB() _ = sqlDB.Close() diff --git a/backend/internal/service/twofa_service_test.go b/backend/internal/service/twofa_service_test.go index c51407a..3c22ac7 100644 --- a/backend/internal/service/twofa_service_test.go +++ b/backend/internal/service/twofa_service_test.go @@ -16,7 +16,7 @@ func TestTwoFASetupAndConfirm(t *testing.T) { t.Run("StartSetup_Success", func(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) u, _ := svc.CreateUser(ctx, &dto.CreateUserRequest{ User: dto.User{UserName: dto.UserName{Username: "u1"}, Email: "u1@e.com"}, Password: dto.Password{Password: "p"}, @@ -33,7 +33,7 @@ func TestTwoFASetupAndConfirm(t *testing.T) { t.Run("ConfirmSetup_Success", func(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) u, _ := svc.CreateUser(ctx, &dto.CreateUserRequest{ User: dto.User{UserName: dto.UserName{Username: "u2"}, Email: "u2@e.com"}, Password: dto.Password{Password: "p"}, @@ -61,7 +61,7 @@ func TestTwoFASetupAndConfirm(t *testing.T) { t.Run("StartSetup_AlreadyEnabled", func(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) u, _ := svc.CreateUser(ctx, &dto.CreateUserRequest{ User: dto.User{UserName: dto.UserName{Username: "u3"}, Email: "u3@e.com"}, Password: dto.Password{Password: "p"}, @@ -85,7 +85,7 @@ func TestTwoFASetupAndConfirm(t *testing.T) { t.Run("StartSetup_OAuthUser", func(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) // Mock OAuth user oauthUser := dto.GoogleUserData{ ID: "oauth123", @@ -108,7 +108,7 @@ func TestTwoFASetupAndConfirm(t *testing.T) { t.Run("StartSetup_DBError", func(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) u, _ := svc.CreateUser(ctx, &dto.CreateUserRequest{ User: dto.User{UserName: dto.UserName{Username: "u4"}, Email: "u4@e.com"}, Password: dto.Password{Password: "p"}, @@ -125,7 +125,7 @@ func TestTwoFASetupAndConfirm(t *testing.T) { func TestConfirmTwoFaSetup_Errors(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) ctx := context.Background() u, _ := svc.CreateUser(ctx, &dto.CreateUserRequest{ @@ -193,7 +193,7 @@ func TestConfirmTwoFaSetup_Errors(t *testing.T) { t.Run("NotInitiated", func(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) // User with no 2FA token u, _ := svc.CreateUser(ctx, &dto.CreateUserRequest{ User: dto.User{UserName: dto.UserName{Username: "ni"}, Email: "ni@e.com"}, @@ -221,7 +221,7 @@ func TestTwoFAChallenge(t *testing.T) { t.Run("Success", func(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) u, _ := svc.CreateUser(ctx, &dto.CreateUserRequest{ User: dto.User{UserName: dto.UserName{Username: "ch1"}, Email: "ch1@e.com"}, Password: dto.Password{Password: "p"}, @@ -253,7 +253,7 @@ func TestTwoFAChallenge(t *testing.T) { t.Run("InvalidCode", func(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) u, _ := svc.CreateUser(ctx, &dto.CreateUserRequest{ User: dto.User{UserName: dto.UserName{Username: "ch2"}, Email: "ch2@e.com"}, Password: dto.Password{Password: "p"}, @@ -281,7 +281,7 @@ func TestTwoFAChallenge(t *testing.T) { t.Run("NotEnabled", func(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) u, _ := svc.CreateUser(ctx, &dto.CreateUserRequest{ User: dto.User{UserName: dto.UserName{Username: "chne"}, Email: "chne@e.com"}, Password: dto.Password{Password: "p"}, @@ -304,7 +304,7 @@ func TestTwoFAChallenge(t *testing.T) { t.Run("DBError", func(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) u, _ := svc.CreateUser(ctx, &dto.CreateUserRequest{ User: dto.User{UserName: dto.UserName{Username: "ch3"}, Email: "ch3@e.com"}, Password: dto.Password{Password: "p"}, @@ -338,7 +338,7 @@ func TestDisableTwoFA(t *testing.T) { t.Run("Success", func(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) u, _ := svc.CreateUser(ctx, &dto.CreateUserRequest{ User: dto.User{UserName: dto.UserName{Username: "dis1"}, Email: "dis1@e.com"}, Password: dto.Password{Password: "p"}, @@ -361,7 +361,7 @@ func TestDisableTwoFA(t *testing.T) { t.Run("AlreadyDisabled", func(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) u, _ := svc.CreateUser(ctx, &dto.CreateUserRequest{ User: dto.User{UserName: dto.UserName{Username: "dis2"}, Email: "dis2@e.com"}, Password: dto.Password{Password: "p"}, @@ -378,7 +378,7 @@ func TestDisableTwoFA(t *testing.T) { t.Run("OAuthUser", func(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) // Mock OAuth user oauthUser := dto.GoogleUserData{ ID: "oauth456", @@ -401,7 +401,7 @@ func TestDisableTwoFA(t *testing.T) { t.Run("DBError", func(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) u, _ := svc.CreateUser(ctx, &dto.CreateUserRequest{ User: dto.User{UserName: dto.UserName{Username: "dis3"}, Email: "dis3@e.com"}, Password: dto.Password{Password: "p"}, @@ -421,7 +421,7 @@ func TestDisableTwoFA(t *testing.T) { t.Run("InvalidPassword", func(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) u, _ := svc.CreateUser(ctx, &dto.CreateUserRequest{ User: dto.User{UserName: dto.UserName{Username: "disinv"}, Email: "disinv@e.com"}, Password: dto.Password{Password: "correct"}, diff --git a/backend/internal/service/user_service_test.go b/backend/internal/service/user_service_test.go index 6971642..540196a 100644 --- a/backend/internal/service/user_service_test.go +++ b/backend/internal/service/user_service_test.go @@ -12,7 +12,7 @@ import ( func TestCreateUser(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) ctx := context.Background() t.Run("Success", func(t *testing.T) { @@ -83,7 +83,7 @@ func TestCreateUser(t *testing.T) { func TestLoginUser(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) ctx := context.Background() // Setup user @@ -232,7 +232,7 @@ func TestLoginUser(t *testing.T) { func TestGetUserByID(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) ctx := context.Background() u, _ := svc.CreateUser(ctx, &dto.CreateUserRequest{ @@ -267,7 +267,7 @@ func TestGetUserByID(t *testing.T) { func TestUpdateUserPassword(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) ctx := context.Background() u, _ := svc.CreateUser(ctx, &dto.CreateUserRequest{ @@ -369,7 +369,7 @@ func TestUpdateUserPassword(t *testing.T) { func TestUpdateUserProfile(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) ctx := context.Background() u, _ := svc.CreateUser(ctx, &dto.CreateUserRequest{ @@ -429,7 +429,7 @@ func TestUpdateUserProfile(t *testing.T) { func TestDeleteUser(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) ctx := context.Background() u, _ := svc.CreateUser(ctx, &dto.CreateUserRequest{ @@ -455,7 +455,7 @@ func TestDeleteUser(t *testing.T) { func TestValidateUserToken(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) ctx := context.Background() createReq := &dto.CreateUserRequest{ @@ -512,7 +512,7 @@ func TestValidateUserToken(t *testing.T) { func TestLogoutUser(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) ctx := context.Background() createReq := &dto.CreateUserRequest{ @@ -547,7 +547,7 @@ func TestDBErrors(t *testing.T) { t.Run("CreateUser", func(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) req := &dto.CreateUserRequest{ User: dto.User{UserName: dto.UserName{Username: "db1"}, Email: "db1@e.com"}, @@ -565,7 +565,7 @@ func TestDBErrors(t *testing.T) { t.Run("LoginUser", func(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) req := &dto.LoginUserRequest{ Identifier: dto.Identifier{Identifier: "db1"}, @@ -583,7 +583,7 @@ func TestDBErrors(t *testing.T) { t.Run("GetUserByID", func(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) sqlDB, _ := db.DB() _ = sqlDB.Close() @@ -596,7 +596,7 @@ func TestDBErrors(t *testing.T) { t.Run("UpdateUserPassword", func(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) req := &dto.UpdateUserPasswordRequest{ OldPassword: dto.OldPassword{OldPassword: "p"}, @@ -614,7 +614,7 @@ func TestDBErrors(t *testing.T) { t.Run("UpdateUserProfile", func(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) req := &dto.UpdateUserRequest{ User: dto.User{UserName: dto.UserName{Username: "n"}, Email: "n@e.com"}, @@ -631,7 +631,7 @@ func TestDBErrors(t *testing.T) { t.Run("DeleteUser", func(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) sqlDB, _ := db.DB() _ = sqlDB.Close() @@ -644,7 +644,7 @@ func TestDBErrors(t *testing.T) { t.Run("ValidateUserToken", func(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) sqlDB, _ := db.DB() _ = sqlDB.Close() @@ -657,7 +657,7 @@ func TestDBErrors(t *testing.T) { t.Run("LogoutUser", func(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) sqlDB, _ := db.DB() _ = sqlDB.Close() From 3964c1bcbb6718c55393f2918929aa6a3b167740 Mon Sep 17 00:00:00 2001 From: Xin Feng <126309503+danielxfeng@users.noreply.github.com> Date: Tue, 27 Jan 2026 12:56:08 +0200 Subject: [PATCH 06/21] test/backend: add some tests for redis integration --- backend/go.mod | 2 + backend/go.sum | 4 + .../routers/users_router_redis_test.go | 187 +++++++++++++++++ .../internal/service/redis_service_test.go | 194 ++++++++++++++++++ backend/internal/service/setup_test.go | 46 ++++- 5 files changed, 424 insertions(+), 9 deletions(-) create mode 100644 backend/internal/routers/users_router_redis_test.go create mode 100644 backend/internal/service/redis_service_test.go diff --git a/backend/go.mod b/backend/go.mod index 9f0d88b..66f7d53 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -23,8 +23,10 @@ require ( ) require ( + github.com/alicebob/miniredis/v2 v2.36.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/yuin/gopher-lua v1.1.1 // indirect ) require ( diff --git a/backend/go.sum b/backend/go.sum index 1a28d33..1bb3b96 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -4,6 +4,8 @@ cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdB cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= +github.com/alicebob/miniredis/v2 v2.36.1 h1:Dvc5oAnNOr7BIfPn7tF269U8DvRW1dBG2D5n0WrfYMI= +github.com/alicebob/miniredis/v2 v2.36.1/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI= github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= @@ -157,6 +159,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2 github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= +github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= diff --git a/backend/internal/routers/users_router_redis_test.go b/backend/internal/routers/users_router_redis_test.go new file mode 100644 index 0000000..daf0dff --- /dev/null +++ b/backend/internal/routers/users_router_redis_test.go @@ -0,0 +1,187 @@ +package routers + +import ( + "bytes" + "context" + "encoding/json" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "strconv" + "strings" + "testing" + "time" + + "github.com/alicebob/miniredis/v2" + "github.com/gin-gonic/gin" + "github.com/redis/go-redis/v9" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + + "github.com/paularynty/transcendence/auth-service-go/internal/config" + db "github.com/paularynty/transcendence/auth-service-go/internal/db" + "github.com/paularynty/transcendence/auth-service-go/internal/dto" + "github.com/paularynty/transcendence/auth-service-go/internal/util" +) + +func setupUsersRouterTestRedis(t *testing.T) (*gin.Engine, *miniredis.Miniredis, func()) { + t.Helper() + gin.SetMode(gin.TestMode) + + util.Logger = slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ + Level: slog.LevelError, + })) + + prevCfg := config.Cfg + config.Cfg = &config.Config{ + JwtSecret: "test-secret", + UserTokenExpiry: 60, + UserTokenAbsoluteExpiry: 600, + TwoFaTokenExpiry: 3600, + OauthStateTokenExpiry: 3600, + GoogleClientId: "test-client", + GoogleRedirectUri: "http://localhost/cb", + FrontendUrl: "http://localhost:3000", + IsRedisEnabled: true, + } + dto.InitValidator() + + // DB setup matches existing patterns. + dbName := "file:" + strings.ReplaceAll(t.Name(), "/", "_") + "?mode=memory&cache=shared&_busy_timeout=5000&_foreign_keys=on" + var err error + db.DB, err = gorm.Open(sqlite.Open(dbName), &gorm.Config{TranslateError: true}) + if err != nil { + t.Fatalf("failed to connect to db: %v", err) + } + db.DB.Exec("PRAGMA foreign_keys = ON") + + err = db.DB.AutoMigrate(&db.User{}, &db.Friend{}, &db.Token{}, &db.HeartBeat{}) + if err != nil { + t.Fatalf("failed to migrate db: %v", err) + } + + // Redis setup. + mr := miniredis.RunT(t) + redisClient := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + db.Redis = redisClient + config.Cfg.RedisURL = "redis://" + mr.Addr() + + router := gin.New() + UsersRouter(router.Group("/users")) + + if db.DB != nil { + sqlDB, _ := db.DB.DB() + if sqlDB != nil { + sqlDB.SetMaxOpenConns(1) + } + } + + cleanup := func() { + config.Cfg = prevCfg + if db.Redis != nil { + _ = db.Redis.Close() + db.Redis = nil + } + mr.Close() + if db.DB != nil { + sqlDB, _ := db.DB.DB() + if sqlDB != nil { + _ = sqlDB.Close() + } + db.DB = nil + } + } + + return router, mr, cleanup +} + +func TestUsersRouter_Redis_LoginValidateLogout(t *testing.T) { + router, mr, cleanup := setupUsersRouterTestRedis(t) + defer cleanup() + + // Create user + createReq := dto.CreateUserRequest{ + User: dto.User{UserName: dto.UserName{Username: "redisrouter"}, Email: "redisrouter@example.com"}, + Password: dto.Password{Password: "password123"}, + } + createBody, _ := json.Marshal(createReq) + createResp := httptest.NewRecorder() + createHTTP := httptest.NewRequest(http.MethodPost, "/users/", bytes.NewBuffer(createBody)) + createHTTP.Header.Set("Content-Type", "application/json") + router.ServeHTTP(createResp, createHTTP) + if createResp.Code != http.StatusCreated { + t.Fatalf("expected 201 on create, got %d. Body: %s", createResp.Code, createResp.Body.String()) + } + + // Login user + loginReq := dto.LoginUserRequest{ + Identifier: dto.Identifier{Identifier: "redisrouter"}, + Password: dto.Password{Password: "password123"}, + } + loginBody, _ := json.Marshal(loginReq) + loginResp := httptest.NewRecorder() + loginHTTP := httptest.NewRequest(http.MethodPost, "/users/loginByIdentifier", bytes.NewBuffer(loginBody)) + loginHTTP.Header.Set("Content-Type", "application/json") + router.ServeHTTP(loginResp, loginHTTP) + if loginResp.Code != http.StatusOK { + t.Fatalf("expected 200 on login, got %d. Body: %s", loginResp.Code, loginResp.Body.String()) + } + + var loginUser dto.UserWithTokenResponse + _ = json.Unmarshal(loginResp.Body.Bytes(), &loginUser) + if loginUser.Token == "" || loginUser.ID == 0 { + t.Fatalf("expected login token and id, got: %+v", loginUser) + } + + // Ensure token is stored in Redis (by key prefix). + keys := mr.Keys() + wantPrefix := "user_token:" + strconv.FormatUint(uint64(loginUser.ID), 10) + ":" + foundTokenKey := false + for _, k := range keys { + if strings.HasPrefix(k, wantPrefix) { + foundTokenKey = true + break + } + } + if !foundTokenKey { + t.Fatalf("expected redis token key with prefix %q, keys: %v", wantPrefix, keys) + } + + // Login should update heartbeat in Redis. + time.Sleep(100 * time.Millisecond) + score, err := db.Redis.ZScore(context.Background(), "heartbeat:", strconv.FormatUint(uint64(loginUser.ID), 10)).Result() + if err != nil { + t.Fatalf("expected heartbeat entry after login, got error: %v", err) + } + if int64(score) < time.Now().Unix()-5 { + t.Fatalf("expected recent heartbeat score after login, got %v", score) + } + + // Validate should succeed + validateResp := httptest.NewRecorder() + validateHTTP := httptest.NewRequest(http.MethodPost, "/users/validate", nil) + validateHTTP.Header.Set("Authorization", "Bearer "+loginUser.Token) + router.ServeHTTP(validateResp, validateHTTP) + if validateResp.Code != http.StatusOK { + t.Fatalf("expected 200 on validate, got %d. Body: %s", validateResp.Code, validateResp.Body.String()) + } + + // Logout should revoke redis tokens + logoutResp := httptest.NewRecorder() + logoutHTTP := httptest.NewRequest(http.MethodDelete, "/users/logout", nil) + logoutHTTP.Header.Set("Authorization", "Bearer "+loginUser.Token) + router.ServeHTTP(logoutResp, logoutHTTP) + if logoutResp.Code != http.StatusNoContent { + t.Fatalf("expected 204 on logout, got %d. Body: %s", logoutResp.Code, logoutResp.Body.String()) + } + + // Validate again should fail + validateAfterResp := httptest.NewRecorder() + validateAfterHTTP := httptest.NewRequest(http.MethodPost, "/users/validate", nil) + validateAfterHTTP.Header.Set("Authorization", "Bearer "+loginUser.Token) + router.ServeHTTP(validateAfterResp, validateAfterHTTP) + if validateAfterResp.Code != http.StatusUnauthorized { + t.Fatalf("expected 401 on validate after logout, got %d. Body: %s", validateAfterResp.Code, validateAfterResp.Body.String()) + } +} diff --git a/backend/internal/service/redis_service_test.go b/backend/internal/service/redis_service_test.go new file mode 100644 index 0000000..be8bbe4 --- /dev/null +++ b/backend/internal/service/redis_service_test.go @@ -0,0 +1,194 @@ +package service + +import ( + "context" + "errors" + "fmt" + "strings" + "testing" + "time" + + "github.com/paularynty/transcendence/auth-service-go/internal/config" + "github.com/paularynty/transcendence/auth-service-go/internal/dto" + "github.com/paularynty/transcendence/auth-service-go/internal/middleware" + "github.com/redis/go-redis/v9" +) + +func withRedisTestExpiries(t *testing.T, userTTLSeconds int, absoluteTTLSeconds int) func() { + t.Helper() + + prevCfg := config.Cfg + cfgCopy := *prevCfg + cfgCopy.UserTokenExpiry = userTTLSeconds + cfgCopy.UserTokenAbsoluteExpiry = absoluteTTLSeconds + config.Cfg = &cfgCopy + + return func() { + config.Cfg = prevCfg + } +} + +func TestRedisTokenLifecycle(t *testing.T) { + db := setupTestDB(t.Name()) + mr, redisClient, cleanupRedis := setupTestRedis(t) + defer cleanupRedis() + defer withRedisTestExpiries(t, 10, 30)() + + svc := NewUserService(db, redisClient) + ctx := context.Background() + + userResp, err := svc.CreateUser(ctx, &dto.CreateUserRequest{ + User: dto.User{UserName: dto.UserName{Username: "redisuser"}, Email: "redis@example.com"}, + Password: dto.Password{Password: "password123"}, + }) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + + token, err := svc.issueNewTokenForUser(ctx, userResp.ID, false) + if err != nil { + t.Fatalf("failed to issue token: %v", err) + } + if token == "" { + t.Fatal("expected non-empty token") + } + + key := buildTokenKey(userResp.ID, token) + if !mr.Exists(key) { + t.Fatalf("expected redis token key to exist: %s", key) + } + + // Drive time close to expiry, then validate and ensure TTL slides forward. + mr.FastForward(9 * time.Second) + ttlBefore := mr.TTL(key) + if ttlBefore <= 0 { + t.Fatalf("expected TTL before validation to be positive, got %v", ttlBefore) + } + + if err := svc.ValidateUserToken(ctx, token, userResp.ID); err != nil { + t.Fatalf("expected token to validate, got %v", err) + } + + ttlAfter := mr.TTL(key) + if ttlAfter < 8*time.Second { + t.Fatalf("expected sliding TTL refresh, got %v", ttlAfter) + } + + // Logout should revoke all redis tokens for the user. + if err := svc.LogoutUser(ctx, userResp.ID); err != nil { + t.Fatalf("logout failed: %v", err) + } + + if mr.Exists(key) { + t.Fatal("expected redis token key to be deleted on logout") + } + + err = svc.ValidateUserToken(ctx, token, userResp.ID) + if err == nil { + t.Fatal("expected token to be invalid after logout") + } + var authErr *middleware.AuthError + if !strings.Contains(err.Error(), "invalid token") || !errors.As(err, &authErr) { + t.Fatalf("expected auth error for invalid token, got %v", err) + } +} + +func TestRedisHeartbeatOnlineStatusAndCleanup(t *testing.T) { + db := setupTestDB(t.Name()) + _, redisClient, cleanupRedis := setupTestRedis(t) + defer cleanupRedis() + + svc := NewUserService(db, redisClient) + ctx := context.Background() + + u1, err := svc.CreateUser(ctx, &dto.CreateUserRequest{ + User: dto.User{UserName: dto.UserName{Username: "hb1"}, Email: "hb1@example.com"}, + Password: dto.Password{Password: "password123"}, + }) + if err != nil { + t.Fatalf("failed to create user1: %v", err) + } + + _, err = svc.CreateUser(ctx, &dto.CreateUserRequest{ + User: dto.User{UserName: dto.UserName{Username: "hb2"}, Email: "hb2@example.com"}, + Password: dto.Password{Password: "password123"}, + }) + if err != nil { + t.Fatalf("failed to create user2: %v", err) + } + + svc.updateHeartBeat(u1.ID) + time.Sleep(100 * time.Millisecond) + + onlineNow, err := svc.getOnlineStatus(ctx) + if err != nil { + t.Fatalf("getOnlineStatus failed: %v", err) + } + + checkerNow := newOnlineStatusChecker(onlineNow) + if !checkerNow.isOnline(u1.ID) { + t.Fatal("expected user1 to be online after heartbeat") + } + + // Force the heartbeat score to be old, then ensure cleanup happens. + oldScore := float64(time.Now().Add(-3 * time.Minute).Unix()) + if err := redisClient.ZAdd(ctx, HeartBeatPrefix, redis.Z{Score: oldScore, Member: u1.ID}).Err(); err != nil { + t.Fatalf("failed to set old heartbeat score: %v", err) + } + + onlineLater, err := svc.getOnlineStatus(ctx) + if err != nil { + t.Fatalf("getOnlineStatus later failed: %v", err) + } + + checkerLater := newOnlineStatusChecker(onlineLater) + if checkerLater.isOnline(u1.ID) { + t.Fatal("expected user1 to be offline after expiration window") + } + + // Cleanup should have removed the expired heartbeat entry. + time.Sleep(100 * time.Millisecond) + if _, err := redisClient.ZScore(ctx, HeartBeatPrefix, fmt.Sprint(u1.ID)).Result(); err == nil { + t.Fatal("expected expired heartbeat to be removed from redis") + } +} + +func TestRedisLoginUpdatesHeartbeat(t *testing.T) { + db := setupTestDB(t.Name()) + _, redisClient, cleanupRedis := setupTestRedis(t) + defer cleanupRedis() + + svc := NewUserService(db, redisClient) + ctx := context.Background() + + created, err := svc.CreateUser(ctx, &dto.CreateUserRequest{ + User: dto.User{UserName: dto.UserName{Username: "loginhb"}, Email: "loginhb@example.com"}, + Password: dto.Password{Password: "password123"}, + }) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + userID := created.ID + + res, err := svc.LoginUser(ctx, &dto.LoginUserRequest{ + Identifier: dto.Identifier{Identifier: "loginhb"}, + Password: dto.Password{Password: "password123"}, + }) + if err != nil { + t.Fatalf("login failed: %v", err) + } + if res.User == nil || res.User.Token == "" { + t.Fatal("expected login to issue a valid token") + } + + time.Sleep(100 * time.Millisecond) + + score, err := redisClient.ZScore(ctx, HeartBeatPrefix, fmt.Sprint(userID)).Result() + if err != nil { + t.Fatalf("expected heartbeat entry for user, got error: %v", err) + } + now := time.Now().Unix() + if int64(score) < now-5 { + t.Fatalf("expected recent heartbeat score, got %v (now=%d)", score, now) + } +} diff --git a/backend/internal/service/setup_test.go b/backend/internal/service/setup_test.go index 1cad0bb..1c19af0 100644 --- a/backend/internal/service/setup_test.go +++ b/backend/internal/service/setup_test.go @@ -6,6 +6,8 @@ import ( "strings" "testing" + "github.com/alicebob/miniredis/v2" + "github.com/redis/go-redis/v9" "gorm.io/driver/sqlite" "gorm.io/gorm" @@ -44,15 +46,18 @@ func setupTestDB(testName string) *gorm.DB { func setupConfig() { config.Cfg = &config.Config{ - JwtSecret: "test-secret", - UserTokenExpiry: 3600, - OauthStateTokenExpiry: 600, - GoogleClientId: "test-client-id", - GoogleClientSecret: "test-client-secret", - GoogleRedirectUri: "http://localhost:8080/callback", - FrontendUrl: "http://localhost:3000", - TwoFaUrlPrefix: "otpauth://totp/Transcendence?secret=", - TwoFaTokenExpiry: 600, + JwtSecret: "test-secret", + UserTokenExpiry: 3600, + UserTokenAbsoluteExpiry: 2592000, + OauthStateTokenExpiry: 600, + GoogleClientId: "test-client-id", + GoogleClientSecret: "test-client-secret", + GoogleRedirectUri: "http://localhost:8080/callback", + FrontendUrl: "http://localhost:3000", + TwoFaUrlPrefix: "otpauth://totp/Transcendence?secret=", + TwoFaTokenExpiry: 600, + RedisURL: "", + IsRedisEnabled: false, } // Mock logger to discard output during tests @@ -66,3 +71,26 @@ func TestMain(m *testing.M) { code := m.Run() os.Exit(code) } + +func setupTestRedis(t *testing.T) (*miniredis.Miniredis, *redis.Client, func()) { + t.Helper() + + mr := miniredis.RunT(t) + client := redis.NewClient(&redis.Options{ + Addr: mr.Addr(), + }) + + prevCfg := config.Cfg + cfgCopy := *prevCfg + cfgCopy.RedisURL = "redis://" + mr.Addr() + cfgCopy.IsRedisEnabled = true + config.Cfg = &cfgCopy + + cleanup := func() { + _ = client.Close() + mr.Close() + config.Cfg = prevCfg + } + + return mr, client, cleanup +} From d881dfd8216bce88937bccb1cbdf89f38bab2e01 Mon Sep 17 00:00:00 2001 From: Xin Feng <126309503+danielxfeng@users.noreply.github.com> Date: Tue, 27 Jan 2026 13:04:25 +0200 Subject: [PATCH 07/21] docs: update README to include Redis configuration and features --- README.md | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f6a9b05..11a1353 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,8 @@ Currently supported features include: - Friend listing - Friend requests - Online status tracking +- Redis-backed session tokens (Optional) (revocation + sliding expiration) +- Redis-backed heartbeats for online status (Optional) ## Libraries @@ -42,6 +44,7 @@ Currently supported features include: - `gin`: web framework - `gorm`: ORM +- `go-redis`: Redis - `go-playground/validator v10`: data validation - `godotenv`: environment variables - `slog-gin`: logging @@ -77,6 +80,21 @@ make dev Then navigate to `http://localhost:3003/api/docs/index.html` for swagger. +Redis is optional. To enable it locally: + +```bash +# example: run redis with docker +docker run --rm -p 6379:6379 redis:latest + +# enable redis mode for the backend +export REDIS_URL=redis://localhost:6379/0 +``` + +Token extension (sliding expiration) in Redis mode: + +- `USER_TOKEN_EXPIRY` controls the Redis TTL and is extended on token validation. +- `USER_TOKEN_ABSOLUTE_EXPIRY` caps the maximum lifetime via the JWT `exp` claim. + ### Frontend ```bash @@ -93,6 +111,6 @@ Due to the constraints of the Hive project, `SQLite` was required for the projec As a result: -- `SQLite` is used to store authentication tokens and heartbeat data. In production, these would be better handled by `Redis`. -- Stale tokens and heartbeat data are not automatically cleaned up, and token auto-renewal is not implemented. +- The project still uses `SQLite` for core data due to Hive constraints. +- Redis-backed tokens and heartbeats are implemented, but the sliding expiration and cleanup strategy is simple. - On the frontend side, friend auto-completion is implemented in a basic manner. From 4ec7750b46ad73dae064c56c8becbd7bac158e83 Mon Sep 17 00:00:00 2001 From: Xin Feng <126309503+danielxfeng@users.noreply.github.com> Date: Tue, 27 Jan 2026 13:22:58 +0200 Subject: [PATCH 08/21] chore/backend: format --- backend/internal/db/redis.go | 6 +++--- backend/internal/service/helper.go | 2 +- backend/internal/service/user_service.go | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/backend/internal/db/redis.go b/backend/internal/db/redis.go index 8d7fe24..76c935e 100644 --- a/backend/internal/db/redis.go +++ b/backend/internal/db/redis.go @@ -22,10 +22,10 @@ func ConnectRedis(redisURL string) { panic("failed to parse redis url, err: " + err.Error()) } - client := redis.NewClient(opt) + client := redis.NewClient(opt) ctx := context.Background() - + _, err = client.Ping(ctx).Result() if err != nil { panic("failed to connect to redis: " + err.Error()) @@ -47,4 +47,4 @@ func CloseRedis() { } else { util.Logger.Info("redis connection closed") } -} \ No newline at end of file +} diff --git a/backend/internal/service/helper.go b/backend/internal/service/helper.go index f37c15e..4d21991 100644 --- a/backend/internal/service/helper.go +++ b/backend/internal/service/helper.go @@ -154,7 +154,7 @@ func (s *UserService) clearExpiredHeartBeatsByRedis() { go func() { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() - + err := s.Redis.ZRemRangeByScore(ctx, HeartBeatPrefix, "-inf", strconv.FormatInt(time.Now().Add(-2*time.Minute).Unix(), 10)).Err() if err != nil { util.Logger.Warn("failed to clear expired heartbeats from redis", "err", err) diff --git a/backend/internal/service/user_service.go b/backend/internal/service/user_service.go index 835411b..d56d5a5 100644 --- a/backend/internal/service/user_service.go +++ b/backend/internal/service/user_service.go @@ -257,7 +257,7 @@ func (s *UserService) validateUserTokenRedis(ctx context.Context, token string, // A rough way to implement sliding expiration s.Redis.Expire(ctx, buildTokenKey(userId, token), time.Duration(config.Cfg.UserTokenExpiry)*time.Second) - + s.updateHeartBeat(userId) return nil } From 41d18668fc057d643b35efef3c26a77c3bb931fa Mon Sep 17 00:00:00 2001 From: Xin Feng <126309503+danielxfeng@users.noreply.github.com> Date: Tue, 27 Jan 2026 23:24:03 +0200 Subject: [PATCH 09/21] feat/backend: add tests for user logout and token revocation in Redis --- .../internal/service/redis_service_test.go | 152 ++++++++++++++++++ backend/internal/service/user_service.go | 7 + 2 files changed, 159 insertions(+) diff --git a/backend/internal/service/redis_service_test.go b/backend/internal/service/redis_service_test.go index be8bbe4..a57a8c4 100644 --- a/backend/internal/service/redis_service_test.go +++ b/backend/internal/service/redis_service_test.go @@ -192,3 +192,155 @@ func TestRedisLoginUpdatesHeartbeat(t *testing.T) { t.Fatalf("expected recent heartbeat score, got %v (now=%d)", score, now) } } + +func TestRedisLogoutRevokesAllTokens(t *testing.T) { + db := setupTestDB(t.Name()) + mr, redisClient, cleanupRedis := setupTestRedis(t) + defer cleanupRedis() + + svc := NewUserService(db, redisClient) + ctx := context.Background() + + userResp, err := svc.CreateUser(ctx, &dto.CreateUserRequest{ + User: dto.User{UserName: dto.UserName{Username: "logoutmulti"}, Email: "logoutmulti@example.com"}, + Password: dto.Password{Password: "password123"}, + }) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + + token1, err := svc.issueNewTokenForUser(ctx, userResp.ID, false) + if err != nil { + t.Fatalf("failed to issue token1: %v", err) + } + token2, err := svc.issueNewTokenForUser(ctx, userResp.ID, false) + if err != nil { + t.Fatalf("failed to issue token2: %v", err) + } + + key1 := buildTokenKey(userResp.ID, token1) + key2 := buildTokenKey(userResp.ID, token2) + if !mr.Exists(key1) || !mr.Exists(key2) { + t.Fatalf("expected both redis token keys to exist: %s, %s", key1, key2) + } + + if err := svc.LogoutUser(ctx, userResp.ID); err != nil { + t.Fatalf("logout failed: %v", err) + } + + if mr.Exists(key1) || mr.Exists(key2) { + t.Fatal("expected redis token keys to be deleted on logout") + } + + if err := svc.ValidateUserToken(ctx, token1, userResp.ID); err == nil { + t.Fatal("expected token1 to be invalid after logout") + } + if err := svc.ValidateUserToken(ctx, token2, userResp.ID); err == nil { + t.Fatal("expected token2 to be invalid after logout") + } +} + +func TestRedisDeleteUserRevokesAllTokens(t *testing.T) { + db := setupTestDB(t.Name()) + mr, redisClient, cleanupRedis := setupTestRedis(t) + defer cleanupRedis() + + svc := NewUserService(db, redisClient) + ctx := context.Background() + + userResp, err := svc.CreateUser(ctx, &dto.CreateUserRequest{ + User: dto.User{UserName: dto.UserName{Username: "delredis"}, Email: "delredis@example.com"}, + Password: dto.Password{Password: "password123"}, + }) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + + token1, err := svc.issueNewTokenForUser(ctx, userResp.ID, false) + if err != nil { + t.Fatalf("failed to issue token1: %v", err) + } + token2, err := svc.issueNewTokenForUser(ctx, userResp.ID, false) + if err != nil { + t.Fatalf("failed to issue token2: %v", err) + } + + key1 := buildTokenKey(userResp.ID, token1) + key2 := buildTokenKey(userResp.ID, token2) + if !mr.Exists(key1) || !mr.Exists(key2) { + t.Fatalf("expected both redis token keys to exist: %s, %s", key1, key2) + } + + if err := svc.DeleteUser(ctx, userResp.ID); err != nil { + t.Fatalf("delete failed: %v", err) + } + + if mr.Exists(key1) || mr.Exists(key2) { + t.Fatal("expected redis token keys to be deleted on user deletion") + } + + if err := svc.ValidateUserToken(ctx, token1, userResp.ID); err == nil { + t.Fatal("expected token1 to be invalid after delete") + } + if err := svc.ValidateUserToken(ctx, token2, userResp.ID); err == nil { + t.Fatal("expected token2 to be invalid after delete") + } +} + +func TestRedisUpdatePasswordRevokesOldTokens(t *testing.T) { + db := setupTestDB(t.Name()) + mr, redisClient, cleanupRedis := setupTestRedis(t) + defer cleanupRedis() + + svc := NewUserService(db, redisClient) + ctx := context.Background() + + userResp, err := svc.CreateUser(ctx, &dto.CreateUserRequest{ + User: dto.User{UserName: dto.UserName{Username: "pwredis"}, Email: "pwredis@example.com"}, + Password: dto.Password{Password: "oldpass"}, + }) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + + token1, err := svc.issueNewTokenForUser(ctx, userResp.ID, false) + if err != nil { + t.Fatalf("failed to issue token1: %v", err) + } + token2, err := svc.issueNewTokenForUser(ctx, userResp.ID, false) + if err != nil { + t.Fatalf("failed to issue token2: %v", err) + } + + key1 := buildTokenKey(userResp.ID, token1) + key2 := buildTokenKey(userResp.ID, token2) + if !mr.Exists(key1) || !mr.Exists(key2) { + t.Fatalf("expected both redis token keys to exist: %s, %s", key1, key2) + } + + updateReq := &dto.UpdateUserPasswordRequest{ + OldPassword: dto.OldPassword{OldPassword: "oldpass"}, + NewPassword: dto.NewPassword{NewPassword: "newpass"}, + } + resp, err := svc.UpdateUserPassword(ctx, userResp.ID, updateReq) + if err != nil { + t.Fatalf("update password failed: %v", err) + } + if resp.Token == "" { + t.Fatal("expected new token from password update") + } + + if mr.Exists(key1) || mr.Exists(key2) { + t.Fatal("expected old redis token keys to be deleted on password change") + } + + if err := svc.ValidateUserToken(ctx, token1, userResp.ID); err == nil { + t.Fatal("expected token1 to be invalid after password change") + } + if err := svc.ValidateUserToken(ctx, token2, userResp.ID); err == nil { + t.Fatal("expected token2 to be invalid after password change") + } + if err := svc.ValidateUserToken(ctx, resp.Token, userResp.ID); err != nil { + t.Fatalf("expected new token to be valid after password change, got %v", err) + } +} diff --git a/backend/internal/service/user_service.go b/backend/internal/service/user_service.go index d56d5a5..2a7a558 100644 --- a/backend/internal/service/user_service.go +++ b/backend/internal/service/user_service.go @@ -188,6 +188,13 @@ func (s *UserService) UpdateUserProfile(ctx context.Context, userID uint, reques } func (s *UserService) DeleteUser(ctx context.Context, userID uint) error { + if config.Cfg.IsRedisEnabled { + err := logoutUserByRedis(ctx, s.Redis, userID) + if err != nil { + return err + } + } + res := s.DB.WithContext(ctx).Unscoped().Delete(&model.User{}, userID) if res.Error != nil { return res.Error From 03cbe8943d57a60e5a1649b8011926b614874f07 Mon Sep 17 00:00:00 2001 From: Xin Feng <126309503+danielxfeng@users.noreply.github.com> Date: Tue, 27 Jan 2026 23:32:06 +0200 Subject: [PATCH 10/21] test/backend: add more tests for duplicated email --- .../service/google_oauth_service_test.go | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/backend/internal/service/google_oauth_service_test.go b/backend/internal/service/google_oauth_service_test.go index 87f373a..c1d0450 100644 --- a/backend/internal/service/google_oauth_service_test.go +++ b/backend/internal/service/google_oauth_service_test.go @@ -138,6 +138,69 @@ func TestHandleGoogleOAuthCallback_Success(t *testing.T) { t.Error("expected token in redirect") } }) + + t.Run("ExistingEmailLink", func(t *testing.T) { + // Create a non-OAuth user with matching email + _, _ = svc.CreateUser(ctx, &dto.CreateUserRequest{ + User: dto.User{UserName: dto.UserName{Username: "emailmatch"}, Email: "linkme@google.com"}, + Password: dto.Password{Password: "p"}, + }) + + FetchGoogleUserInfo = func(payload *idtoken.Payload) (*dto.GoogleUserData, error) { + return &dto.GoogleUserData{ + ID: "g_link", + Email: "linkme@google.com", + Name: "Link Me", + }, nil + } + + redirectURL := svc.HandleGoogleOAuthCallback(ctx, "validcode", state) + u, _ := url.Parse(redirectURL) + q := u.Query() + if q.Get("token") == "" { + t.Error("expected token in redirect") + } + if q.Get("error") != "" { + t.Errorf("unexpected error in redirect: %s", q.Get("error")) + } + + // Verify existing user is linked + var user model.User + err := db.Where("email = ?", "linkme@google.com").First(&user).Error + if err != nil { + t.Fatal("expected existing user") + } + if user.GoogleOauthID == nil || *user.GoogleOauthID != "g_link" { + t.Error("expected google oauth id to be linked") + } + }) + + t.Run("ExistingEmailWith2FA", func(t *testing.T) { + // Create a user with 2FA enabled + u, _ := svc.CreateUser(ctx, &dto.CreateUserRequest{ + User: dto.User{UserName: dto.UserName{Username: "email2fa"}, Email: "2fa@google.com"}, + Password: dto.Password{Password: "p"}, + }) + db.Model(&model.User{}).Where("id = ?", u.ID).Update("two_fa_token", "secret") + + FetchGoogleUserInfo = func(payload *idtoken.Payload) (*dto.GoogleUserData, error) { + return &dto.GoogleUserData{ + ID: "g_2fa", + Email: "2fa@google.com", + Name: "Two Fa", + }, nil + } + + redirectURL := svc.HandleGoogleOAuthCallback(ctx, "validcode", state) + u2, _ := url.Parse(redirectURL) + q := u2.Query() + if q.Get("token") != "" { + t.Error("expected no token in redirect") + } + if q.Get("error") == "" { + t.Error("expected error in redirect for 2FA user") + } + }) } func TestHandleGoogleOAuthCallback_Errors(t *testing.T) { From fafb3e844c441728f2a6b49a4c5db8eed9d77d87 Mon Sep 17 00:00:00 2001 From: Xin Feng <126309503+danielxfeng@users.noreply.github.com> Date: Tue, 27 Jan 2026 10:08:26 +0200 Subject: [PATCH 11/21] chore/backend: add redis packages, and env --- backend/.env.sample | 3 +++ backend/go.mod | 6 ++++++ backend/go.sum | 6 ++++++ 3 files changed, 15 insertions(+) diff --git a/backend/.env.sample b/backend/.env.sample index 7061c89..fdab70f 100644 --- a/backend/.env.sample +++ b/backend/.env.sample @@ -1,6 +1,9 @@ # Db address DB_ADDRESS=data/sqlite3.db +# Redis address, leave empty to disable Redis +REDIS_URL=rediss://example-redis.upstash.io + # JWT JWT_SECRET=not-dev-secret USER_TOKEN_EXPIRY=3600 diff --git a/backend/go.mod b/backend/go.mod index 1cec820..9f0d88b 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -22,6 +22,11 @@ require ( gorm.io/gorm v1.31.1 ) +require ( + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect +) + require ( cloud.google.com/go/compute/metadata v0.9.0 // indirect github.com/KyleBanks/depth v1.2.1 // indirect @@ -63,6 +68,7 @@ require ( github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/quic-go v0.58.0 // indirect + github.com/redis/go-redis/v9 v9.17.3 github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect diff --git a/backend/go.sum b/backend/go.sum index 771bd38..1a28d33 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -12,11 +12,15 @@ github.com/bytedance/sonic v1.14.2 h1:k1twIoe97C1DtYUo+fZQy865IuHia4PR5RPiuGPPII github.com/bytedance/sonic v1.14.2/go.mod h1:T80iDELeHiHKSc0C9tubFygiuXoGzrkjKzX2quAx980= github.com/bytedance/sonic/loader v0.4.0 h1:olZ7lEqcxtZygCK9EKYKADnpQoYkRQxaeY2NYzevs+o= github.com/bytedance/sonic/loader v0.4.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= @@ -125,6 +129,8 @@ github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= github.com/quic-go/quic-go v0.58.0 h1:ggY2pvZaVdB9EyojxL1p+5mptkuHyX5MOSv4dgWF4Ug= github.com/quic-go/quic-go v0.58.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/redis/go-redis/v9 v9.17.3 h1:fN29NdNrE17KttK5Ndf20buqfDZwGNgoUr9qjl1DQx4= +github.com/redis/go-redis/v9 v9.17.3/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/samber/slog-gin v1.19.0 h1:zwlPQhwvi3o1lufsfVSoyCaFHMWfUCsJD0mcb+1P6WQ= From 5a43a9427c29900e180ba8c4a5462feab16dfb5a Mon Sep 17 00:00:00 2001 From: Xin Feng <126309503+danielxfeng@users.noreply.github.com> Date: Tue, 27 Jan 2026 10:09:53 +0200 Subject: [PATCH 12/21] feat/backend: add config, and init redis --- backend/internal/config/config.go | 4 +++ backend/internal/db/redis.go | 45 +++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) create mode 100644 backend/internal/db/redis.go diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 482fdbe..a7216fd 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -17,6 +17,8 @@ type Config struct { FrontendUrl string TwoFaUrlPrefix string TwoFaTokenExpiry int + RedisURL string + IsRedisEnabled bool } var Cfg *Config @@ -55,5 +57,7 @@ func LoadConfig() { FrontendUrl: getEnvStrOrDefault("FRONTEND_URL", "http://localhost:5173"), TwoFaUrlPrefix: getEnvStrOrDefault("TWO_FA_URL_PREFIX", "otpauth://totp/Transcendence?secret="), TwoFaTokenExpiry: getEnvIntOrDefault("TWO_FA_TOKEN_EXPIRY", 600), + RedisURL: getEnvStrOrDefault("REDIS_URL", ""), + IsRedisEnabled: getEnvStrOrDefault("REDIS_URL", "") != "", } } diff --git a/backend/internal/db/redis.go b/backend/internal/db/redis.go new file mode 100644 index 0000000..df69801 --- /dev/null +++ b/backend/internal/db/redis.go @@ -0,0 +1,45 @@ +package db + +import ( + "context" + + "github.com/paularynty/transcendence/auth-service-go/internal/config" + "github.com/paularynty/transcendence/auth-service-go/internal/util" + "github.com/redis/go-redis/v9" +) + +var Redis *redis.Client + +// ConnectOptionalRedis connects to Redis and sets the global Redis client. +// +// If Redis is disabled via configuration, or if the connection attempt fails, +// Redis remains nil and config.Cfg.IsRedisEnabled is set to false. +func ConnectOptionalRedis(redisURL string) { + if !config.Cfg.IsRedisEnabled { + util.Logger.Info("redis is disabled by config") + return + } + + opt, err := redis.ParseURL(redisURL) + + if err != nil { + util.Logger.Error("failed to parse redis url", "err", err) + config.Cfg.IsRedisEnabled = false + return + } + + client := redis.NewClient(opt) + + ctx := context.Background() + + _, err = client.Ping(ctx).Result() + if err != nil { + util.Logger.Error("failed to connect to redis", "err", err) + config.Cfg.IsRedisEnabled = false + return + } + + Redis = client + + util.Logger.Info("connected to redis") +} \ No newline at end of file From 3038d7dad99d42de165d3162479ada9bfece4df0 Mon Sep 17 00:00:00 2001 From: Xin Feng <126309503+danielxfeng@users.noreply.github.com> Date: Tue, 27 Jan 2026 10:42:42 +0200 Subject: [PATCH 13/21] fix/backend: panic when redis cannot be inited --- backend/internal/db/redis.go | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/backend/internal/db/redis.go b/backend/internal/db/redis.go index df69801..bcbbcce 100644 --- a/backend/internal/db/redis.go +++ b/backend/internal/db/redis.go @@ -10,11 +10,7 @@ import ( var Redis *redis.Client -// ConnectOptionalRedis connects to Redis and sets the global Redis client. -// -// If Redis is disabled via configuration, or if the connection attempt fails, -// Redis remains nil and config.Cfg.IsRedisEnabled is set to false. -func ConnectOptionalRedis(redisURL string) { +func ConnectRedis(redisURL string) { if !config.Cfg.IsRedisEnabled { util.Logger.Info("redis is disabled by config") return @@ -23,9 +19,7 @@ func ConnectOptionalRedis(redisURL string) { opt, err := redis.ParseURL(redisURL) if err != nil { - util.Logger.Error("failed to parse redis url", "err", err) - config.Cfg.IsRedisEnabled = false - return + panic("failed to parse redis url, err: " + err.Error()) } client := redis.NewClient(opt) @@ -34,9 +28,7 @@ func ConnectOptionalRedis(redisURL string) { _, err = client.Ping(ctx).Result() if err != nil { - util.Logger.Error("failed to connect to redis", "err", err) - config.Cfg.IsRedisEnabled = false - return + panic("failed to connect to redis: " + err.Error()) } Redis = client From a758af70519da3ea2a1806383acb85b27a4f5f8b Mon Sep 17 00:00:00 2001 From: Xin Feng <126309503+danielxfeng@users.noreply.github.com> Date: Tue, 27 Jan 2026 12:29:40 +0200 Subject: [PATCH 14/21] feat/backend: integrate Redis for user token management and heartbeat --- backend/.env.sample | 4 +- backend/cmd/server/main.go | 3 + backend/internal/config/config.go | 54 ++++---- backend/internal/db/redis.go | 13 ++ backend/internal/routers/users_router.go | 2 +- backend/internal/service/friend_service.go | 3 +- backend/internal/service/helper.go | 144 ++++++++++++++++++++- backend/internal/service/user_service.go | 60 ++++++++- backend/internal/util/jwt/jwt.go | 9 +- 9 files changed, 253 insertions(+), 39 deletions(-) diff --git a/backend/.env.sample b/backend/.env.sample index fdab70f..e692901 100644 --- a/backend/.env.sample +++ b/backend/.env.sample @@ -6,9 +6,11 @@ REDIS_URL=rediss://example-redis.upstash.io # JWT JWT_SECRET=not-dev-secret -USER_TOKEN_EXPIRY=3600 +USER_TOKEN_EXPIRY=3600 # 1 hour TWO_FA_TOKEN_EXPIRY=600 OAUTH_STATE_TOKEN_EXPIRY=300 +# Sliding expiration is enabled under Redis mode, while this limits the longest possible expiry time. +USER_TOKEN_ABSOLUTE_EXPIRY=2592000 # 30 days # Google OAuth GOOGLE_CLIENT_ID=100000000-e3dsadsadsa321321.apps.googleusercontent.com diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index e1b550c..f5ea8b5 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -85,6 +85,9 @@ func main() { db.ConnectDB(config.Cfg.DbAddress) defer db.CloseDB() + db.ConnectRedis(config.Cfg.RedisURL) + defer db.CloseRedis() + // router r := SetupRouter(util.Logger) routers.UsersRouter(r.Group("/api/users")) diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index a7216fd..a2058ba 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -6,19 +6,20 @@ import ( ) type Config struct { - GinMode string - DbAddress string - JwtSecret string - UserTokenExpiry int - OauthStateTokenExpiry int - GoogleClientId string - GoogleClientSecret string - GoogleRedirectUri string - FrontendUrl string - TwoFaUrlPrefix string - TwoFaTokenExpiry int - RedisURL string - IsRedisEnabled bool + GinMode string + DbAddress string + JwtSecret string + UserTokenExpiry int + OauthStateTokenExpiry int + GoogleClientId string + GoogleClientSecret string + GoogleRedirectUri string + FrontendUrl string + TwoFaUrlPrefix string + TwoFaTokenExpiry int + RedisURL string + IsRedisEnabled bool + UserTokenAbsoluteExpiry int } var Cfg *Config @@ -46,18 +47,19 @@ func getEnvIntOrDefault(key string, defaultValue int) int { func LoadConfig() { Cfg = &Config{ - GinMode: getEnvStrOrDefault("GIN_MODE", "debug"), - DbAddress: getEnvStrOrDefault("DB_ADDRESS", "data/auth_service_db.sqlite"), - JwtSecret: getEnvStrOrDefault("JWT_SECRET", "test-secret"), - UserTokenExpiry: getEnvIntOrDefault("USER_TOKEN_EXPIRY", 3600), - OauthStateTokenExpiry: getEnvIntOrDefault("OAUTH_STATE_TOKEN_EXPIRY", 600), - GoogleClientId: getEnvStrOrDefault("GOOGLE_CLIENT_ID", "test-google-client-id"), - GoogleClientSecret: getEnvStrOrDefault("GOOGLE_CLIENT_SECRET", "test-google-client-secret"), - GoogleRedirectUri: getEnvStrOrDefault("GOOGLE_REDIRECT_URI", "test-google-redirect-uri"), - FrontendUrl: getEnvStrOrDefault("FRONTEND_URL", "http://localhost:5173"), - TwoFaUrlPrefix: getEnvStrOrDefault("TWO_FA_URL_PREFIX", "otpauth://totp/Transcendence?secret="), - TwoFaTokenExpiry: getEnvIntOrDefault("TWO_FA_TOKEN_EXPIRY", 600), - RedisURL: getEnvStrOrDefault("REDIS_URL", ""), - IsRedisEnabled: getEnvStrOrDefault("REDIS_URL", "") != "", + GinMode: getEnvStrOrDefault("GIN_MODE", "debug"), + DbAddress: getEnvStrOrDefault("DB_ADDRESS", "data/auth_service_db.sqlite"), + JwtSecret: getEnvStrOrDefault("JWT_SECRET", "test-secret"), + UserTokenExpiry: getEnvIntOrDefault("USER_TOKEN_EXPIRY", 3600), + OauthStateTokenExpiry: getEnvIntOrDefault("OAUTH_STATE_TOKEN_EXPIRY", 600), + GoogleClientId: getEnvStrOrDefault("GOOGLE_CLIENT_ID", "test-google-client-id"), + GoogleClientSecret: getEnvStrOrDefault("GOOGLE_CLIENT_SECRET", "test-google-client-secret"), + GoogleRedirectUri: getEnvStrOrDefault("GOOGLE_REDIRECT_URI", "test-google-redirect-uri"), + FrontendUrl: getEnvStrOrDefault("FRONTEND_URL", "http://localhost:5173"), + TwoFaUrlPrefix: getEnvStrOrDefault("TWO_FA_URL_PREFIX", "otpauth://totp/Transcendence?secret="), + TwoFaTokenExpiry: getEnvIntOrDefault("TWO_FA_TOKEN_EXPIRY", 600), + RedisURL: getEnvStrOrDefault("REDIS_URL", ""), + IsRedisEnabled: getEnvStrOrDefault("REDIS_URL", "") != "", + UserTokenAbsoluteExpiry: getEnvIntOrDefault("USER_TOKEN_ABSOLUTE_EXPIRY", 2592000), } } diff --git a/backend/internal/db/redis.go b/backend/internal/db/redis.go index bcbbcce..8d7fe24 100644 --- a/backend/internal/db/redis.go +++ b/backend/internal/db/redis.go @@ -34,4 +34,17 @@ func ConnectRedis(redisURL string) { Redis = client util.Logger.Info("connected to redis") +} + +func CloseRedis() { + if Redis == nil { + return + } + + err := Redis.Close() + if err != nil { + util.Logger.Error("failed to close redis connection", "error", err) + } else { + util.Logger.Info("redis connection closed") + } } \ No newline at end of file diff --git a/backend/internal/routers/users_router.go b/backend/internal/routers/users_router.go index facd551..9ca89f3 100644 --- a/backend/internal/routers/users_router.go +++ b/backend/internal/routers/users_router.go @@ -11,7 +11,7 @@ import ( ) func UsersRouter(r *gin.RouterGroup) { - h := &handler.UserHandler{Service: service.NewUserService(db.DB)} + h := &handler.UserHandler{Service: service.NewUserService(db.DB, db.Redis)} // Public endpoints r.POST("/", middleware.ValidateBody[dto.CreateUserRequest](), h.CreateUserHandler) diff --git a/backend/internal/service/friend_service.go b/backend/internal/service/friend_service.go index cd7ab7f..c0a41a1 100644 --- a/backend/internal/service/friend_service.go +++ b/backend/internal/service/friend_service.go @@ -3,7 +3,6 @@ package service import ( "context" "errors" - "time" model "github.com/paularynty/transcendence/auth-service-go/internal/db" "github.com/paularynty/transcendence/auth-service-go/internal/dto" @@ -31,7 +30,7 @@ func (s *UserService) GetUserFriends(ctx context.Context, userID uint) ([]dto.Fr return nil, err } - onlineStatus, err := gorm.G[model.HeartBeat](s.DB).Where("last_seen_at > ?", time.Now().Add(-2*time.Minute)).Find(ctx) + onlineStatus, err := s.getOnlineStatus(ctx) if err != nil { return nil, err } diff --git a/backend/internal/service/helper.go b/backend/internal/service/helper.go index cc91742..f37c15e 100644 --- a/backend/internal/service/helper.go +++ b/backend/internal/service/helper.go @@ -3,25 +3,35 @@ package service import ( "context" "fmt" + "strconv" "strings" "time" + "github.com/paularynty/transcendence/auth-service-go/internal/config" model "github.com/paularynty/transcendence/auth-service-go/internal/db" "github.com/paularynty/transcendence/auth-service-go/internal/dto" "github.com/paularynty/transcendence/auth-service-go/internal/util" "github.com/paularynty/transcendence/auth-service-go/internal/util/jwt" + "github.com/redis/go-redis/v9" "gorm.io/gorm" "gorm.io/gorm/clause" ) -func NewUserService(db *gorm.DB) *UserService { +const HeartBeatPrefix = "heartbeat:" + +func NewUserService(db *gorm.DB, redis *redis.Client) *UserService { if db == nil { panic("UserService: db is nil") } + if config.Cfg.IsRedisEnabled && redis == nil { + panic("UserService: redis is enabled but redis client is nil") + } + return &UserService{ - DB: db, + DB: db, + Redis: redis, } } @@ -87,7 +97,7 @@ func (os *onlineStatusChecker) isOnline(userID uint) bool { return exists } -func (s *UserService) updateHeartBeat(userID uint) { +func (s *UserService) updateHeartBeatByDB(userID uint) { go func() { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() @@ -106,7 +116,94 @@ func (s *UserService) updateHeartBeat(userID uint) { }() } -func (s *UserService) issueNewTokenForUser(ctx context.Context, userID uint, revokeAllTokens bool) (string, error) { +func (s *UserService) updateHeartBeatByRedis(userID uint) { + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + err := s.Redis.ZAdd(ctx, HeartBeatPrefix, redis.Z{ + Score: float64(time.Now().Unix()), + Member: userID, + }).Err() + + if err != nil { + util.Logger.Warn("failed to update heartbeat for user", fmt.Sprint(userID), err.Error()) + } + }() +} + +func (s *UserService) updateHeartBeat(userID uint) { + if config.Cfg.IsRedisEnabled { + s.updateHeartBeatByRedis(userID) + } else { + s.updateHeartBeatByDB(userID) + } +} + +func (s *UserService) getOnlineStatusByDB(ctx context.Context) ([]model.HeartBeat, error) { + onlineStatus, err := gorm.G[model.HeartBeat](s.DB).Where("last_seen_at > ?", time.Now().Add(-2*time.Minute)).Find(ctx) + if err != nil { + return nil, err + } + + return onlineStatus, nil +} + +func (s *UserService) clearExpiredHeartBeatsByRedis() { + + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + err := s.Redis.ZRemRangeByScore(ctx, HeartBeatPrefix, "-inf", strconv.FormatInt(time.Now().Add(-2*time.Minute).Unix(), 10)).Err() + if err != nil { + util.Logger.Warn("failed to clear expired heartbeats from redis", "err", err) + } + }() +} + +func (s *UserService) getOnlineStatusByRedis(ctx context.Context) ([]model.HeartBeat, error) { + heartBeats := make([]model.HeartBeat, 0) + + zs, err := s.Redis.ZRangeByScoreWithScores(ctx, HeartBeatPrefix, &redis.ZRangeBy{ + Min: strconv.FormatInt(time.Now().Add(-2*time.Minute).Unix(), 10), + Max: "+inf", + }).Result() + if err != nil { + return nil, err + } + + for _, z := range zs { + userID, err := strconv.ParseUint(fmt.Sprint(z.Member), 10, 64) + if err != nil { + return nil, err + } + + heartBeats = append(heartBeats, model.HeartBeat{ + UserID: uint(userID), + LastSeenAt: time.Unix(int64(z.Score), 0), + }) + } + + // Async clear expired heartbeats + s.clearExpiredHeartBeatsByRedis() + + return heartBeats, nil +} + +func (s *UserService) getOnlineStatus(ctx context.Context) ([]model.HeartBeat, error) { + if config.Cfg.IsRedisEnabled { + return s.getOnlineStatusByRedis(ctx) + } else { + return s.getOnlineStatusByDB(ctx) + } +} + +func buildTokenKey(userID uint, token string) string { + return fmt.Sprintf("user_token:%d:%s", userID, token) +} + +func (s *UserService) issueNewTokenForUserByDB(ctx context.Context, userID uint, revokeAllTokens bool) (string, error) { if revokeAllTokens { res := s.DB.WithContext(ctx).Exec("DELETE FROM tokens WHERE user_id = ?", userID) @@ -132,3 +229,42 @@ func (s *UserService) issueNewTokenForUser(ctx context.Context, userID uint, rev return token, nil } + +func (s *UserService) issueNewTokenForUserByRedis(ctx context.Context, userID uint, revokeAllTokens bool) (string, error) { + + if revokeAllTokens { + // A rough way to delete all tokens for the user + iter := s.Redis.Scan(ctx, 0, buildTokenKey(userID, "*"), 100).Iterator() + for iter.Next(ctx) { + err := s.Redis.Del(ctx, iter.Val()).Err() + if err != nil { + return "", err + } + } + if err := iter.Err(); err != nil { + return "", err + } + } + + token, err := jwt.SignUserToken(userID) + if err != nil { + return "", err + } + + err = s.Redis.Set(ctx, buildTokenKey(userID, token), "", time.Duration(config.Cfg.UserTokenExpiry)*time.Second).Err() + if err != nil { + return "", err + } + + s.updateHeartBeat(userID) + + return token, nil +} + +func (s *UserService) issueNewTokenForUser(ctx context.Context, userID uint, revokeAllTokens bool) (string, error) { + if config.Cfg.IsRedisEnabled { + return s.issueNewTokenForUserByRedis(ctx, userID, revokeAllTokens) + } else { + return s.issueNewTokenForUserByDB(ctx, userID, revokeAllTokens) + } +} diff --git a/backend/internal/service/user_service.go b/backend/internal/service/user_service.go index 39eea67..835411b 100644 --- a/backend/internal/service/user_service.go +++ b/backend/internal/service/user_service.go @@ -4,14 +4,17 @@ import ( "context" "errors" "strings" + "time" "golang.org/x/crypto/bcrypt" "gorm.io/gorm" + "github.com/paularynty/transcendence/auth-service-go/internal/config" model "github.com/paularynty/transcendence/auth-service-go/internal/db" "github.com/paularynty/transcendence/auth-service-go/internal/dto" "github.com/paularynty/transcendence/auth-service-go/internal/middleware" "github.com/paularynty/transcendence/auth-service-go/internal/util/jwt" + "github.com/redis/go-redis/v9" ) const TwoFAPrePrefix = "pre-" @@ -20,7 +23,8 @@ const MaxAvatarSize = 1 * 1024 * 1024 // 1 MB const BaseGoogleOAuthURL = "https://accounts.google.com/o/oauth2/v2/auth" type UserService struct { - DB *gorm.DB + DB *gorm.DB + Redis *redis.Client } func (s *UserService) CreateUser(ctx context.Context, request *dto.CreateUserRequest) (*dto.UserWithoutTokenResponse, error) { @@ -192,8 +196,8 @@ func (s *UserService) DeleteUser(ctx context.Context, userID uint) error { return nil } -func (s *UserService) LogoutUser(ctx context.Context, userID uint) error { - _, err := gorm.G[model.Token](s.DB.Unscoped()).Where("user_id = ?", userID).Delete(ctx) +func logoutUserByDB(ctx context.Context, db *gorm.DB, userID uint) error { + _, err := gorm.G[model.Token](db.Unscoped()).Where("user_id = ?", userID).Delete(ctx) if err != nil { return err } @@ -201,7 +205,31 @@ func (s *UserService) LogoutUser(ctx context.Context, userID uint) error { return nil } -func (s *UserService) ValidateUserToken(ctx context.Context, token string, userId uint) error { +func logoutUserByRedis(ctx context.Context, redis *redis.Client, userID uint) error { + + iter := redis.Scan(ctx, 0, buildTokenKey(userID, "*"), 100).Iterator() + for iter.Next(ctx) { + err := redis.Del(ctx, iter.Val()).Err() + if err != nil { + return err + } + } + if err := iter.Err(); err != nil { + return err + } + + return nil +} + +func (s *UserService) LogoutUser(ctx context.Context, userID uint) error { + if config.Cfg.IsRedisEnabled { + return logoutUserByRedis(ctx, s.Redis, userID) + } else { + return logoutUserByDB(ctx, s.DB, userID) + } +} + +func (s *UserService) validateUserTokenDB(ctx context.Context, token string, userId uint) error { modelToken, err := gorm.G[model.Token](s.DB).Where("token = ?", token).First(ctx) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { @@ -217,3 +245,27 @@ func (s *UserService) ValidateUserToken(ctx context.Context, token string, userI s.updateHeartBeat(userId) return nil } + +func (s *UserService) validateUserTokenRedis(ctx context.Context, token string, userId uint) error { + _, err := s.Redis.Get(ctx, buildTokenKey(userId, token)).Result() + if err != nil { + if errors.Is(err, redis.Nil) { + return middleware.NewAuthError(401, "invalid token") + } + return err + } + + // A rough way to implement sliding expiration + s.Redis.Expire(ctx, buildTokenKey(userId, token), time.Duration(config.Cfg.UserTokenExpiry)*time.Second) + + s.updateHeartBeat(userId) + return nil +} + +func (s *UserService) ValidateUserToken(ctx context.Context, token string, userId uint) error { + if config.Cfg.IsRedisEnabled { + return s.validateUserTokenRedis(ctx, token, userId) + } else { + return s.validateUserTokenDB(ctx, token, userId) + } +} diff --git a/backend/internal/util/jwt/jwt.go b/backend/internal/util/jwt/jwt.go index 1ea435e..8f76365 100644 --- a/backend/internal/util/jwt/jwt.go +++ b/backend/internal/util/jwt/jwt.go @@ -26,10 +26,17 @@ func generateRegisteredClaims(expiration int) libjwt.RegisteredClaims { } func SignUserToken(userID uint) (string, error) { + userTokenExpiry := config.Cfg.UserTokenExpiry + // For Redis mode, use absolute expiry to limit max token lifetime, + // because the actual expiry is managed in Redis with sliding expiration. + if config.Cfg.IsRedisEnabled { + userTokenExpiry = config.Cfg.UserTokenAbsoluteExpiry + } + claims := dto.UserJwtPayload{ UserID: userID, Type: UserTokenType, - RegisteredClaims: generateRegisteredClaims(config.Cfg.UserTokenExpiry), + RegisteredClaims: generateRegisteredClaims(userTokenExpiry), } token := libjwt.NewWithClaims(libjwt.SigningMethodHS256, claims) From 4698ba06fef7d98199e11843a44aaf022f16fde2 Mon Sep 17 00:00:00 2001 From: Xin Feng <126309503+danielxfeng@users.noreply.github.com> Date: Tue, 27 Jan 2026 12:33:33 +0200 Subject: [PATCH 15/21] fix/backend: fix the tests for redis --- .../routers/users_router_failure_test.go | 8 ++--- backend/internal/routers/users_router_test.go | 6 ++-- .../internal/service/friend_service_test.go | 6 ++-- .../service/google_oauth_service_test.go | 16 +++++----- backend/internal/service/helper_test.go | 6 ++-- .../internal/service/twofa_service_test.go | 32 +++++++++---------- backend/internal/service/user_service_test.go | 32 +++++++++---------- 7 files changed, 53 insertions(+), 53 deletions(-) diff --git a/backend/internal/routers/users_router_failure_test.go b/backend/internal/routers/users_router_failure_test.go index 1f61a93..0a767d9 100644 --- a/backend/internal/routers/users_router_failure_test.go +++ b/backend/internal/routers/users_router_failure_test.go @@ -146,7 +146,7 @@ func TestUsersRouter_LoginUser_Failures(t *testing.T) { // 3. Invalid Credentials // Create user - svc := service.NewUserService(model.DB) + svc := service.NewUserService(model.DB, nil) _, _ = svc.CreateUser(context.Background(), &dto.CreateUserRequest{ User: dto.User{UserName: dto.UserName{Username: "loginfail"}, Email: "fail@e.com"}, Password: dto.Password{Password: "correct"}, @@ -168,7 +168,7 @@ func TestUsersRouter_UpdateUser_Failures(t *testing.T) { router, cleanup := setupUsersRouterTestFailure(t) defer cleanup() - svc := service.NewUserService(model.DB) + svc := service.NewUserService(model.DB, nil) u, _ := svc.CreateUser(context.Background(), &dto.CreateUserRequest{ User: dto.User{UserName: dto.UserName{Username: "u1"}, Email: "u1@e.com"}, Password: dto.Password{Password: "pass"}, @@ -216,7 +216,7 @@ func TestUsersRouter_Friends_Failures(t *testing.T) { router, cleanup := setupUsersRouterTestFailure(t) defer cleanup() - svc := service.NewUserService(model.DB) + svc := service.NewUserService(model.DB, nil) u1, _ := svc.CreateUser(context.Background(), &dto.CreateUserRequest{ User: dto.User{UserName: dto.UserName{Username: "f1"}, Email: "f1@e.com"}, Password: dto.Password{Password: "pass"}, @@ -274,7 +274,7 @@ func TestUsersRouter_2FA_Failures(t *testing.T) { router, cleanup := setupUsersRouterTestFailure(t) defer cleanup() - svc := service.NewUserService(model.DB) + svc := service.NewUserService(model.DB, nil) u, _ := svc.CreateUser(context.Background(), &dto.CreateUserRequest{ User: dto.User{UserName: dto.UserName{Username: "2fafail"}, Email: "2fafail@e.com"}, Password: dto.Password{Password: "pass"}, diff --git a/backend/internal/routers/users_router_test.go b/backend/internal/routers/users_router_test.go index bfd97d6..338547b 100644 --- a/backend/internal/routers/users_router_test.go +++ b/backend/internal/routers/users_router_test.go @@ -237,7 +237,7 @@ func TestUsersRouter_UpdateUserPassword(t *testing.T) { router, cleanup := setupUsersRouterTestUnique(t) defer cleanup() - svc := service.NewUserService(model.DB) + svc := service.NewUserService(model.DB, nil) userResp, _ := svc.CreateUser(context.Background(), &dto.CreateUserRequest{ User: dto.User{UserName: dto.UserName{Username: "pw"}, Email: "pw@e.com"}, Password: dto.Password{Password: "oldpass"}, @@ -336,7 +336,7 @@ func TestUsersRouter_Friends(t *testing.T) { router, cleanup := setupUsersRouterTestUnique(t) defer cleanup() - svc := service.NewUserService(model.DB) + svc := service.NewUserService(model.DB, nil) u1, _ := svc.CreateUser(context.Background(), &dto.CreateUserRequest{ User: dto.User{UserName: dto.UserName{Username: "f1"}, Email: "f1@e.com"}, Password: dto.Password{Password: "p"}, @@ -382,7 +382,7 @@ func TestUsersRouter_2FA(t *testing.T) { router, cleanup := setupUsersRouterTestUnique(t) defer cleanup() - svc := service.NewUserService(model.DB) + svc := service.NewUserService(model.DB, nil) user, _ := svc.CreateUser(context.Background(), &dto.CreateUserRequest{ User: dto.User{UserName: dto.UserName{Username: "2fa"}, Email: "2fa@e.com"}, Password: dto.Password{Password: "pass"}, diff --git a/backend/internal/service/friend_service_test.go b/backend/internal/service/friend_service_test.go index 4a65641..8d16b2d 100644 --- a/backend/internal/service/friend_service_test.go +++ b/backend/internal/service/friend_service_test.go @@ -12,7 +12,7 @@ import ( func TestGetAllUsersLimitedInfo(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) ctx := context.Background() // Create users @@ -47,7 +47,7 @@ func TestGetAllUsersLimitedInfo(t *testing.T) { func TestAddNewFriend(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) ctx := context.Background() u1, _ := svc.CreateUser(ctx, &dto.CreateUserRequest{ @@ -119,7 +119,7 @@ func TestAddNewFriend(t *testing.T) { func TestGetUserFriends(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) ctx := context.Background() u1, _ := svc.CreateUser(ctx, &dto.CreateUserRequest{ diff --git a/backend/internal/service/google_oauth_service_test.go b/backend/internal/service/google_oauth_service_test.go index ce0c89f..9a87066 100644 --- a/backend/internal/service/google_oauth_service_test.go +++ b/backend/internal/service/google_oauth_service_test.go @@ -16,7 +16,7 @@ import ( func TestGetGoogleOAuthURL(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) ctx := context.Background() t.Run("Success", func(t *testing.T) { @@ -45,7 +45,7 @@ func TestGetGoogleOAuthURL(t *testing.T) { func TestHandleGoogleOAuthCallback_InvalidState(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) ctx := context.Background() // Helper to parse redirect URL @@ -80,7 +80,7 @@ func TestHandleGoogleOAuthCallback_InvalidState(t *testing.T) { func TestHandleGoogleOAuthCallback_Success(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) ctx := context.Background() // Mock dependencies @@ -142,7 +142,7 @@ func TestHandleGoogleOAuthCallback_Success(t *testing.T) { func TestHandleGoogleOAuthCallback_Errors(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) ctx := context.Background() origExchange := ExchangeCodeForTokens @@ -184,7 +184,7 @@ func TestHandleGoogleOAuthCallback_Errors(t *testing.T) { func TestLinkGoogleAccountToExistingUser(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) ctx := context.Background() u, _ := svc.CreateUser(ctx, &dto.CreateUserRequest{ @@ -254,7 +254,7 @@ func TestLinkGoogleAccountToExistingUser(t *testing.T) { func TestCreateNewUserFromGoogleInfo(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) ctx := context.Background() t.Run("Success", func(t *testing.T) { @@ -304,7 +304,7 @@ func TestCreateNewUserFromGoogleInfo(t *testing.T) { func TestHandleGoogleOAuthCallback_DBError(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) ctx := context.Background() origExchange := ExchangeCodeForTokens @@ -340,7 +340,7 @@ func TestHandleGoogleOAuthCallback_DBError(t *testing.T) { func TestHandleGoogleOAuthCallback_LinkError(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) ctx := context.Background() origExchange := ExchangeCodeForTokens diff --git a/backend/internal/service/helper_test.go b/backend/internal/service/helper_test.go index f37ee6e..863d331 100644 --- a/backend/internal/service/helper_test.go +++ b/backend/internal/service/helper_test.go @@ -61,7 +61,7 @@ func TestHelperFunctions(t *testing.T) { t.Run("UpdateHeartBeat", func(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) // Create user first to satisfy FK _, _ = svc.CreateUser(context.Background(), &dto.CreateUserRequest{ @@ -83,7 +83,7 @@ func TestHelperFunctions(t *testing.T) { t.Run("IssueNewTokenForUser", func(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) // Create user first _, _ = svc.CreateUser(context.Background(), &dto.CreateUserRequest{ @@ -113,7 +113,7 @@ func TestHelperFunctions(t *testing.T) { t.Run("IssueNewTokenForUser_DBError", func(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) sqlDB, _ := db.DB() _ = sqlDB.Close() diff --git a/backend/internal/service/twofa_service_test.go b/backend/internal/service/twofa_service_test.go index c51407a..3c22ac7 100644 --- a/backend/internal/service/twofa_service_test.go +++ b/backend/internal/service/twofa_service_test.go @@ -16,7 +16,7 @@ func TestTwoFASetupAndConfirm(t *testing.T) { t.Run("StartSetup_Success", func(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) u, _ := svc.CreateUser(ctx, &dto.CreateUserRequest{ User: dto.User{UserName: dto.UserName{Username: "u1"}, Email: "u1@e.com"}, Password: dto.Password{Password: "p"}, @@ -33,7 +33,7 @@ func TestTwoFASetupAndConfirm(t *testing.T) { t.Run("ConfirmSetup_Success", func(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) u, _ := svc.CreateUser(ctx, &dto.CreateUserRequest{ User: dto.User{UserName: dto.UserName{Username: "u2"}, Email: "u2@e.com"}, Password: dto.Password{Password: "p"}, @@ -61,7 +61,7 @@ func TestTwoFASetupAndConfirm(t *testing.T) { t.Run("StartSetup_AlreadyEnabled", func(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) u, _ := svc.CreateUser(ctx, &dto.CreateUserRequest{ User: dto.User{UserName: dto.UserName{Username: "u3"}, Email: "u3@e.com"}, Password: dto.Password{Password: "p"}, @@ -85,7 +85,7 @@ func TestTwoFASetupAndConfirm(t *testing.T) { t.Run("StartSetup_OAuthUser", func(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) // Mock OAuth user oauthUser := dto.GoogleUserData{ ID: "oauth123", @@ -108,7 +108,7 @@ func TestTwoFASetupAndConfirm(t *testing.T) { t.Run("StartSetup_DBError", func(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) u, _ := svc.CreateUser(ctx, &dto.CreateUserRequest{ User: dto.User{UserName: dto.UserName{Username: "u4"}, Email: "u4@e.com"}, Password: dto.Password{Password: "p"}, @@ -125,7 +125,7 @@ func TestTwoFASetupAndConfirm(t *testing.T) { func TestConfirmTwoFaSetup_Errors(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) ctx := context.Background() u, _ := svc.CreateUser(ctx, &dto.CreateUserRequest{ @@ -193,7 +193,7 @@ func TestConfirmTwoFaSetup_Errors(t *testing.T) { t.Run("NotInitiated", func(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) // User with no 2FA token u, _ := svc.CreateUser(ctx, &dto.CreateUserRequest{ User: dto.User{UserName: dto.UserName{Username: "ni"}, Email: "ni@e.com"}, @@ -221,7 +221,7 @@ func TestTwoFAChallenge(t *testing.T) { t.Run("Success", func(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) u, _ := svc.CreateUser(ctx, &dto.CreateUserRequest{ User: dto.User{UserName: dto.UserName{Username: "ch1"}, Email: "ch1@e.com"}, Password: dto.Password{Password: "p"}, @@ -253,7 +253,7 @@ func TestTwoFAChallenge(t *testing.T) { t.Run("InvalidCode", func(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) u, _ := svc.CreateUser(ctx, &dto.CreateUserRequest{ User: dto.User{UserName: dto.UserName{Username: "ch2"}, Email: "ch2@e.com"}, Password: dto.Password{Password: "p"}, @@ -281,7 +281,7 @@ func TestTwoFAChallenge(t *testing.T) { t.Run("NotEnabled", func(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) u, _ := svc.CreateUser(ctx, &dto.CreateUserRequest{ User: dto.User{UserName: dto.UserName{Username: "chne"}, Email: "chne@e.com"}, Password: dto.Password{Password: "p"}, @@ -304,7 +304,7 @@ func TestTwoFAChallenge(t *testing.T) { t.Run("DBError", func(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) u, _ := svc.CreateUser(ctx, &dto.CreateUserRequest{ User: dto.User{UserName: dto.UserName{Username: "ch3"}, Email: "ch3@e.com"}, Password: dto.Password{Password: "p"}, @@ -338,7 +338,7 @@ func TestDisableTwoFA(t *testing.T) { t.Run("Success", func(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) u, _ := svc.CreateUser(ctx, &dto.CreateUserRequest{ User: dto.User{UserName: dto.UserName{Username: "dis1"}, Email: "dis1@e.com"}, Password: dto.Password{Password: "p"}, @@ -361,7 +361,7 @@ func TestDisableTwoFA(t *testing.T) { t.Run("AlreadyDisabled", func(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) u, _ := svc.CreateUser(ctx, &dto.CreateUserRequest{ User: dto.User{UserName: dto.UserName{Username: "dis2"}, Email: "dis2@e.com"}, Password: dto.Password{Password: "p"}, @@ -378,7 +378,7 @@ func TestDisableTwoFA(t *testing.T) { t.Run("OAuthUser", func(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) // Mock OAuth user oauthUser := dto.GoogleUserData{ ID: "oauth456", @@ -401,7 +401,7 @@ func TestDisableTwoFA(t *testing.T) { t.Run("DBError", func(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) u, _ := svc.CreateUser(ctx, &dto.CreateUserRequest{ User: dto.User{UserName: dto.UserName{Username: "dis3"}, Email: "dis3@e.com"}, Password: dto.Password{Password: "p"}, @@ -421,7 +421,7 @@ func TestDisableTwoFA(t *testing.T) { t.Run("InvalidPassword", func(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) u, _ := svc.CreateUser(ctx, &dto.CreateUserRequest{ User: dto.User{UserName: dto.UserName{Username: "disinv"}, Email: "disinv@e.com"}, Password: dto.Password{Password: "correct"}, diff --git a/backend/internal/service/user_service_test.go b/backend/internal/service/user_service_test.go index 6971642..540196a 100644 --- a/backend/internal/service/user_service_test.go +++ b/backend/internal/service/user_service_test.go @@ -12,7 +12,7 @@ import ( func TestCreateUser(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) ctx := context.Background() t.Run("Success", func(t *testing.T) { @@ -83,7 +83,7 @@ func TestCreateUser(t *testing.T) { func TestLoginUser(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) ctx := context.Background() // Setup user @@ -232,7 +232,7 @@ func TestLoginUser(t *testing.T) { func TestGetUserByID(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) ctx := context.Background() u, _ := svc.CreateUser(ctx, &dto.CreateUserRequest{ @@ -267,7 +267,7 @@ func TestGetUserByID(t *testing.T) { func TestUpdateUserPassword(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) ctx := context.Background() u, _ := svc.CreateUser(ctx, &dto.CreateUserRequest{ @@ -369,7 +369,7 @@ func TestUpdateUserPassword(t *testing.T) { func TestUpdateUserProfile(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) ctx := context.Background() u, _ := svc.CreateUser(ctx, &dto.CreateUserRequest{ @@ -429,7 +429,7 @@ func TestUpdateUserProfile(t *testing.T) { func TestDeleteUser(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) ctx := context.Background() u, _ := svc.CreateUser(ctx, &dto.CreateUserRequest{ @@ -455,7 +455,7 @@ func TestDeleteUser(t *testing.T) { func TestValidateUserToken(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) ctx := context.Background() createReq := &dto.CreateUserRequest{ @@ -512,7 +512,7 @@ func TestValidateUserToken(t *testing.T) { func TestLogoutUser(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) ctx := context.Background() createReq := &dto.CreateUserRequest{ @@ -547,7 +547,7 @@ func TestDBErrors(t *testing.T) { t.Run("CreateUser", func(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) req := &dto.CreateUserRequest{ User: dto.User{UserName: dto.UserName{Username: "db1"}, Email: "db1@e.com"}, @@ -565,7 +565,7 @@ func TestDBErrors(t *testing.T) { t.Run("LoginUser", func(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) req := &dto.LoginUserRequest{ Identifier: dto.Identifier{Identifier: "db1"}, @@ -583,7 +583,7 @@ func TestDBErrors(t *testing.T) { t.Run("GetUserByID", func(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) sqlDB, _ := db.DB() _ = sqlDB.Close() @@ -596,7 +596,7 @@ func TestDBErrors(t *testing.T) { t.Run("UpdateUserPassword", func(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) req := &dto.UpdateUserPasswordRequest{ OldPassword: dto.OldPassword{OldPassword: "p"}, @@ -614,7 +614,7 @@ func TestDBErrors(t *testing.T) { t.Run("UpdateUserProfile", func(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) req := &dto.UpdateUserRequest{ User: dto.User{UserName: dto.UserName{Username: "n"}, Email: "n@e.com"}, @@ -631,7 +631,7 @@ func TestDBErrors(t *testing.T) { t.Run("DeleteUser", func(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) sqlDB, _ := db.DB() _ = sqlDB.Close() @@ -644,7 +644,7 @@ func TestDBErrors(t *testing.T) { t.Run("ValidateUserToken", func(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) sqlDB, _ := db.DB() _ = sqlDB.Close() @@ -657,7 +657,7 @@ func TestDBErrors(t *testing.T) { t.Run("LogoutUser", func(t *testing.T) { db := setupTestDB(t.Name()) - svc := NewUserService(db) + svc := NewUserService(db, nil) sqlDB, _ := db.DB() _ = sqlDB.Close() From 2d5b52bb5d1da12959388a01cb41980195cb8062 Mon Sep 17 00:00:00 2001 From: Xin Feng <126309503+danielxfeng@users.noreply.github.com> Date: Tue, 27 Jan 2026 12:56:08 +0200 Subject: [PATCH 16/21] test/backend: add some tests for redis integration --- backend/go.mod | 2 + backend/go.sum | 4 + .../routers/users_router_redis_test.go | 187 +++++++++++++++++ .../internal/service/redis_service_test.go | 194 ++++++++++++++++++ backend/internal/service/setup_test.go | 46 ++++- 5 files changed, 424 insertions(+), 9 deletions(-) create mode 100644 backend/internal/routers/users_router_redis_test.go create mode 100644 backend/internal/service/redis_service_test.go diff --git a/backend/go.mod b/backend/go.mod index 9f0d88b..66f7d53 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -23,8 +23,10 @@ require ( ) require ( + github.com/alicebob/miniredis/v2 v2.36.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/yuin/gopher-lua v1.1.1 // indirect ) require ( diff --git a/backend/go.sum b/backend/go.sum index 1a28d33..1bb3b96 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -4,6 +4,8 @@ cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdB cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= +github.com/alicebob/miniredis/v2 v2.36.1 h1:Dvc5oAnNOr7BIfPn7tF269U8DvRW1dBG2D5n0WrfYMI= +github.com/alicebob/miniredis/v2 v2.36.1/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI= github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= @@ -157,6 +159,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2 github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= +github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= diff --git a/backend/internal/routers/users_router_redis_test.go b/backend/internal/routers/users_router_redis_test.go new file mode 100644 index 0000000..daf0dff --- /dev/null +++ b/backend/internal/routers/users_router_redis_test.go @@ -0,0 +1,187 @@ +package routers + +import ( + "bytes" + "context" + "encoding/json" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "strconv" + "strings" + "testing" + "time" + + "github.com/alicebob/miniredis/v2" + "github.com/gin-gonic/gin" + "github.com/redis/go-redis/v9" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + + "github.com/paularynty/transcendence/auth-service-go/internal/config" + db "github.com/paularynty/transcendence/auth-service-go/internal/db" + "github.com/paularynty/transcendence/auth-service-go/internal/dto" + "github.com/paularynty/transcendence/auth-service-go/internal/util" +) + +func setupUsersRouterTestRedis(t *testing.T) (*gin.Engine, *miniredis.Miniredis, func()) { + t.Helper() + gin.SetMode(gin.TestMode) + + util.Logger = slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ + Level: slog.LevelError, + })) + + prevCfg := config.Cfg + config.Cfg = &config.Config{ + JwtSecret: "test-secret", + UserTokenExpiry: 60, + UserTokenAbsoluteExpiry: 600, + TwoFaTokenExpiry: 3600, + OauthStateTokenExpiry: 3600, + GoogleClientId: "test-client", + GoogleRedirectUri: "http://localhost/cb", + FrontendUrl: "http://localhost:3000", + IsRedisEnabled: true, + } + dto.InitValidator() + + // DB setup matches existing patterns. + dbName := "file:" + strings.ReplaceAll(t.Name(), "/", "_") + "?mode=memory&cache=shared&_busy_timeout=5000&_foreign_keys=on" + var err error + db.DB, err = gorm.Open(sqlite.Open(dbName), &gorm.Config{TranslateError: true}) + if err != nil { + t.Fatalf("failed to connect to db: %v", err) + } + db.DB.Exec("PRAGMA foreign_keys = ON") + + err = db.DB.AutoMigrate(&db.User{}, &db.Friend{}, &db.Token{}, &db.HeartBeat{}) + if err != nil { + t.Fatalf("failed to migrate db: %v", err) + } + + // Redis setup. + mr := miniredis.RunT(t) + redisClient := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + db.Redis = redisClient + config.Cfg.RedisURL = "redis://" + mr.Addr() + + router := gin.New() + UsersRouter(router.Group("/users")) + + if db.DB != nil { + sqlDB, _ := db.DB.DB() + if sqlDB != nil { + sqlDB.SetMaxOpenConns(1) + } + } + + cleanup := func() { + config.Cfg = prevCfg + if db.Redis != nil { + _ = db.Redis.Close() + db.Redis = nil + } + mr.Close() + if db.DB != nil { + sqlDB, _ := db.DB.DB() + if sqlDB != nil { + _ = sqlDB.Close() + } + db.DB = nil + } + } + + return router, mr, cleanup +} + +func TestUsersRouter_Redis_LoginValidateLogout(t *testing.T) { + router, mr, cleanup := setupUsersRouterTestRedis(t) + defer cleanup() + + // Create user + createReq := dto.CreateUserRequest{ + User: dto.User{UserName: dto.UserName{Username: "redisrouter"}, Email: "redisrouter@example.com"}, + Password: dto.Password{Password: "password123"}, + } + createBody, _ := json.Marshal(createReq) + createResp := httptest.NewRecorder() + createHTTP := httptest.NewRequest(http.MethodPost, "/users/", bytes.NewBuffer(createBody)) + createHTTP.Header.Set("Content-Type", "application/json") + router.ServeHTTP(createResp, createHTTP) + if createResp.Code != http.StatusCreated { + t.Fatalf("expected 201 on create, got %d. Body: %s", createResp.Code, createResp.Body.String()) + } + + // Login user + loginReq := dto.LoginUserRequest{ + Identifier: dto.Identifier{Identifier: "redisrouter"}, + Password: dto.Password{Password: "password123"}, + } + loginBody, _ := json.Marshal(loginReq) + loginResp := httptest.NewRecorder() + loginHTTP := httptest.NewRequest(http.MethodPost, "/users/loginByIdentifier", bytes.NewBuffer(loginBody)) + loginHTTP.Header.Set("Content-Type", "application/json") + router.ServeHTTP(loginResp, loginHTTP) + if loginResp.Code != http.StatusOK { + t.Fatalf("expected 200 on login, got %d. Body: %s", loginResp.Code, loginResp.Body.String()) + } + + var loginUser dto.UserWithTokenResponse + _ = json.Unmarshal(loginResp.Body.Bytes(), &loginUser) + if loginUser.Token == "" || loginUser.ID == 0 { + t.Fatalf("expected login token and id, got: %+v", loginUser) + } + + // Ensure token is stored in Redis (by key prefix). + keys := mr.Keys() + wantPrefix := "user_token:" + strconv.FormatUint(uint64(loginUser.ID), 10) + ":" + foundTokenKey := false + for _, k := range keys { + if strings.HasPrefix(k, wantPrefix) { + foundTokenKey = true + break + } + } + if !foundTokenKey { + t.Fatalf("expected redis token key with prefix %q, keys: %v", wantPrefix, keys) + } + + // Login should update heartbeat in Redis. + time.Sleep(100 * time.Millisecond) + score, err := db.Redis.ZScore(context.Background(), "heartbeat:", strconv.FormatUint(uint64(loginUser.ID), 10)).Result() + if err != nil { + t.Fatalf("expected heartbeat entry after login, got error: %v", err) + } + if int64(score) < time.Now().Unix()-5 { + t.Fatalf("expected recent heartbeat score after login, got %v", score) + } + + // Validate should succeed + validateResp := httptest.NewRecorder() + validateHTTP := httptest.NewRequest(http.MethodPost, "/users/validate", nil) + validateHTTP.Header.Set("Authorization", "Bearer "+loginUser.Token) + router.ServeHTTP(validateResp, validateHTTP) + if validateResp.Code != http.StatusOK { + t.Fatalf("expected 200 on validate, got %d. Body: %s", validateResp.Code, validateResp.Body.String()) + } + + // Logout should revoke redis tokens + logoutResp := httptest.NewRecorder() + logoutHTTP := httptest.NewRequest(http.MethodDelete, "/users/logout", nil) + logoutHTTP.Header.Set("Authorization", "Bearer "+loginUser.Token) + router.ServeHTTP(logoutResp, logoutHTTP) + if logoutResp.Code != http.StatusNoContent { + t.Fatalf("expected 204 on logout, got %d. Body: %s", logoutResp.Code, logoutResp.Body.String()) + } + + // Validate again should fail + validateAfterResp := httptest.NewRecorder() + validateAfterHTTP := httptest.NewRequest(http.MethodPost, "/users/validate", nil) + validateAfterHTTP.Header.Set("Authorization", "Bearer "+loginUser.Token) + router.ServeHTTP(validateAfterResp, validateAfterHTTP) + if validateAfterResp.Code != http.StatusUnauthorized { + t.Fatalf("expected 401 on validate after logout, got %d. Body: %s", validateAfterResp.Code, validateAfterResp.Body.String()) + } +} diff --git a/backend/internal/service/redis_service_test.go b/backend/internal/service/redis_service_test.go new file mode 100644 index 0000000..be8bbe4 --- /dev/null +++ b/backend/internal/service/redis_service_test.go @@ -0,0 +1,194 @@ +package service + +import ( + "context" + "errors" + "fmt" + "strings" + "testing" + "time" + + "github.com/paularynty/transcendence/auth-service-go/internal/config" + "github.com/paularynty/transcendence/auth-service-go/internal/dto" + "github.com/paularynty/transcendence/auth-service-go/internal/middleware" + "github.com/redis/go-redis/v9" +) + +func withRedisTestExpiries(t *testing.T, userTTLSeconds int, absoluteTTLSeconds int) func() { + t.Helper() + + prevCfg := config.Cfg + cfgCopy := *prevCfg + cfgCopy.UserTokenExpiry = userTTLSeconds + cfgCopy.UserTokenAbsoluteExpiry = absoluteTTLSeconds + config.Cfg = &cfgCopy + + return func() { + config.Cfg = prevCfg + } +} + +func TestRedisTokenLifecycle(t *testing.T) { + db := setupTestDB(t.Name()) + mr, redisClient, cleanupRedis := setupTestRedis(t) + defer cleanupRedis() + defer withRedisTestExpiries(t, 10, 30)() + + svc := NewUserService(db, redisClient) + ctx := context.Background() + + userResp, err := svc.CreateUser(ctx, &dto.CreateUserRequest{ + User: dto.User{UserName: dto.UserName{Username: "redisuser"}, Email: "redis@example.com"}, + Password: dto.Password{Password: "password123"}, + }) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + + token, err := svc.issueNewTokenForUser(ctx, userResp.ID, false) + if err != nil { + t.Fatalf("failed to issue token: %v", err) + } + if token == "" { + t.Fatal("expected non-empty token") + } + + key := buildTokenKey(userResp.ID, token) + if !mr.Exists(key) { + t.Fatalf("expected redis token key to exist: %s", key) + } + + // Drive time close to expiry, then validate and ensure TTL slides forward. + mr.FastForward(9 * time.Second) + ttlBefore := mr.TTL(key) + if ttlBefore <= 0 { + t.Fatalf("expected TTL before validation to be positive, got %v", ttlBefore) + } + + if err := svc.ValidateUserToken(ctx, token, userResp.ID); err != nil { + t.Fatalf("expected token to validate, got %v", err) + } + + ttlAfter := mr.TTL(key) + if ttlAfter < 8*time.Second { + t.Fatalf("expected sliding TTL refresh, got %v", ttlAfter) + } + + // Logout should revoke all redis tokens for the user. + if err := svc.LogoutUser(ctx, userResp.ID); err != nil { + t.Fatalf("logout failed: %v", err) + } + + if mr.Exists(key) { + t.Fatal("expected redis token key to be deleted on logout") + } + + err = svc.ValidateUserToken(ctx, token, userResp.ID) + if err == nil { + t.Fatal("expected token to be invalid after logout") + } + var authErr *middleware.AuthError + if !strings.Contains(err.Error(), "invalid token") || !errors.As(err, &authErr) { + t.Fatalf("expected auth error for invalid token, got %v", err) + } +} + +func TestRedisHeartbeatOnlineStatusAndCleanup(t *testing.T) { + db := setupTestDB(t.Name()) + _, redisClient, cleanupRedis := setupTestRedis(t) + defer cleanupRedis() + + svc := NewUserService(db, redisClient) + ctx := context.Background() + + u1, err := svc.CreateUser(ctx, &dto.CreateUserRequest{ + User: dto.User{UserName: dto.UserName{Username: "hb1"}, Email: "hb1@example.com"}, + Password: dto.Password{Password: "password123"}, + }) + if err != nil { + t.Fatalf("failed to create user1: %v", err) + } + + _, err = svc.CreateUser(ctx, &dto.CreateUserRequest{ + User: dto.User{UserName: dto.UserName{Username: "hb2"}, Email: "hb2@example.com"}, + Password: dto.Password{Password: "password123"}, + }) + if err != nil { + t.Fatalf("failed to create user2: %v", err) + } + + svc.updateHeartBeat(u1.ID) + time.Sleep(100 * time.Millisecond) + + onlineNow, err := svc.getOnlineStatus(ctx) + if err != nil { + t.Fatalf("getOnlineStatus failed: %v", err) + } + + checkerNow := newOnlineStatusChecker(onlineNow) + if !checkerNow.isOnline(u1.ID) { + t.Fatal("expected user1 to be online after heartbeat") + } + + // Force the heartbeat score to be old, then ensure cleanup happens. + oldScore := float64(time.Now().Add(-3 * time.Minute).Unix()) + if err := redisClient.ZAdd(ctx, HeartBeatPrefix, redis.Z{Score: oldScore, Member: u1.ID}).Err(); err != nil { + t.Fatalf("failed to set old heartbeat score: %v", err) + } + + onlineLater, err := svc.getOnlineStatus(ctx) + if err != nil { + t.Fatalf("getOnlineStatus later failed: %v", err) + } + + checkerLater := newOnlineStatusChecker(onlineLater) + if checkerLater.isOnline(u1.ID) { + t.Fatal("expected user1 to be offline after expiration window") + } + + // Cleanup should have removed the expired heartbeat entry. + time.Sleep(100 * time.Millisecond) + if _, err := redisClient.ZScore(ctx, HeartBeatPrefix, fmt.Sprint(u1.ID)).Result(); err == nil { + t.Fatal("expected expired heartbeat to be removed from redis") + } +} + +func TestRedisLoginUpdatesHeartbeat(t *testing.T) { + db := setupTestDB(t.Name()) + _, redisClient, cleanupRedis := setupTestRedis(t) + defer cleanupRedis() + + svc := NewUserService(db, redisClient) + ctx := context.Background() + + created, err := svc.CreateUser(ctx, &dto.CreateUserRequest{ + User: dto.User{UserName: dto.UserName{Username: "loginhb"}, Email: "loginhb@example.com"}, + Password: dto.Password{Password: "password123"}, + }) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + userID := created.ID + + res, err := svc.LoginUser(ctx, &dto.LoginUserRequest{ + Identifier: dto.Identifier{Identifier: "loginhb"}, + Password: dto.Password{Password: "password123"}, + }) + if err != nil { + t.Fatalf("login failed: %v", err) + } + if res.User == nil || res.User.Token == "" { + t.Fatal("expected login to issue a valid token") + } + + time.Sleep(100 * time.Millisecond) + + score, err := redisClient.ZScore(ctx, HeartBeatPrefix, fmt.Sprint(userID)).Result() + if err != nil { + t.Fatalf("expected heartbeat entry for user, got error: %v", err) + } + now := time.Now().Unix() + if int64(score) < now-5 { + t.Fatalf("expected recent heartbeat score, got %v (now=%d)", score, now) + } +} diff --git a/backend/internal/service/setup_test.go b/backend/internal/service/setup_test.go index 1cad0bb..1c19af0 100644 --- a/backend/internal/service/setup_test.go +++ b/backend/internal/service/setup_test.go @@ -6,6 +6,8 @@ import ( "strings" "testing" + "github.com/alicebob/miniredis/v2" + "github.com/redis/go-redis/v9" "gorm.io/driver/sqlite" "gorm.io/gorm" @@ -44,15 +46,18 @@ func setupTestDB(testName string) *gorm.DB { func setupConfig() { config.Cfg = &config.Config{ - JwtSecret: "test-secret", - UserTokenExpiry: 3600, - OauthStateTokenExpiry: 600, - GoogleClientId: "test-client-id", - GoogleClientSecret: "test-client-secret", - GoogleRedirectUri: "http://localhost:8080/callback", - FrontendUrl: "http://localhost:3000", - TwoFaUrlPrefix: "otpauth://totp/Transcendence?secret=", - TwoFaTokenExpiry: 600, + JwtSecret: "test-secret", + UserTokenExpiry: 3600, + UserTokenAbsoluteExpiry: 2592000, + OauthStateTokenExpiry: 600, + GoogleClientId: "test-client-id", + GoogleClientSecret: "test-client-secret", + GoogleRedirectUri: "http://localhost:8080/callback", + FrontendUrl: "http://localhost:3000", + TwoFaUrlPrefix: "otpauth://totp/Transcendence?secret=", + TwoFaTokenExpiry: 600, + RedisURL: "", + IsRedisEnabled: false, } // Mock logger to discard output during tests @@ -66,3 +71,26 @@ func TestMain(m *testing.M) { code := m.Run() os.Exit(code) } + +func setupTestRedis(t *testing.T) (*miniredis.Miniredis, *redis.Client, func()) { + t.Helper() + + mr := miniredis.RunT(t) + client := redis.NewClient(&redis.Options{ + Addr: mr.Addr(), + }) + + prevCfg := config.Cfg + cfgCopy := *prevCfg + cfgCopy.RedisURL = "redis://" + mr.Addr() + cfgCopy.IsRedisEnabled = true + config.Cfg = &cfgCopy + + cleanup := func() { + _ = client.Close() + mr.Close() + config.Cfg = prevCfg + } + + return mr, client, cleanup +} From d860200ae26170be17094b77ebaff1a5dff34228 Mon Sep 17 00:00:00 2001 From: Xin Feng <126309503+danielxfeng@users.noreply.github.com> Date: Tue, 27 Jan 2026 13:04:25 +0200 Subject: [PATCH 17/21] docs: update README to include Redis configuration and features --- README.md | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 9bab707..157d62e 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,8 @@ Currently supported features include: - Friend listing - Friend requests - Online status tracking +- Redis-backed session tokens (Optional) (revocation + sliding expiration) +- Redis-backed heartbeats for online status (Optional) ## Libraries @@ -42,6 +44,7 @@ Currently supported features include: - `gin`: web framework - `gorm`: ORM +- `go-redis`: Redis - `go-playground/validator v10`: data validation - `godotenv`: environment variables - `slog-gin`: logging @@ -77,6 +80,21 @@ make dev Then navigate to `http://localhost:3003/api/docs/index.html` for swagger. +Redis is optional. To enable it locally: + +```bash +# example: run redis with docker +docker run --rm -p 6379:6379 redis:latest + +# enable redis mode for the backend +export REDIS_URL=redis://localhost:6379/0 +``` + +Token extension (sliding expiration) in Redis mode: + +- `USER_TOKEN_EXPIRY` controls the Redis TTL and is extended on token validation. +- `USER_TOKEN_ABSOLUTE_EXPIRY` caps the maximum lifetime via the JWT `exp` claim. + ### Frontend ```bash @@ -93,6 +111,6 @@ Due to the constraints of the Hive project, `SQLite` was required for the projec As a result: -- `SQLite` is used to store authentication tokens and heartbeat data. In production, these would be better handled by `Redis`. -- Stale tokens and heartbeat data are not automatically cleaned up, and token auto-renewal is not implemented. +- The project still uses `SQLite` for core data due to Hive constraints. +- Redis-backed tokens and heartbeats are implemented, but the sliding expiration and cleanup strategy is simple. - On the frontend side, friend auto-completion is implemented in a basic manner. From f3c58a4a52439362183d06d776ad9e25b84feefd Mon Sep 17 00:00:00 2001 From: Xin Feng <126309503+danielxfeng@users.noreply.github.com> Date: Tue, 27 Jan 2026 13:22:58 +0200 Subject: [PATCH 18/21] chore/backend: format --- backend/internal/db/redis.go | 6 +++--- backend/internal/service/helper.go | 2 +- backend/internal/service/user_service.go | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/backend/internal/db/redis.go b/backend/internal/db/redis.go index 8d7fe24..76c935e 100644 --- a/backend/internal/db/redis.go +++ b/backend/internal/db/redis.go @@ -22,10 +22,10 @@ func ConnectRedis(redisURL string) { panic("failed to parse redis url, err: " + err.Error()) } - client := redis.NewClient(opt) + client := redis.NewClient(opt) ctx := context.Background() - + _, err = client.Ping(ctx).Result() if err != nil { panic("failed to connect to redis: " + err.Error()) @@ -47,4 +47,4 @@ func CloseRedis() { } else { util.Logger.Info("redis connection closed") } -} \ No newline at end of file +} diff --git a/backend/internal/service/helper.go b/backend/internal/service/helper.go index f37c15e..4d21991 100644 --- a/backend/internal/service/helper.go +++ b/backend/internal/service/helper.go @@ -154,7 +154,7 @@ func (s *UserService) clearExpiredHeartBeatsByRedis() { go func() { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() - + err := s.Redis.ZRemRangeByScore(ctx, HeartBeatPrefix, "-inf", strconv.FormatInt(time.Now().Add(-2*time.Minute).Unix(), 10)).Err() if err != nil { util.Logger.Warn("failed to clear expired heartbeats from redis", "err", err) diff --git a/backend/internal/service/user_service.go b/backend/internal/service/user_service.go index 835411b..d56d5a5 100644 --- a/backend/internal/service/user_service.go +++ b/backend/internal/service/user_service.go @@ -257,7 +257,7 @@ func (s *UserService) validateUserTokenRedis(ctx context.Context, token string, // A rough way to implement sliding expiration s.Redis.Expire(ctx, buildTokenKey(userId, token), time.Duration(config.Cfg.UserTokenExpiry)*time.Second) - + s.updateHeartBeat(userId) return nil } From 8dedd6eea2b4e04221da8794ed59aba0dd6f3cd6 Mon Sep 17 00:00:00 2001 From: Xin Feng <126309503+danielxfeng@users.noreply.github.com> Date: Tue, 27 Jan 2026 23:24:03 +0200 Subject: [PATCH 19/21] feat/backend: add tests for user logout and token revocation in Redis --- .../internal/service/redis_service_test.go | 152 ++++++++++++++++++ backend/internal/service/user_service.go | 7 + 2 files changed, 159 insertions(+) diff --git a/backend/internal/service/redis_service_test.go b/backend/internal/service/redis_service_test.go index be8bbe4..a57a8c4 100644 --- a/backend/internal/service/redis_service_test.go +++ b/backend/internal/service/redis_service_test.go @@ -192,3 +192,155 @@ func TestRedisLoginUpdatesHeartbeat(t *testing.T) { t.Fatalf("expected recent heartbeat score, got %v (now=%d)", score, now) } } + +func TestRedisLogoutRevokesAllTokens(t *testing.T) { + db := setupTestDB(t.Name()) + mr, redisClient, cleanupRedis := setupTestRedis(t) + defer cleanupRedis() + + svc := NewUserService(db, redisClient) + ctx := context.Background() + + userResp, err := svc.CreateUser(ctx, &dto.CreateUserRequest{ + User: dto.User{UserName: dto.UserName{Username: "logoutmulti"}, Email: "logoutmulti@example.com"}, + Password: dto.Password{Password: "password123"}, + }) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + + token1, err := svc.issueNewTokenForUser(ctx, userResp.ID, false) + if err != nil { + t.Fatalf("failed to issue token1: %v", err) + } + token2, err := svc.issueNewTokenForUser(ctx, userResp.ID, false) + if err != nil { + t.Fatalf("failed to issue token2: %v", err) + } + + key1 := buildTokenKey(userResp.ID, token1) + key2 := buildTokenKey(userResp.ID, token2) + if !mr.Exists(key1) || !mr.Exists(key2) { + t.Fatalf("expected both redis token keys to exist: %s, %s", key1, key2) + } + + if err := svc.LogoutUser(ctx, userResp.ID); err != nil { + t.Fatalf("logout failed: %v", err) + } + + if mr.Exists(key1) || mr.Exists(key2) { + t.Fatal("expected redis token keys to be deleted on logout") + } + + if err := svc.ValidateUserToken(ctx, token1, userResp.ID); err == nil { + t.Fatal("expected token1 to be invalid after logout") + } + if err := svc.ValidateUserToken(ctx, token2, userResp.ID); err == nil { + t.Fatal("expected token2 to be invalid after logout") + } +} + +func TestRedisDeleteUserRevokesAllTokens(t *testing.T) { + db := setupTestDB(t.Name()) + mr, redisClient, cleanupRedis := setupTestRedis(t) + defer cleanupRedis() + + svc := NewUserService(db, redisClient) + ctx := context.Background() + + userResp, err := svc.CreateUser(ctx, &dto.CreateUserRequest{ + User: dto.User{UserName: dto.UserName{Username: "delredis"}, Email: "delredis@example.com"}, + Password: dto.Password{Password: "password123"}, + }) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + + token1, err := svc.issueNewTokenForUser(ctx, userResp.ID, false) + if err != nil { + t.Fatalf("failed to issue token1: %v", err) + } + token2, err := svc.issueNewTokenForUser(ctx, userResp.ID, false) + if err != nil { + t.Fatalf("failed to issue token2: %v", err) + } + + key1 := buildTokenKey(userResp.ID, token1) + key2 := buildTokenKey(userResp.ID, token2) + if !mr.Exists(key1) || !mr.Exists(key2) { + t.Fatalf("expected both redis token keys to exist: %s, %s", key1, key2) + } + + if err := svc.DeleteUser(ctx, userResp.ID); err != nil { + t.Fatalf("delete failed: %v", err) + } + + if mr.Exists(key1) || mr.Exists(key2) { + t.Fatal("expected redis token keys to be deleted on user deletion") + } + + if err := svc.ValidateUserToken(ctx, token1, userResp.ID); err == nil { + t.Fatal("expected token1 to be invalid after delete") + } + if err := svc.ValidateUserToken(ctx, token2, userResp.ID); err == nil { + t.Fatal("expected token2 to be invalid after delete") + } +} + +func TestRedisUpdatePasswordRevokesOldTokens(t *testing.T) { + db := setupTestDB(t.Name()) + mr, redisClient, cleanupRedis := setupTestRedis(t) + defer cleanupRedis() + + svc := NewUserService(db, redisClient) + ctx := context.Background() + + userResp, err := svc.CreateUser(ctx, &dto.CreateUserRequest{ + User: dto.User{UserName: dto.UserName{Username: "pwredis"}, Email: "pwredis@example.com"}, + Password: dto.Password{Password: "oldpass"}, + }) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + + token1, err := svc.issueNewTokenForUser(ctx, userResp.ID, false) + if err != nil { + t.Fatalf("failed to issue token1: %v", err) + } + token2, err := svc.issueNewTokenForUser(ctx, userResp.ID, false) + if err != nil { + t.Fatalf("failed to issue token2: %v", err) + } + + key1 := buildTokenKey(userResp.ID, token1) + key2 := buildTokenKey(userResp.ID, token2) + if !mr.Exists(key1) || !mr.Exists(key2) { + t.Fatalf("expected both redis token keys to exist: %s, %s", key1, key2) + } + + updateReq := &dto.UpdateUserPasswordRequest{ + OldPassword: dto.OldPassword{OldPassword: "oldpass"}, + NewPassword: dto.NewPassword{NewPassword: "newpass"}, + } + resp, err := svc.UpdateUserPassword(ctx, userResp.ID, updateReq) + if err != nil { + t.Fatalf("update password failed: %v", err) + } + if resp.Token == "" { + t.Fatal("expected new token from password update") + } + + if mr.Exists(key1) || mr.Exists(key2) { + t.Fatal("expected old redis token keys to be deleted on password change") + } + + if err := svc.ValidateUserToken(ctx, token1, userResp.ID); err == nil { + t.Fatal("expected token1 to be invalid after password change") + } + if err := svc.ValidateUserToken(ctx, token2, userResp.ID); err == nil { + t.Fatal("expected token2 to be invalid after password change") + } + if err := svc.ValidateUserToken(ctx, resp.Token, userResp.ID); err != nil { + t.Fatalf("expected new token to be valid after password change, got %v", err) + } +} diff --git a/backend/internal/service/user_service.go b/backend/internal/service/user_service.go index d56d5a5..2a7a558 100644 --- a/backend/internal/service/user_service.go +++ b/backend/internal/service/user_service.go @@ -188,6 +188,13 @@ func (s *UserService) UpdateUserProfile(ctx context.Context, userID uint, reques } func (s *UserService) DeleteUser(ctx context.Context, userID uint) error { + if config.Cfg.IsRedisEnabled { + err := logoutUserByRedis(ctx, s.Redis, userID) + if err != nil { + return err + } + } + res := s.DB.WithContext(ctx).Unscoped().Delete(&model.User{}, userID) if res.Error != nil { return res.Error From cc24fe9f01e745ed9d8a7136e35d56391b79656d Mon Sep 17 00:00:00 2001 From: Xin Feng <126309503+danielxfeng@users.noreply.github.com> Date: Tue, 27 Jan 2026 23:32:06 +0200 Subject: [PATCH 20/21] test/backend: add more tests for duplicated email --- .../service/google_oauth_service_test.go | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/backend/internal/service/google_oauth_service_test.go b/backend/internal/service/google_oauth_service_test.go index 9a87066..f8e08a5 100644 --- a/backend/internal/service/google_oauth_service_test.go +++ b/backend/internal/service/google_oauth_service_test.go @@ -138,6 +138,69 @@ func TestHandleGoogleOAuthCallback_Success(t *testing.T) { t.Error("expected token in redirect") } }) + + t.Run("ExistingEmailLink", func(t *testing.T) { + // Create a non-OAuth user with matching email + _, _ = svc.CreateUser(ctx, &dto.CreateUserRequest{ + User: dto.User{UserName: dto.UserName{Username: "emailmatch"}, Email: "linkme@google.com"}, + Password: dto.Password{Password: "p"}, + }) + + FetchGoogleUserInfo = func(payload *idtoken.Payload) (*dto.GoogleUserData, error) { + return &dto.GoogleUserData{ + ID: "g_link", + Email: "linkme@google.com", + Name: "Link Me", + }, nil + } + + redirectURL := svc.HandleGoogleOAuthCallback(ctx, "validcode", state) + u, _ := url.Parse(redirectURL) + q := u.Query() + if q.Get("token") == "" { + t.Error("expected token in redirect") + } + if q.Get("error") != "" { + t.Errorf("unexpected error in redirect: %s", q.Get("error")) + } + + // Verify existing user is linked + var user model.User + err := db.Where("email = ?", "linkme@google.com").First(&user).Error + if err != nil { + t.Fatal("expected existing user") + } + if user.GoogleOauthID == nil || *user.GoogleOauthID != "g_link" { + t.Error("expected google oauth id to be linked") + } + }) + + t.Run("ExistingEmailWith2FA", func(t *testing.T) { + // Create a user with 2FA enabled + u, _ := svc.CreateUser(ctx, &dto.CreateUserRequest{ + User: dto.User{UserName: dto.UserName{Username: "email2fa"}, Email: "2fa@google.com"}, + Password: dto.Password{Password: "p"}, + }) + db.Model(&model.User{}).Where("id = ?", u.ID).Update("two_fa_token", "secret") + + FetchGoogleUserInfo = func(payload *idtoken.Payload) (*dto.GoogleUserData, error) { + return &dto.GoogleUserData{ + ID: "g_2fa", + Email: "2fa@google.com", + Name: "Two Fa", + }, nil + } + + redirectURL := svc.HandleGoogleOAuthCallback(ctx, "validcode", state) + u2, _ := url.Parse(redirectURL) + q := u2.Query() + if q.Get("token") != "" { + t.Error("expected no token in redirect") + } + if q.Get("error") == "" { + t.Error("expected error in redirect for 2FA user") + } + }) } func TestHandleGoogleOAuthCallback_Errors(t *testing.T) { From bbc04febd1b882b0083a2a9491a9bcfff4172211 Mon Sep 17 00:00:00 2001 From: Xin Feng <126309503+danielxfeng@users.noreply.github.com> Date: Tue, 27 Jan 2026 23:47:50 +0200 Subject: [PATCH 21/21] test/backend: update Google OAuth callback tests for same-email linking scenario --- .../internal/service/google_oauth_service_test.go | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/backend/internal/service/google_oauth_service_test.go b/backend/internal/service/google_oauth_service_test.go index f8e08a5..4e6fcc2 100644 --- a/backend/internal/service/google_oauth_service_test.go +++ b/backend/internal/service/google_oauth_service_test.go @@ -157,21 +157,21 @@ func TestHandleGoogleOAuthCallback_Success(t *testing.T) { redirectURL := svc.HandleGoogleOAuthCallback(ctx, "validcode", state) u, _ := url.Parse(redirectURL) q := u.Query() - if q.Get("token") == "" { - t.Error("expected token in redirect") + if q.Get("token") != "" { + t.Error("expected no token in redirect") } - if q.Get("error") != "" { - t.Errorf("unexpected error in redirect: %s", q.Get("error")) + if q.Get("error") == "" { + t.Error("expected error in redirect for same-email linking") } - // Verify existing user is linked + // Verify existing user is NOT linked var user model.User err := db.Where("email = ?", "linkme@google.com").First(&user).Error if err != nil { t.Fatal("expected existing user") } - if user.GoogleOauthID == nil || *user.GoogleOauthID != "g_link" { - t.Error("expected google oauth id to be linked") + if user.GoogleOauthID != nil { + t.Error("expected google oauth id to remain unset") } })