fix: 前后台 Token Redis 存储隔离,修复同账号互相覆盖问题
核心变更:
- Redis key 加入 clientType 前缀:echo:auth:token:{frontend|admin}:{user_id}
- JWT Claims 新增 client_type 字段区分前台/管理端
- 新增 constants/client_type.go 定义 ClientTypeFrontend / ClientTypeAdmin
- 全链路传递 clientType:Controller → Service → TokenStore → Redis
- 中间件从 JWT Claims 提取 clientType 做 Redis 校验
- 登出只删除当前端 Token,不影响另一端
前端变更:
- 管理端 login API 改为调用 POST /api/v1/admin/auth/login(之前错误调用了前台接口)
文档更新:
- 系统设计文档、实施计划、API文档、开发规范、项目进度全部同步
Made-with: Cursor
This commit is contained in:
@@ -3,6 +3,7 @@ package controller
|
||||
|
||||
import (
|
||||
"github.com/echochat/backend/app/auth/service"
|
||||
"github.com/echochat/backend/app/constants"
|
||||
"github.com/echochat/backend/app/dto"
|
||||
"github.com/echochat/backend/pkg/logs"
|
||||
"github.com/echochat/backend/pkg/middleware"
|
||||
@@ -67,7 +68,7 @@ func (ctrl *AuthController) Login(c *gin.Context) {
|
||||
zap.String("ip", c.ClientIP()),
|
||||
)
|
||||
|
||||
resp, err := ctrl.authService.Login(ctx, &req, c.ClientIP())
|
||||
resp, err := ctrl.authService.Login(ctx, &req, c.ClientIP(), constants.ClientTypeFrontend)
|
||||
if err != nil {
|
||||
handleAuthError(c, err)
|
||||
return
|
||||
@@ -88,9 +89,13 @@ func (ctrl *AuthController) Logout(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
logs.Info(ctx, funcName, "用户登出", zap.Int64("user_id", userID))
|
||||
clientType := middleware.GetCurrentClientType(c)
|
||||
logs.Info(ctx, funcName, "用户登出",
|
||||
zap.Int64("user_id", userID),
|
||||
zap.String("client_type", clientType),
|
||||
)
|
||||
|
||||
if err := ctrl.authService.Logout(ctx, userID); err != nil {
|
||||
if err := ctrl.authService.Logout(ctx, userID, clientType); err != nil {
|
||||
utils.ResponseError(c, "登出失败")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ func NewAuthService(userDAO *dao.UserDAO, roleDAO *dao.RoleDAO, jwtCfg *config.J
|
||||
}
|
||||
}
|
||||
|
||||
// Register 用户注册
|
||||
// Register 用户注册(前台用户端专用,clientType 固定为 frontend)
|
||||
// 流程:检查用户名/邮箱是否重复 → 加密密码 → 创建用户 → 分配默认角色 → 生成 Token
|
||||
func (s *AuthService) Register(ctx context.Context, req *dto.RegisterRequest) (*dto.LoginResponse, error) {
|
||||
funcName := "service.auth_service.Register"
|
||||
@@ -110,9 +110,9 @@ func (s *AuthService) Register(ctx context.Context, req *dto.RegisterRequest) (*
|
||||
}
|
||||
}
|
||||
|
||||
// 获取角色列表并生成 Token
|
||||
// 获取角色列表并生成 Token(注册只有前台,clientType 为 frontend)
|
||||
roles, _ := s.roleDAO.GetUserRoleCodes(ctx, user.ID)
|
||||
resp, err := s.buildLoginResponse(ctx, user, roles)
|
||||
resp, err := s.buildLoginResponse(ctx, user, roles, constants.ClientTypeFrontend)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -123,7 +123,8 @@ func (s *AuthService) Register(ctx context.Context, req *dto.RegisterRequest) (*
|
||||
|
||||
// Login 用户登录
|
||||
// 流程:查找用户 → 校验密码 → 检查账号状态 → 更新登录信息 → 生成 Token
|
||||
func (s *AuthService) Login(ctx context.Context, req *dto.LoginRequest, clientIP string) (*dto.LoginResponse, error) {
|
||||
// clientType 区分前台(frontend)和管理端(admin),Token 在 Redis 中隔离存储
|
||||
func (s *AuthService) Login(ctx context.Context, req *dto.LoginRequest, clientIP, clientType string) (*dto.LoginResponse, error) {
|
||||
funcName := "service.auth_service.Login"
|
||||
logs.Info(ctx, funcName, "开始处理登录",
|
||||
zap.String("account", req.Account),
|
||||
@@ -164,9 +165,9 @@ func (s *AuthService) Login(ctx context.Context, req *dto.LoginRequest, clientIP
|
||||
// 更新最后登录信息
|
||||
_ = s.userDAO.UpdateLastLogin(ctx, user.ID, clientIP)
|
||||
|
||||
// 获取角色列表并生成 Token(同时存入 Redis)
|
||||
// 获取角色列表并生成 Token(同时存入 Redis,按 clientType 隔离)
|
||||
roles, _ := s.roleDAO.GetUserRoleCodes(ctx, user.ID)
|
||||
resp, err := s.buildLoginResponse(ctx, user, roles)
|
||||
resp, err := s.buildLoginResponse(ctx, user, roles, clientType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -174,19 +175,21 @@ func (s *AuthService) Login(ctx context.Context, req *dto.LoginRequest, clientIP
|
||||
logs.Info(ctx, funcName, "登录成功",
|
||||
zap.Int64("user_id", user.ID),
|
||||
zap.String("username", user.Username),
|
||||
zap.String("client_type", clientType),
|
||||
)
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// AdminLogin 管理后台登录
|
||||
// 与普通登录相同,但额外检查用户是否拥有 admin 或 super_admin 角色
|
||||
// clientType 固定为 constants.ClientTypeAdmin
|
||||
func (s *AuthService) AdminLogin(ctx context.Context, req *dto.LoginRequest, clientIP string) (*dto.LoginResponse, error) {
|
||||
funcName := "service.auth_service.AdminLogin"
|
||||
logs.Info(ctx, funcName, "开始处理管理员登录",
|
||||
zap.String("account", req.Account),
|
||||
)
|
||||
|
||||
resp, err := s.Login(ctx, req, clientIP)
|
||||
resp, err := s.Login(ctx, req, clientIP, constants.ClientTypeAdmin)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -229,10 +232,17 @@ func (s *AuthService) RefreshToken(ctx context.Context, refreshToken string) (*d
|
||||
return nil, ErrRefreshTokenType
|
||||
}
|
||||
|
||||
// 从 claims 中获取 clientType(前台 / 管理端)
|
||||
clientType := claims.ClientType
|
||||
if clientType == "" {
|
||||
clientType = constants.ClientTypeFrontend
|
||||
}
|
||||
|
||||
// 验证 Refresh Token 是否与 Redis 中存储的一致
|
||||
if !s.tokenStore.ValidateRefreshToken(ctx, claims.UserID, refreshToken) {
|
||||
if !s.tokenStore.ValidateRefreshToken(ctx, claims.UserID, clientType, refreshToken) {
|
||||
logs.Warn(ctx, funcName, "Refresh Token 已失效(不在 Redis 中)",
|
||||
zap.Int64("user_id", claims.UserID),
|
||||
zap.String("client_type", clientType),
|
||||
)
|
||||
return nil, ErrRefreshTokenType
|
||||
}
|
||||
@@ -250,29 +260,35 @@ func (s *AuthService) RefreshToken(ctx context.Context, refreshToken string) (*d
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 获取角色并生成新 Token(同时存入 Redis 覆盖旧 Token)
|
||||
// 获取角色并生成新 Token(同时存入 Redis 覆盖旧 Token,保持 clientType 不变)
|
||||
roles, _ := s.roleDAO.GetUserRoleCodes(ctx, user.ID)
|
||||
resp, err := s.buildLoginResponse(ctx, user, roles)
|
||||
resp, err := s.buildLoginResponse(ctx, user, roles, clientType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
logs.Info(ctx, funcName, "Token 刷新成功", zap.Int64("user_id", user.ID))
|
||||
logs.Info(ctx, funcName, "Token 刷新成功",
|
||||
zap.Int64("user_id", user.ID),
|
||||
zap.String("client_type", clientType),
|
||||
)
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// Logout 用户登出
|
||||
// 从 Redis 中删除该用户的 Access Token 和 Refresh Token
|
||||
func (s *AuthService) Logout(ctx context.Context, userID int64) error {
|
||||
// 从 Redis 中删除指定 clientType 下该用户的 Token(前台登出不影响管理端)
|
||||
func (s *AuthService) Logout(ctx context.Context, userID int64, clientType string) error {
|
||||
funcName := "service.auth_service.Logout"
|
||||
logs.Info(ctx, funcName, "用户登出", zap.Int64("user_id", userID))
|
||||
return s.tokenStore.RemoveTokens(ctx, userID)
|
||||
logs.Info(ctx, funcName, "用户登出",
|
||||
zap.Int64("user_id", userID),
|
||||
zap.String("client_type", clientType),
|
||||
)
|
||||
return s.tokenStore.RemoveTokens(ctx, userID, clientType)
|
||||
}
|
||||
|
||||
// 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)
|
||||
// 供 JWT 中间件调用,实现有状态 JWT 验证,按 clientType 隔离校验
|
||||
func (s *AuthService) ValidateAccessToken(ctx context.Context, userID int64, clientType, token string) bool {
|
||||
return s.tokenStore.ValidateAccessToken(ctx, userID, clientType, token)
|
||||
}
|
||||
|
||||
// GetProfile 获取用户个人信息
|
||||
@@ -374,23 +390,24 @@ func (s *AuthService) checkUserStatus(status int) error {
|
||||
}
|
||||
|
||||
// buildLoginResponse 构建登录响应(生成 Token + 存入 Redis + 用户信息)
|
||||
func (s *AuthService) buildLoginResponse(ctx context.Context, user *model.User, roles []string) (*dto.LoginResponse, error) {
|
||||
// clientType 决定 Token 存入 Redis 的 key 前缀和 JWT Claims 中的 client_type 字段
|
||||
func (s *AuthService) buildLoginResponse(ctx context.Context, user *model.User, roles []string, clientType string) (*dto.LoginResponse, error) {
|
||||
if roles == nil {
|
||||
roles = []string{}
|
||||
}
|
||||
|
||||
token, err := utils.GenerateToken(s.jwtCfg, user.ID, user.Username, roles)
|
||||
token, err := utils.GenerateToken(s.jwtCfg, user.ID, user.Username, roles, clientType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
refreshToken, err := utils.GenerateRefreshToken(s.jwtCfg, user.ID)
|
||||
refreshToken, err := utils.GenerateRefreshToken(s.jwtCfg, user.ID, clientType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 将 Token 存入 Redis(有状态 JWT,支持主动失效和单设备登录)
|
||||
if err = s.tokenStore.SaveTokens(ctx, user.ID, token, refreshToken); err != nil {
|
||||
// 将 Token 存入 Redis(按 clientType 隔离,前台和管理端互不影响)
|
||||
if err = s.tokenStore.SaveTokens(ctx, user.ID, clientType, token, refreshToken); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
@@ -11,10 +11,12 @@ import (
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Redis Key 前缀,遵循设计方案中 echo:auth:* 的命名规范
|
||||
// Redis Key 前缀,按 client_type 隔离前台和管理端的 Token
|
||||
// 格式:echo:auth:token:{client_type}:{user_id}
|
||||
// 例如:echo:auth:token:frontend:1 / echo:auth:token:admin:1
|
||||
const (
|
||||
keyPrefixAccessToken = "echo:auth:token:" // echo:auth:token:{user_id}
|
||||
keyPrefixRefreshToken = "echo:auth:refresh:" // echo:auth:refresh:{user_id}
|
||||
keyPrefixAccessToken = "echo:auth:token:" // echo:auth:token:{client_type}:{user_id}
|
||||
keyPrefixRefreshToken = "echo:auth:refresh:" // echo:auth:refresh:{client_type}:{user_id}
|
||||
)
|
||||
|
||||
// TokenStore 管理 Token 在 Redis 中的存取
|
||||
@@ -33,12 +35,13 @@ func NewTokenStore(redisClient *redis.Client, jwtCfg *config.JWTConfig) *TokenSt
|
||||
}
|
||||
|
||||
// SaveTokens 将 Access Token 和 Refresh Token 存入 Redis
|
||||
// 每次登录/注册时调用,覆盖旧 Token(实现单设备登录)
|
||||
func (s *TokenStore) SaveTokens(ctx context.Context, userID int64, accessToken, refreshToken string) error {
|
||||
// 每次登录/注册时调用,覆盖同一 clientType 下的旧 Token
|
||||
// clientType 用于隔离前台(frontend)和管理端(admin)的 Token
|
||||
func (s *TokenStore) SaveTokens(ctx context.Context, userID int64, clientType, accessToken, refreshToken string) error {
|
||||
funcName := "service.token_store.SaveTokens"
|
||||
|
||||
accessKey := fmt.Sprintf("%s%d", keyPrefixAccessToken, userID)
|
||||
refreshKey := fmt.Sprintf("%s%d", keyPrefixRefreshToken, userID)
|
||||
accessKey := fmt.Sprintf("%s%s:%d", keyPrefixAccessToken, clientType, userID)
|
||||
refreshKey := fmt.Sprintf("%s%s:%d", keyPrefixRefreshToken, clientType, userID)
|
||||
|
||||
accessTTL := time.Duration(s.jwtCfg.AccessExpireMin) * time.Minute
|
||||
refreshTTL := time.Duration(s.jwtCfg.RefreshExpireDay) * 24 * time.Hour
|
||||
@@ -50,6 +53,7 @@ func (s *TokenStore) SaveTokens(ctx context.Context, userID int64, accessToken,
|
||||
if _, err := pipe.Exec(ctx); err != nil {
|
||||
logs.Error(ctx, funcName, "保存 Token 到 Redis 失败",
|
||||
zap.Int64("user_id", userID),
|
||||
zap.String("client_type", clientType),
|
||||
zap.Error(err),
|
||||
)
|
||||
return err
|
||||
@@ -57,6 +61,7 @@ func (s *TokenStore) SaveTokens(ctx context.Context, userID int64, accessToken,
|
||||
|
||||
logs.Debug(ctx, funcName, "Token 已存入 Redis",
|
||||
zap.Int64("user_id", userID),
|
||||
zap.String("client_type", clientType),
|
||||
zap.Duration("access_ttl", accessTTL),
|
||||
zap.Duration("refresh_ttl", refreshTTL),
|
||||
)
|
||||
@@ -64,21 +69,23 @@ func (s *TokenStore) SaveTokens(ctx context.Context, userID int64, accessToken,
|
||||
}
|
||||
|
||||
// ValidateAccessToken 校验 Access Token 是否与 Redis 中存储的一致
|
||||
// 返回 true 表示有效,false 表示已被登出/覆盖
|
||||
func (s *TokenStore) ValidateAccessToken(ctx context.Context, userID int64, token string) bool {
|
||||
// clientType 用于定位正确的 Redis key(前台 vs 管理端)
|
||||
func (s *TokenStore) ValidateAccessToken(ctx context.Context, userID int64, clientType, token string) bool {
|
||||
funcName := "service.token_store.ValidateAccessToken"
|
||||
|
||||
key := fmt.Sprintf("%s%d", keyPrefixAccessToken, userID)
|
||||
key := fmt.Sprintf("%s%s:%d", keyPrefixAccessToken, clientType, userID)
|
||||
stored, err := s.redis.Get(ctx, key).Result()
|
||||
if err == redis.Nil {
|
||||
logs.Debug(ctx, funcName, "Token 不存在(已登出或过期)",
|
||||
zap.Int64("user_id", userID),
|
||||
zap.String("client_type", clientType),
|
||||
)
|
||||
return false
|
||||
}
|
||||
if err != nil {
|
||||
logs.Error(ctx, funcName, "Redis 查询 Token 失败",
|
||||
zap.Int64("user_id", userID),
|
||||
zap.String("client_type", clientType),
|
||||
zap.Error(err),
|
||||
)
|
||||
return false
|
||||
@@ -88,8 +95,8 @@ func (s *TokenStore) ValidateAccessToken(ctx context.Context, userID int64, toke
|
||||
}
|
||||
|
||||
// ValidateRefreshToken 校验 Refresh Token 是否与 Redis 中存储的一致
|
||||
func (s *TokenStore) ValidateRefreshToken(ctx context.Context, userID int64, token string) bool {
|
||||
key := fmt.Sprintf("%s%d", keyPrefixRefreshToken, userID)
|
||||
func (s *TokenStore) ValidateRefreshToken(ctx context.Context, userID int64, clientType, token string) bool {
|
||||
key := fmt.Sprintf("%s%s:%d", keyPrefixRefreshToken, clientType, userID)
|
||||
stored, err := s.redis.Get(ctx, key).Result()
|
||||
if err != nil {
|
||||
return false
|
||||
@@ -97,16 +104,17 @@ func (s *TokenStore) ValidateRefreshToken(ctx context.Context, userID int64, tok
|
||||
return stored == token
|
||||
}
|
||||
|
||||
// RemoveTokens 从 Redis 删除用户的所有 Token(登出时调用)
|
||||
func (s *TokenStore) RemoveTokens(ctx context.Context, userID int64) error {
|
||||
// RemoveTokens 从 Redis 删除指定 clientType 下用户的 Token(登出时调用)
|
||||
func (s *TokenStore) RemoveTokens(ctx context.Context, userID int64, clientType string) error {
|
||||
funcName := "service.token_store.RemoveTokens"
|
||||
|
||||
accessKey := fmt.Sprintf("%s%d", keyPrefixAccessToken, userID)
|
||||
refreshKey := fmt.Sprintf("%s%d", keyPrefixRefreshToken, userID)
|
||||
accessKey := fmt.Sprintf("%s%s:%d", keyPrefixAccessToken, clientType, userID)
|
||||
refreshKey := fmt.Sprintf("%s%s:%d", keyPrefixRefreshToken, clientType, userID)
|
||||
|
||||
if err := s.redis.Del(ctx, accessKey, refreshKey).Err(); err != nil {
|
||||
logs.Error(ctx, funcName, "删除 Token 失败",
|
||||
zap.Int64("user_id", userID),
|
||||
zap.String("client_type", clientType),
|
||||
zap.Error(err),
|
||||
)
|
||||
return err
|
||||
@@ -114,6 +122,7 @@ func (s *TokenStore) RemoveTokens(ctx context.Context, userID int64) error {
|
||||
|
||||
logs.Info(ctx, funcName, "Token 已从 Redis 删除",
|
||||
zap.Int64("user_id", userID),
|
||||
zap.String("client_type", clientType),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
7
backend/go-service/app/constants/client_type.go
Normal file
7
backend/go-service/app/constants/client_type.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package constants
|
||||
|
||||
// 客户端类型,用于区分前台用户端和后台管理端的 Token 存储
|
||||
const (
|
||||
ClientTypeFrontend = "frontend" // 前台用户端
|
||||
ClientTypeAdmin = "admin" // 后台管理端
|
||||
)
|
||||
Reference in New Issue
Block a user