fix(auth): 有状态 JWT + 统一响应格式,严格遵循设计方案

1. 有状态 JWT(Token 存 Redis):
   - 新增 token_store.go: Token 的 Redis 存取管理
     - echo:auth:token:{user_id} → Access Token (TTL = access_expire_min)
     - echo:auth:refresh:{user_id} → Refresh Token (TTL = refresh_expire_day)
   - 登录/注册时自动将 Token 存入 Redis(覆盖旧 Token,实现单设备登录)
   - JWT 中间件增加 Redis 有效性校验(TokenValidator 接口解耦)
   - 登出时从 Redis 删除 Token,使其立即失效
   - 刷新 Token 时校验 Redis 中的 Refresh Token

2. 统一成功响应为 "success" + 200:
   - 注册接口改用 ResponseOK(原 ResponseCreated/201)
   - 所有成功响应统一为 {"code": 0, "message": "success"}

3. API 文档同步更新:
   - frontend/auth.md: 登出说明改为 Redis 方案、注册响应统一
   - README.md: 移除 "created" 示例

已验证:登出后 Token 立即失效 ✓ 重新登录后新 Token 有效 ✓

Made-with: Cursor
This commit is contained in:
bujinyuan
2026-02-28 16:57:46 +08:00
parent 5a65716d10
commit b607434c57
9 changed files with 203 additions and 42 deletions

View File

@@ -46,7 +46,7 @@ func (ctrl *AuthController) Register(c *gin.Context) {
return return
} }
utils.ResponseCreated(c, resp) utils.ResponseOK(c, resp)
} }
// Login 用户登录 // Login 用户登录
@@ -82,11 +82,19 @@ func (ctrl *AuthController) Logout(c *gin.Context) {
funcName := "controller.auth_controller.Logout" funcName := "controller.auth_controller.Logout"
ctx := c.Request.Context() 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)) logs.Info(ctx, funcName, "用户登出", zap.Int64("user_id", userID))
// 当前为无状态 JWT登出由客户端删除 Token 实现 if err := ctrl.authService.Logout(ctx, userID); err != nil {
// 后续可扩展:将 Token 加入 Redis 黑名单 utils.ResponseError(c, "登出失败")
return
}
utils.ResponseOK(c, nil) utils.ResponseOK(c, nil)
} }

View File

@@ -13,6 +13,7 @@ import (
var AuthSet = wire.NewSet( var AuthSet = wire.NewSet(
dao.NewUserDAO, dao.NewUserDAO,
dao.NewRoleDAO, dao.NewRoleDAO,
service.NewTokenStore,
service.NewAuthService, service.NewAuthService,
controller.NewAuthController, controller.NewAuthController,
controller.NewAdminAuthController, controller.NewAdminAuthController,

View File

@@ -31,14 +31,16 @@ type AuthService struct {
userDAO *dao.UserDAO userDAO *dao.UserDAO
roleDAO *dao.RoleDAO roleDAO *dao.RoleDAO
jwtCfg *config.JWTConfig jwtCfg *config.JWTConfig
tokenStore *TokenStore
} }
// NewAuthService 创建认证服务实例 // 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{ return &AuthService{
userDAO: userDAO, userDAO: userDAO,
roleDAO: roleDAO, roleDAO: roleDAO,
jwtCfg: jwtCfg, jwtCfg: jwtCfg,
tokenStore: tokenStore,
} }
} }
@@ -110,7 +112,7 @@ func (s *AuthService) Register(ctx context.Context, req *dto.RegisterRequest) (*
// 获取角色列表并生成 Token // 获取角色列表并生成 Token
roles, _ := s.roleDAO.GetUserRoleCodes(ctx, user.ID) roles, _ := s.roleDAO.GetUserRoleCodes(ctx, user.ID)
resp, err := s.buildLoginResponse(user, roles) resp, err := s.buildLoginResponse(ctx, user, roles)
if err != nil { if err != nil {
return nil, err 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) _ = s.userDAO.UpdateLastLogin(ctx, user.ID, clientIP)
// 获取角色列表并生成 Token // 获取角色列表并生成 Token(同时存入 Redis
roles, _ := s.roleDAO.GetUserRoleCodes(ctx, user.ID) roles, _ := s.roleDAO.GetUserRoleCodes(ctx, user.ID)
resp, err := s.buildLoginResponse(user, roles) resp, err := s.buildLoginResponse(ctx, user, roles)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -227,6 +229,14 @@ func (s *AuthService) RefreshToken(ctx context.Context, refreshToken string) (*d
return nil, ErrRefreshTokenType 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) user, err := s.userDAO.FindByID(ctx, claims.UserID)
if err != nil { if err != nil {
@@ -240,9 +250,9 @@ func (s *AuthService) RefreshToken(ctx context.Context, refreshToken string) (*d
return nil, err return nil, err
} }
// 获取角色并生成新 Token // 获取角色并生成新 Token(同时存入 Redis 覆盖旧 Token
roles, _ := s.roleDAO.GetUserRoleCodes(ctx, user.ID) roles, _ := s.roleDAO.GetUserRoleCodes(ctx, user.ID)
resp, err := s.buildLoginResponse(user, roles) resp, err := s.buildLoginResponse(ctx, user, roles)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -251,6 +261,20 @@ func (s *AuthService) RefreshToken(ctx context.Context, refreshToken string) (*d
return resp, nil 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 获取用户个人信息 // GetProfile 获取用户个人信息
func (s *AuthService) GetProfile(ctx context.Context, userID int64) (*dto.UserInfo, error) { func (s *AuthService) GetProfile(ctx context.Context, userID int64) (*dto.UserInfo, error) {
funcName := "service.auth_service.GetProfile" funcName := "service.auth_service.GetProfile"
@@ -349,8 +373,8 @@ func (s *AuthService) checkUserStatus(status int) error {
} }
} }
// buildLoginResponse 构建登录响应(生成 Token + 用户信息) // buildLoginResponse 构建登录响应(生成 Token + 存入 Redis + 用户信息)
func (s *AuthService) buildLoginResponse(user *model.User, roles []string) (*dto.LoginResponse, error) { func (s *AuthService) buildLoginResponse(ctx context.Context, user *model.User, roles []string) (*dto.LoginResponse, error) {
if roles == nil { if roles == nil {
roles = []string{} roles = []string{}
} }
@@ -365,6 +389,11 @@ func (s *AuthService) buildLoginResponse(user *model.User, roles []string) (*dto
return nil, err return nil, err
} }
// 将 Token 存入 Redis有状态 JWT支持主动失效和单设备登录
if err = s.tokenStore.SaveTokens(ctx, user.ID, token, refreshToken); err != nil {
return nil, err
}
return &dto.LoginResponse{ return &dto.LoginResponse{
Token: token, Token: token,
RefreshToken: refreshToken, RefreshToken: refreshToken,

View File

@@ -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
}

View File

@@ -31,7 +31,8 @@ func InitializeApp(cfg *config.Config) (*App, error) {
userDAO := dao.NewUserDAO(gormDB) userDAO := dao.NewUserDAO(gormDB)
roleDAO := dao.NewRoleDAO(gormDB) roleDAO := dao.NewRoleDAO(gormDB)
jwtConfig := provideJWTConfig(cfg) 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) authController := controller.NewAuthController(authService)
adminAuthController := controller.NewAdminAuthController(authService) adminAuthController := controller.NewAdminAuthController(authService)
app := NewApp(cfg, gormDB, client, authService, authController, adminAuthController) app := NewApp(cfg, gormDB, client, authService, authController, adminAuthController)

View File

@@ -1,6 +1,7 @@
package middleware package middleware
import ( import (
"context"
"strings" "strings"
"github.com/echochat/backend/config" "github.com/echochat/backend/config"
@@ -16,9 +17,15 @@ const (
ContextKeyRoles = "roles" // Context 中存储当前用户角色列表的 Key ContextKeyRoles = "roles" // Context 中存储当前用户角色列表的 Key
) )
// JWTAuth JWT 认证中间件 // TokenValidator Token 有效性校验接口
// Authorization Header 中提取 Bearer Token验证后将用户信息注入 Gin Context // AuthService 实现,中间件通过此接口检查 Token 是否在 Redis 中有效
func JWTAuth(jwtCfg *config.JWTConfig) gin.HandlerFunc { 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) { return func(c *gin.Context) {
funcName := "middleware.JWTAuth" funcName := "middleware.JWTAuth"
ctx := c.Request.Context() ctx := c.Request.Context()
@@ -30,7 +37,6 @@ func JWTAuth(jwtCfg *config.JWTConfig) gin.HandlerFunc {
return return
} }
// 提取 Bearer Token
parts := strings.SplitN(authHeader, " ", 2) parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" { if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" {
utils.ResponseUnauthorized(c, "认证格式错误,应为 Bearer {token}") utils.ResponseUnauthorized(c, "认证格式错误,应为 Bearer {token}")
@@ -50,14 +56,23 @@ func JWTAuth(jwtCfg *config.JWTConfig) gin.HandlerFunc {
return return
} }
// 验证 Token 类型(只允许 access token 访问接口)
if claims.Subject != "access" { if claims.Subject != "access" {
utils.ResponseUnauthorized(c, "无效的 Token 类型") utils.ResponseUnauthorized(c, "无效的 Token 类型")
c.Abort() c.Abort()
return 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(ContextKeyUserID, claims.UserID)
c.Set(ContextKeyUsername, claims.Username) c.Set(ContextKeyUsername, claims.Username)
c.Set(ContextKeyRoles, claims.Roles) c.Set(ContextKeyRoles, claims.Roles)
@@ -108,7 +123,6 @@ func RequireRole(roles ...string) gin.HandlerFunc {
} }
// GetCurrentUserID 从 Gin Context 获取当前登录用户 ID // GetCurrentUserID 从 Gin Context 获取当前登录用户 ID
// 供 Controller 层调用的便捷方法
func GetCurrentUserID(c *gin.Context) (int64, bool) { func GetCurrentUserID(c *gin.Context) (int64, bool) {
val, exists := c.Get(ContextKeyUserID) val, exists := c.Get(ContextKeyUserID)
if !exists { if !exists {

View File

@@ -25,8 +25,8 @@ func Setup(engine *gin.Engine, app *provider.App) {
}) })
}) })
// JWT 认证中间件实例 // JWT 认证中间件实例(有状态 JWT通过 AuthService 校验 Redis
jwtAuth := middleware.JWTAuth(&app.Config.JWT) jwtAuth := middleware.JWTAuth(&app.Config.JWT, app.AuthService)
// --- 各模块路由注册 --- // --- 各模块路由注册 ---
auth.RegisterRoutes(engine, app.AuthController, app.AdminAuthController, jwtAuth) auth.RegisterRoutes(engine, app.AuthController, app.AdminAuthController, jwtAuth)

View File

@@ -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 ```json
{ {

View File

@@ -43,11 +43,11 @@
} }
``` ```
**成功响应201 Created** **成功响应200 OK**
```json ```json
{ {
"code": 0, "code": 0,
"message": "created", "message": "success",
"data": { "data": {
"token": "eyJhbGciOiJIUzI1NiIs...", "token": "eyJhbGciOiJIUzI1NiIs...",
"refresh_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 ```json