diff --git a/backend/go-service/app/auth/controller/auth_controller.go b/backend/go-service/app/auth/controller/auth_controller.go index 45ef011..d9d3796 100644 --- a/backend/go-service/app/auth/controller/auth_controller.go +++ b/backend/go-service/app/auth/controller/auth_controller.go @@ -46,7 +46,7 @@ func (ctrl *AuthController) Register(c *gin.Context) { return } - utils.ResponseCreated(c, resp) + utils.ResponseOK(c, resp) } // Login 用户登录 @@ -82,11 +82,19 @@ func (ctrl *AuthController) Logout(c *gin.Context) { funcName := "controller.auth_controller.Logout" ctx := c.Request.Context() - userID, _ := middleware.GetCurrentUserID(c) + userID, ok := middleware.GetCurrentUserID(c) + if !ok { + utils.ResponseUnauthorized(c, "无法获取用户信息") + return + } + logs.Info(ctx, funcName, "用户登出", zap.Int64("user_id", userID)) - // 当前为无状态 JWT,登出由客户端删除 Token 实现 - // 后续可扩展:将 Token 加入 Redis 黑名单 + if err := ctrl.authService.Logout(ctx, userID); err != nil { + utils.ResponseError(c, "登出失败") + return + } + utils.ResponseOK(c, nil) } diff --git a/backend/go-service/app/auth/provider.go b/backend/go-service/app/auth/provider.go index 7e9e711..7cdd5c2 100644 --- a/backend/go-service/app/auth/provider.go +++ b/backend/go-service/app/auth/provider.go @@ -13,6 +13,7 @@ import ( var AuthSet = wire.NewSet( dao.NewUserDAO, dao.NewRoleDAO, + service.NewTokenStore, service.NewAuthService, controller.NewAuthController, controller.NewAdminAuthController, diff --git a/backend/go-service/app/auth/service/auth_service.go b/backend/go-service/app/auth/service/auth_service.go index 4d0515e..c199b04 100644 --- a/backend/go-service/app/auth/service/auth_service.go +++ b/backend/go-service/app/auth/service/auth_service.go @@ -28,17 +28,19 @@ var ( // AuthService 认证服务,处理注册、登录、Token 管理、个人信息等业务逻辑 type AuthService struct { - userDAO *dao.UserDAO - roleDAO *dao.RoleDAO - jwtCfg *config.JWTConfig + userDAO *dao.UserDAO + roleDAO *dao.RoleDAO + jwtCfg *config.JWTConfig + tokenStore *TokenStore } // NewAuthService 创建认证服务实例 -func NewAuthService(userDAO *dao.UserDAO, roleDAO *dao.RoleDAO, jwtCfg *config.JWTConfig) *AuthService { +func NewAuthService(userDAO *dao.UserDAO, roleDAO *dao.RoleDAO, jwtCfg *config.JWTConfig, tokenStore *TokenStore) *AuthService { return &AuthService{ - userDAO: userDAO, - roleDAO: roleDAO, - jwtCfg: jwtCfg, + userDAO: userDAO, + roleDAO: roleDAO, + jwtCfg: jwtCfg, + tokenStore: tokenStore, } } @@ -110,7 +112,7 @@ func (s *AuthService) Register(ctx context.Context, req *dto.RegisterRequest) (* // 获取角色列表并生成 Token roles, _ := s.roleDAO.GetUserRoleCodes(ctx, user.ID) - resp, err := s.buildLoginResponse(user, roles) + resp, err := s.buildLoginResponse(ctx, user, roles) if err != nil { return nil, err } @@ -162,9 +164,9 @@ func (s *AuthService) Login(ctx context.Context, req *dto.LoginRequest, clientIP // 更新最后登录信息 _ = s.userDAO.UpdateLastLogin(ctx, user.ID, clientIP) - // 获取角色列表并生成 Token + // 获取角色列表并生成 Token(同时存入 Redis) roles, _ := s.roleDAO.GetUserRoleCodes(ctx, user.ID) - resp, err := s.buildLoginResponse(user, roles) + resp, err := s.buildLoginResponse(ctx, user, roles) if err != nil { return nil, err } @@ -227,6 +229,14 @@ func (s *AuthService) RefreshToken(ctx context.Context, refreshToken string) (*d return nil, ErrRefreshTokenType } + // 验证 Refresh Token 是否与 Redis 中存储的一致 + if !s.tokenStore.ValidateRefreshToken(ctx, claims.UserID, refreshToken) { + logs.Warn(ctx, funcName, "Refresh Token 已失效(不在 Redis 中)", + zap.Int64("user_id", claims.UserID), + ) + return nil, ErrRefreshTokenType + } + // 查找用户(确保用户仍然有效) user, err := s.userDAO.FindByID(ctx, claims.UserID) if err != nil { @@ -240,9 +250,9 @@ func (s *AuthService) RefreshToken(ctx context.Context, refreshToken string) (*d return nil, err } - // 获取角色并生成新 Token + // 获取角色并生成新 Token(同时存入 Redis 覆盖旧 Token) roles, _ := s.roleDAO.GetUserRoleCodes(ctx, user.ID) - resp, err := s.buildLoginResponse(user, roles) + resp, err := s.buildLoginResponse(ctx, user, roles) if err != nil { return nil, err } @@ -251,6 +261,20 @@ func (s *AuthService) RefreshToken(ctx context.Context, refreshToken string) (*d return resp, nil } +// Logout 用户登出 +// 从 Redis 中删除该用户的 Access Token 和 Refresh Token +func (s *AuthService) Logout(ctx context.Context, userID int64) error { + funcName := "service.auth_service.Logout" + logs.Info(ctx, funcName, "用户登出", zap.Int64("user_id", userID)) + return s.tokenStore.RemoveTokens(ctx, userID) +} + +// ValidateAccessToken 校验 Access Token 是否在 Redis 中有效 +// 供 JWT 中间件调用,实现有状态 JWT 验证 +func (s *AuthService) ValidateAccessToken(ctx context.Context, userID int64, token string) bool { + return s.tokenStore.ValidateAccessToken(ctx, userID, token) +} + // GetProfile 获取用户个人信息 func (s *AuthService) GetProfile(ctx context.Context, userID int64) (*dto.UserInfo, error) { funcName := "service.auth_service.GetProfile" @@ -349,8 +373,8 @@ func (s *AuthService) checkUserStatus(status int) error { } } -// buildLoginResponse 构建登录响应(生成 Token + 用户信息) -func (s *AuthService) buildLoginResponse(user *model.User, roles []string) (*dto.LoginResponse, error) { +// buildLoginResponse 构建登录响应(生成 Token + 存入 Redis + 用户信息) +func (s *AuthService) buildLoginResponse(ctx context.Context, user *model.User, roles []string) (*dto.LoginResponse, error) { if roles == nil { roles = []string{} } @@ -365,6 +389,11 @@ func (s *AuthService) buildLoginResponse(user *model.User, roles []string) (*dto return nil, err } + // 将 Token 存入 Redis(有状态 JWT,支持主动失效和单设备登录) + if err = s.tokenStore.SaveTokens(ctx, user.ID, token, refreshToken); err != nil { + return nil, err + } + return &dto.LoginResponse{ Token: token, RefreshToken: refreshToken, diff --git a/backend/go-service/app/auth/service/token_store.go b/backend/go-service/app/auth/service/token_store.go new file mode 100644 index 0000000..f332855 --- /dev/null +++ b/backend/go-service/app/auth/service/token_store.go @@ -0,0 +1,119 @@ +package service + +import ( + "context" + "fmt" + "time" + + "github.com/echochat/backend/config" + "github.com/echochat/backend/pkg/logs" + "github.com/redis/go-redis/v9" + "go.uber.org/zap" +) + +// Redis Key 前缀,遵循设计方案中 echo:auth:* 的命名规范 +const ( + keyPrefixAccessToken = "echo:auth:token:" // echo:auth:token:{user_id} + keyPrefixRefreshToken = "echo:auth:refresh:" // echo:auth:refresh:{user_id} +) + +// TokenStore 管理 Token 在 Redis 中的存取 +// 实现有状态 JWT:登录存入、验证时校验、登出时删除 +type TokenStore struct { + redis *redis.Client + jwtCfg *config.JWTConfig +} + +// NewTokenStore 创建 TokenStore 实例 +func NewTokenStore(redisClient *redis.Client, jwtCfg *config.JWTConfig) *TokenStore { + return &TokenStore{ + redis: redisClient, + jwtCfg: jwtCfg, + } +} + +// SaveTokens 将 Access Token 和 Refresh Token 存入 Redis +// 每次登录/注册时调用,覆盖旧 Token(实现单设备登录) +func (s *TokenStore) SaveTokens(ctx context.Context, userID int64, accessToken, refreshToken string) error { + funcName := "service.token_store.SaveTokens" + + accessKey := fmt.Sprintf("%s%d", keyPrefixAccessToken, userID) + refreshKey := fmt.Sprintf("%s%d", keyPrefixRefreshToken, userID) + + accessTTL := time.Duration(s.jwtCfg.AccessExpireMin) * time.Minute + refreshTTL := time.Duration(s.jwtCfg.RefreshExpireDay) * 24 * time.Hour + + pipe := s.redis.Pipeline() + pipe.Set(ctx, accessKey, accessToken, accessTTL) + pipe.Set(ctx, refreshKey, refreshToken, refreshTTL) + + if _, err := pipe.Exec(ctx); err != nil { + logs.Error(ctx, funcName, "保存 Token 到 Redis 失败", + zap.Int64("user_id", userID), + zap.Error(err), + ) + return err + } + + logs.Debug(ctx, funcName, "Token 已存入 Redis", + zap.Int64("user_id", userID), + zap.Duration("access_ttl", accessTTL), + zap.Duration("refresh_ttl", refreshTTL), + ) + return nil +} + +// ValidateAccessToken 校验 Access Token 是否与 Redis 中存储的一致 +// 返回 true 表示有效,false 表示已被登出/覆盖 +func (s *TokenStore) ValidateAccessToken(ctx context.Context, userID int64, token string) bool { + funcName := "service.token_store.ValidateAccessToken" + + key := fmt.Sprintf("%s%d", keyPrefixAccessToken, userID) + stored, err := s.redis.Get(ctx, key).Result() + if err == redis.Nil { + logs.Debug(ctx, funcName, "Token 不存在(已登出或过期)", + zap.Int64("user_id", userID), + ) + return false + } + if err != nil { + logs.Error(ctx, funcName, "Redis 查询 Token 失败", + zap.Int64("user_id", userID), + zap.Error(err), + ) + return false + } + + return stored == token +} + +// ValidateRefreshToken 校验 Refresh Token 是否与 Redis 中存储的一致 +func (s *TokenStore) ValidateRefreshToken(ctx context.Context, userID int64, token string) bool { + key := fmt.Sprintf("%s%d", keyPrefixRefreshToken, userID) + stored, err := s.redis.Get(ctx, key).Result() + if err != nil { + return false + } + return stored == token +} + +// RemoveTokens 从 Redis 删除用户的所有 Token(登出时调用) +func (s *TokenStore) RemoveTokens(ctx context.Context, userID int64) error { + funcName := "service.token_store.RemoveTokens" + + accessKey := fmt.Sprintf("%s%d", keyPrefixAccessToken, userID) + refreshKey := fmt.Sprintf("%s%d", keyPrefixRefreshToken, userID) + + if err := s.redis.Del(ctx, accessKey, refreshKey).Err(); err != nil { + logs.Error(ctx, funcName, "删除 Token 失败", + zap.Int64("user_id", userID), + zap.Error(err), + ) + return err + } + + logs.Info(ctx, funcName, "Token 已从 Redis 删除", + zap.Int64("user_id", userID), + ) + return nil +} diff --git a/backend/go-service/app/provider/wire_gen.go b/backend/go-service/app/provider/wire_gen.go index d60bf45..c753327 100644 --- a/backend/go-service/app/provider/wire_gen.go +++ b/backend/go-service/app/provider/wire_gen.go @@ -31,7 +31,8 @@ func InitializeApp(cfg *config.Config) (*App, error) { userDAO := dao.NewUserDAO(gormDB) roleDAO := dao.NewRoleDAO(gormDB) jwtConfig := provideJWTConfig(cfg) - authService := service.NewAuthService(userDAO, roleDAO, jwtConfig) + tokenStore := service.NewTokenStore(client, jwtConfig) + authService := service.NewAuthService(userDAO, roleDAO, jwtConfig, tokenStore) authController := controller.NewAuthController(authService) adminAuthController := controller.NewAdminAuthController(authService) app := NewApp(cfg, gormDB, client, authService, authController, adminAuthController) diff --git a/backend/go-service/pkg/middleware/auth.go b/backend/go-service/pkg/middleware/auth.go index 1caa208..7fd5dd4 100644 --- a/backend/go-service/pkg/middleware/auth.go +++ b/backend/go-service/pkg/middleware/auth.go @@ -1,6 +1,7 @@ package middleware import ( + "context" "strings" "github.com/echochat/backend/config" @@ -16,9 +17,15 @@ const ( ContextKeyRoles = "roles" // Context 中存储当前用户角色列表的 Key ) -// JWTAuth JWT 认证中间件 -// 从 Authorization Header 中提取 Bearer Token,验证后将用户信息注入 Gin Context -func JWTAuth(jwtCfg *config.JWTConfig) gin.HandlerFunc { +// TokenValidator Token 有效性校验接口 +// 由 AuthService 实现,中间件通过此接口检查 Token 是否在 Redis 中有效 +type TokenValidator interface { + ValidateAccessToken(ctx context.Context, userID int64, token string) bool +} + +// JWTAuth JWT 认证中间件(有状态 JWT) +// 验证流程:解析 Token → 检查类型 → 校验 Redis 有效性 → 注入用户信息 +func JWTAuth(jwtCfg *config.JWTConfig, validator TokenValidator) gin.HandlerFunc { return func(c *gin.Context) { funcName := "middleware.JWTAuth" ctx := c.Request.Context() @@ -30,7 +37,6 @@ func JWTAuth(jwtCfg *config.JWTConfig) gin.HandlerFunc { return } - // 提取 Bearer Token parts := strings.SplitN(authHeader, " ", 2) if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" { utils.ResponseUnauthorized(c, "认证格式错误,应为 Bearer {token}") @@ -50,14 +56,23 @@ func JWTAuth(jwtCfg *config.JWTConfig) gin.HandlerFunc { return } - // 验证 Token 类型(只允许 access token 访问接口) if claims.Subject != "access" { utils.ResponseUnauthorized(c, "无效的 Token 类型") c.Abort() return } - // 将用户信息注入 Gin Context,供后续 Controller/Service 使用 + // 校验 Token 是否在 Redis 中有效(有状态 JWT 核心逻辑) + if !validator.ValidateAccessToken(ctx, claims.UserID, tokenStr) { + logs.Warn(ctx, funcName, "Token 已失效(已登出或被覆盖)", + zap.Int64("user_id", claims.UserID), + zap.String("ip", c.ClientIP()), + ) + utils.ResponseUnauthorized(c, "认证已失效,请重新登录") + c.Abort() + return + } + c.Set(ContextKeyUserID, claims.UserID) c.Set(ContextKeyUsername, claims.Username) c.Set(ContextKeyRoles, claims.Roles) @@ -108,7 +123,6 @@ func RequireRole(roles ...string) gin.HandlerFunc { } // GetCurrentUserID 从 Gin Context 获取当前登录用户 ID -// 供 Controller 层调用的便捷方法 func GetCurrentUserID(c *gin.Context) (int64, bool) { val, exists := c.Get(ContextKeyUserID) if !exists { diff --git a/backend/go-service/router/router.go b/backend/go-service/router/router.go index b95850c..5ce628a 100644 --- a/backend/go-service/router/router.go +++ b/backend/go-service/router/router.go @@ -25,8 +25,8 @@ func Setup(engine *gin.Engine, app *provider.App) { }) }) - // JWT 认证中间件实例 - jwtAuth := middleware.JWTAuth(&app.Config.JWT) + // JWT 认证中间件实例(有状态 JWT,通过 AuthService 校验 Redis) + jwtAuth := middleware.JWTAuth(&app.Config.JWT, app.AuthService) // --- 各模块路由注册 --- auth.RegisterRoutes(engine, app.AuthController, app.AdminAuthController, jwtAuth) diff --git a/docs/api/README.md b/docs/api/README.md index 314bc9d..643bda9 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -80,17 +80,6 @@ yyyy-MM-dd HH:mm:ss } ``` -**创建成功响应(如注册):** -```json -{ - "code": 0, - "message": "created", - "data": { ... }, - "trace_id": "6478824e-2926-4d35-aa5f-047c8cfbb36b", - "time": "2026-02-27 18:00:00" -} -``` - **错误响应:** ```json { diff --git a/docs/api/frontend/auth.md b/docs/api/frontend/auth.md index 1343ede..08e660b 100644 --- a/docs/api/frontend/auth.md +++ b/docs/api/frontend/auth.md @@ -43,11 +43,11 @@ } ``` -**成功响应(201 Created):** +**成功响应(200 OK):** ```json { "code": 0, - "message": "created", + "message": "success", "data": { "token": "eyJhbGciOiJIUzI1NiIs...", "refresh_token": "eyJhbGciOiJIUzI1NiIs...", @@ -139,7 +139,7 @@ **权限:** 需认证 -**说明:** 当前采用无状态 JWT 方案,服务端不存储 Token 状态。退出登录由客户端主动删除本地存储的 Token 即可。后续可扩展为将 Token 加入 Redis 黑名单实现服务端主动失效。 +**说明:** 采用有状态 JWT 方案,Token 存储在 Redis 中(`echo:auth:token:{user_id}` 和 `echo:auth:refresh:{user_id}`)。登出时服务端会从 Redis 中删除该用户的 Access Token 和 Refresh Token,使其立即失效。客户端也应同步清除本地存储的 Token。 **成功响应:** ```json