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:
@@ -33,7 +33,7 @@ EchoChat 是一个实时音视频通讯平台,包含三个子项目:
|
|||||||
4. **模块系统**:前端统一使用 ESM(`export`/`import`),禁止 CommonJS
|
4. **模块系统**:前端统一使用 ESM(`export`/`import`),禁止 CommonJS
|
||||||
5. **Go 常量命名**:camelCase(`UserStatusActive`),非大写下划线
|
5. **Go 常量命名**:camelCase(`UserStatusActive`),非大写下划线
|
||||||
6. **API 响应**:统一 `{ "code": 0, "message": "success", "data": ... }`
|
6. **API 响应**:统一 `{ "code": 0, "message": "success", "data": ... }`
|
||||||
7. **JWT 策略**:有状态 JWT,Token 存 Redis,登出时从 Redis 删除
|
7. **JWT 策略**:有状态 JWT,Token 存 Redis(按 clientType 隔离:`echo:auth:token:{frontend|admin}:{user_id}`),前后台互不影响
|
||||||
8. **代码注释**:所有公开函数、组件、Store 必须有详细注释
|
8. **代码注释**:所有公开函数、组件、Store 必须有详细注释
|
||||||
9. **文档同步**:代码变更后必须同步更新 docs/ 下相关文档
|
9. **文档同步**:代码变更后必须同步更新 docs/ 下相关文档
|
||||||
10. **验证方式**:使用 Playwright MCP 进行页面自动化验证
|
10. **验证方式**:使用 Playwright MCP 进行页面自动化验证
|
||||||
|
|||||||
@@ -1,21 +1,24 @@
|
|||||||
/**
|
/**
|
||||||
* 管理后台认证 API
|
* 管理后台认证 API
|
||||||
*
|
*
|
||||||
* 对应后端路由:/api/v1/auth/*
|
* 登录接口:/api/v1/admin/auth/login(管理端专用,后端检查管理员角色)
|
||||||
* 管理员登录使用与普通用户相同的登录接口,
|
* 其他接口:/api/v1/auth/*(与前台共用,通过 JWT 中的 client_type 区分)
|
||||||
* 权限区分由 JWT 中的 role 字段和 RequireRole 中间件控制
|
*
|
||||||
|
* Token 在 Redis 中按 client_type 隔离存储:
|
||||||
|
* - 管理端:echo:auth:token:admin:{user_id}
|
||||||
|
* - 前台:echo:auth:token:frontend:{user_id}
|
||||||
*/
|
*/
|
||||||
import request from '@/utils/request'
|
import request from '@/utils/request'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 管理员登录
|
* 管理员登录
|
||||||
* POST /api/v1/auth/login
|
* POST /api/v1/admin/auth/login
|
||||||
*
|
*
|
||||||
* @param {Object} data - { account, password }
|
* @param {Object} data - { account, password }
|
||||||
* @returns {Promise<Object>} { token, refresh_token, expires_in, user }
|
* @returns {Promise<Object>} { token, refresh_token, expires_in, user }
|
||||||
*/
|
*/
|
||||||
export const login = (data) => {
|
export const login = (data) => {
|
||||||
return request.post('/api/v1/auth/login', data)
|
return request.post('/api/v1/admin/auth/login', data)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package controller
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/echochat/backend/app/auth/service"
|
"github.com/echochat/backend/app/auth/service"
|
||||||
|
"github.com/echochat/backend/app/constants"
|
||||||
"github.com/echochat/backend/app/dto"
|
"github.com/echochat/backend/app/dto"
|
||||||
"github.com/echochat/backend/pkg/logs"
|
"github.com/echochat/backend/pkg/logs"
|
||||||
"github.com/echochat/backend/pkg/middleware"
|
"github.com/echochat/backend/pkg/middleware"
|
||||||
@@ -67,7 +68,7 @@ func (ctrl *AuthController) Login(c *gin.Context) {
|
|||||||
zap.String("ip", c.ClientIP()),
|
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 {
|
if err != nil {
|
||||||
handleAuthError(c, err)
|
handleAuthError(c, err)
|
||||||
return
|
return
|
||||||
@@ -88,9 +89,13 @@ func (ctrl *AuthController) Logout(c *gin.Context) {
|
|||||||
return
|
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, "登出失败")
|
utils.ResponseError(c, "登出失败")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ func NewAuthService(userDAO *dao.UserDAO, roleDAO *dao.RoleDAO, jwtCfg *config.J
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register 用户注册
|
// Register 用户注册(前台用户端专用,clientType 固定为 frontend)
|
||||||
// 流程:检查用户名/邮箱是否重复 → 加密密码 → 创建用户 → 分配默认角色 → 生成 Token
|
// 流程:检查用户名/邮箱是否重复 → 加密密码 → 创建用户 → 分配默认角色 → 生成 Token
|
||||||
func (s *AuthService) Register(ctx context.Context, req *dto.RegisterRequest) (*dto.LoginResponse, error) {
|
func (s *AuthService) Register(ctx context.Context, req *dto.RegisterRequest) (*dto.LoginResponse, error) {
|
||||||
funcName := "service.auth_service.Register"
|
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)
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -123,7 +123,8 @@ func (s *AuthService) Register(ctx context.Context, req *dto.RegisterRequest) (*
|
|||||||
|
|
||||||
// Login 用户登录
|
// Login 用户登录
|
||||||
// 流程:查找用户 → 校验密码 → 检查账号状态 → 更新登录信息 → 生成 Token
|
// 流程:查找用户 → 校验密码 → 检查账号状态 → 更新登录信息 → 生成 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"
|
funcName := "service.auth_service.Login"
|
||||||
logs.Info(ctx, funcName, "开始处理登录",
|
logs.Info(ctx, funcName, "开始处理登录",
|
||||||
zap.String("account", req.Account),
|
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)
|
_ = s.userDAO.UpdateLastLogin(ctx, user.ID, clientIP)
|
||||||
|
|
||||||
// 获取角色列表并生成 Token(同时存入 Redis)
|
// 获取角色列表并生成 Token(同时存入 Redis,按 clientType 隔离)
|
||||||
roles, _ := s.roleDAO.GetUserRoleCodes(ctx, user.ID)
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -174,19 +175,21 @@ func (s *AuthService) Login(ctx context.Context, req *dto.LoginRequest, clientIP
|
|||||||
logs.Info(ctx, funcName, "登录成功",
|
logs.Info(ctx, funcName, "登录成功",
|
||||||
zap.Int64("user_id", user.ID),
|
zap.Int64("user_id", user.ID),
|
||||||
zap.String("username", user.Username),
|
zap.String("username", user.Username),
|
||||||
|
zap.String("client_type", clientType),
|
||||||
)
|
)
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdminLogin 管理后台登录
|
// AdminLogin 管理后台登录
|
||||||
// 与普通登录相同,但额外检查用户是否拥有 admin 或 super_admin 角色
|
// 与普通登录相同,但额外检查用户是否拥有 admin 或 super_admin 角色
|
||||||
|
// clientType 固定为 constants.ClientTypeAdmin
|
||||||
func (s *AuthService) AdminLogin(ctx context.Context, req *dto.LoginRequest, clientIP string) (*dto.LoginResponse, error) {
|
func (s *AuthService) AdminLogin(ctx context.Context, req *dto.LoginRequest, clientIP string) (*dto.LoginResponse, error) {
|
||||||
funcName := "service.auth_service.AdminLogin"
|
funcName := "service.auth_service.AdminLogin"
|
||||||
logs.Info(ctx, funcName, "开始处理管理员登录",
|
logs.Info(ctx, funcName, "开始处理管理员登录",
|
||||||
zap.String("account", req.Account),
|
zap.String("account", req.Account),
|
||||||
)
|
)
|
||||||
|
|
||||||
resp, err := s.Login(ctx, req, clientIP)
|
resp, err := s.Login(ctx, req, clientIP, constants.ClientTypeAdmin)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -229,10 +232,17 @@ func (s *AuthService) RefreshToken(ctx context.Context, refreshToken string) (*d
|
|||||||
return nil, ErrRefreshTokenType
|
return nil, ErrRefreshTokenType
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 从 claims 中获取 clientType(前台 / 管理端)
|
||||||
|
clientType := claims.ClientType
|
||||||
|
if clientType == "" {
|
||||||
|
clientType = constants.ClientTypeFrontend
|
||||||
|
}
|
||||||
|
|
||||||
// 验证 Refresh Token 是否与 Redis 中存储的一致
|
// 验证 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 中)",
|
logs.Warn(ctx, funcName, "Refresh Token 已失效(不在 Redis 中)",
|
||||||
zap.Int64("user_id", claims.UserID),
|
zap.Int64("user_id", claims.UserID),
|
||||||
|
zap.String("client_type", clientType),
|
||||||
)
|
)
|
||||||
return nil, ErrRefreshTokenType
|
return nil, ErrRefreshTokenType
|
||||||
}
|
}
|
||||||
@@ -250,29 +260,35 @@ func (s *AuthService) RefreshToken(ctx context.Context, refreshToken string) (*d
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取角色并生成新 Token(同时存入 Redis 覆盖旧 Token)
|
// 获取角色并生成新 Token(同时存入 Redis 覆盖旧 Token,保持 clientType 不变)
|
||||||
roles, _ := s.roleDAO.GetUserRoleCodes(ctx, user.ID)
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
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
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Logout 用户登出
|
// Logout 用户登出
|
||||||
// 从 Redis 中删除该用户的 Access Token 和 Refresh Token
|
// 从 Redis 中删除指定 clientType 下该用户的 Token(前台登出不影响管理端)
|
||||||
func (s *AuthService) Logout(ctx context.Context, userID int64) error {
|
func (s *AuthService) Logout(ctx context.Context, userID int64, clientType string) error {
|
||||||
funcName := "service.auth_service.Logout"
|
funcName := "service.auth_service.Logout"
|
||||||
logs.Info(ctx, funcName, "用户登出", zap.Int64("user_id", userID))
|
logs.Info(ctx, funcName, "用户登出",
|
||||||
return s.tokenStore.RemoveTokens(ctx, userID)
|
zap.Int64("user_id", userID),
|
||||||
|
zap.String("client_type", clientType),
|
||||||
|
)
|
||||||
|
return s.tokenStore.RemoveTokens(ctx, userID, clientType)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ValidateAccessToken 校验 Access Token 是否在 Redis 中有效
|
// ValidateAccessToken 校验 Access Token 是否在 Redis 中有效
|
||||||
// 供 JWT 中间件调用,实现有状态 JWT 验证
|
// 供 JWT 中间件调用,实现有状态 JWT 验证,按 clientType 隔离校验
|
||||||
func (s *AuthService) ValidateAccessToken(ctx context.Context, userID int64, token string) bool {
|
func (s *AuthService) ValidateAccessToken(ctx context.Context, userID int64, clientType, token string) bool {
|
||||||
return s.tokenStore.ValidateAccessToken(ctx, userID, token)
|
return s.tokenStore.ValidateAccessToken(ctx, userID, clientType, token)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetProfile 获取用户个人信息
|
// GetProfile 获取用户个人信息
|
||||||
@@ -374,23 +390,24 @@ func (s *AuthService) checkUserStatus(status int) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// buildLoginResponse 构建登录响应(生成 Token + 存入 Redis + 用户信息)
|
// 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 {
|
if roles == nil {
|
||||||
roles = []string{}
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
refreshToken, err := utils.GenerateRefreshToken(s.jwtCfg, user.ID)
|
refreshToken, err := utils.GenerateRefreshToken(s.jwtCfg, user.ID, clientType)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 将 Token 存入 Redis(有状态 JWT,支持主动失效和单设备登录)
|
// 将 Token 存入 Redis(按 clientType 隔离,前台和管理端互不影响)
|
||||||
if err = s.tokenStore.SaveTokens(ctx, user.ID, token, refreshToken); err != nil {
|
if err = s.tokenStore.SaveTokens(ctx, user.ID, clientType, token, refreshToken); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,10 +11,12 @@ import (
|
|||||||
"go.uber.org/zap"
|
"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 (
|
const (
|
||||||
keyPrefixAccessToken = "echo:auth:token:" // echo:auth:token:{user_id}
|
keyPrefixAccessToken = "echo:auth:token:" // echo:auth:token:{client_type}:{user_id}
|
||||||
keyPrefixRefreshToken = "echo:auth:refresh:" // echo:auth:refresh:{user_id}
|
keyPrefixRefreshToken = "echo:auth:refresh:" // echo:auth:refresh:{client_type}:{user_id}
|
||||||
)
|
)
|
||||||
|
|
||||||
// TokenStore 管理 Token 在 Redis 中的存取
|
// TokenStore 管理 Token 在 Redis 中的存取
|
||||||
@@ -33,12 +35,13 @@ func NewTokenStore(redisClient *redis.Client, jwtCfg *config.JWTConfig) *TokenSt
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SaveTokens 将 Access Token 和 Refresh Token 存入 Redis
|
// SaveTokens 将 Access Token 和 Refresh Token 存入 Redis
|
||||||
// 每次登录/注册时调用,覆盖旧 Token(实现单设备登录)
|
// 每次登录/注册时调用,覆盖同一 clientType 下的旧 Token
|
||||||
func (s *TokenStore) SaveTokens(ctx context.Context, userID int64, accessToken, refreshToken string) error {
|
// clientType 用于隔离前台(frontend)和管理端(admin)的 Token
|
||||||
|
func (s *TokenStore) SaveTokens(ctx context.Context, userID int64, clientType, accessToken, refreshToken string) error {
|
||||||
funcName := "service.token_store.SaveTokens"
|
funcName := "service.token_store.SaveTokens"
|
||||||
|
|
||||||
accessKey := fmt.Sprintf("%s%d", keyPrefixAccessToken, userID)
|
accessKey := fmt.Sprintf("%s%s:%d", keyPrefixAccessToken, clientType, userID)
|
||||||
refreshKey := fmt.Sprintf("%s%d", keyPrefixRefreshToken, userID)
|
refreshKey := fmt.Sprintf("%s%s:%d", keyPrefixRefreshToken, clientType, userID)
|
||||||
|
|
||||||
accessTTL := time.Duration(s.jwtCfg.AccessExpireMin) * time.Minute
|
accessTTL := time.Duration(s.jwtCfg.AccessExpireMin) * time.Minute
|
||||||
refreshTTL := time.Duration(s.jwtCfg.RefreshExpireDay) * 24 * time.Hour
|
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 {
|
if _, err := pipe.Exec(ctx); err != nil {
|
||||||
logs.Error(ctx, funcName, "保存 Token 到 Redis 失败",
|
logs.Error(ctx, funcName, "保存 Token 到 Redis 失败",
|
||||||
zap.Int64("user_id", userID),
|
zap.Int64("user_id", userID),
|
||||||
|
zap.String("client_type", clientType),
|
||||||
zap.Error(err),
|
zap.Error(err),
|
||||||
)
|
)
|
||||||
return err
|
return err
|
||||||
@@ -57,6 +61,7 @@ func (s *TokenStore) SaveTokens(ctx context.Context, userID int64, accessToken,
|
|||||||
|
|
||||||
logs.Debug(ctx, funcName, "Token 已存入 Redis",
|
logs.Debug(ctx, funcName, "Token 已存入 Redis",
|
||||||
zap.Int64("user_id", userID),
|
zap.Int64("user_id", userID),
|
||||||
|
zap.String("client_type", clientType),
|
||||||
zap.Duration("access_ttl", accessTTL),
|
zap.Duration("access_ttl", accessTTL),
|
||||||
zap.Duration("refresh_ttl", refreshTTL),
|
zap.Duration("refresh_ttl", refreshTTL),
|
||||||
)
|
)
|
||||||
@@ -64,21 +69,23 @@ func (s *TokenStore) SaveTokens(ctx context.Context, userID int64, accessToken,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ValidateAccessToken 校验 Access Token 是否与 Redis 中存储的一致
|
// ValidateAccessToken 校验 Access Token 是否与 Redis 中存储的一致
|
||||||
// 返回 true 表示有效,false 表示已被登出/覆盖
|
// clientType 用于定位正确的 Redis key(前台 vs 管理端)
|
||||||
func (s *TokenStore) ValidateAccessToken(ctx context.Context, userID int64, token string) bool {
|
func (s *TokenStore) ValidateAccessToken(ctx context.Context, userID int64, clientType, token string) bool {
|
||||||
funcName := "service.token_store.ValidateAccessToken"
|
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()
|
stored, err := s.redis.Get(ctx, key).Result()
|
||||||
if err == redis.Nil {
|
if err == redis.Nil {
|
||||||
logs.Debug(ctx, funcName, "Token 不存在(已登出或过期)",
|
logs.Debug(ctx, funcName, "Token 不存在(已登出或过期)",
|
||||||
zap.Int64("user_id", userID),
|
zap.Int64("user_id", userID),
|
||||||
|
zap.String("client_type", clientType),
|
||||||
)
|
)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logs.Error(ctx, funcName, "Redis 查询 Token 失败",
|
logs.Error(ctx, funcName, "Redis 查询 Token 失败",
|
||||||
zap.Int64("user_id", userID),
|
zap.Int64("user_id", userID),
|
||||||
|
zap.String("client_type", clientType),
|
||||||
zap.Error(err),
|
zap.Error(err),
|
||||||
)
|
)
|
||||||
return false
|
return false
|
||||||
@@ -88,8 +95,8 @@ func (s *TokenStore) ValidateAccessToken(ctx context.Context, userID int64, toke
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ValidateRefreshToken 校验 Refresh Token 是否与 Redis 中存储的一致
|
// ValidateRefreshToken 校验 Refresh Token 是否与 Redis 中存储的一致
|
||||||
func (s *TokenStore) ValidateRefreshToken(ctx context.Context, userID int64, token string) bool {
|
func (s *TokenStore) ValidateRefreshToken(ctx context.Context, userID int64, clientType, token string) bool {
|
||||||
key := fmt.Sprintf("%s%d", keyPrefixRefreshToken, userID)
|
key := fmt.Sprintf("%s%s:%d", keyPrefixRefreshToken, clientType, userID)
|
||||||
stored, err := s.redis.Get(ctx, key).Result()
|
stored, err := s.redis.Get(ctx, key).Result()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return false
|
||||||
@@ -97,16 +104,17 @@ func (s *TokenStore) ValidateRefreshToken(ctx context.Context, userID int64, tok
|
|||||||
return stored == token
|
return stored == token
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemoveTokens 从 Redis 删除用户的所有 Token(登出时调用)
|
// RemoveTokens 从 Redis 删除指定 clientType 下用户的 Token(登出时调用)
|
||||||
func (s *TokenStore) RemoveTokens(ctx context.Context, userID int64) error {
|
func (s *TokenStore) RemoveTokens(ctx context.Context, userID int64, clientType string) error {
|
||||||
funcName := "service.token_store.RemoveTokens"
|
funcName := "service.token_store.RemoveTokens"
|
||||||
|
|
||||||
accessKey := fmt.Sprintf("%s%d", keyPrefixAccessToken, userID)
|
accessKey := fmt.Sprintf("%s%s:%d", keyPrefixAccessToken, clientType, userID)
|
||||||
refreshKey := fmt.Sprintf("%s%d", keyPrefixRefreshToken, userID)
|
refreshKey := fmt.Sprintf("%s%s:%d", keyPrefixRefreshToken, clientType, userID)
|
||||||
|
|
||||||
if err := s.redis.Del(ctx, accessKey, refreshKey).Err(); err != nil {
|
if err := s.redis.Del(ctx, accessKey, refreshKey).Err(); err != nil {
|
||||||
logs.Error(ctx, funcName, "删除 Token 失败",
|
logs.Error(ctx, funcName, "删除 Token 失败",
|
||||||
zap.Int64("user_id", userID),
|
zap.Int64("user_id", userID),
|
||||||
|
zap.String("client_type", clientType),
|
||||||
zap.Error(err),
|
zap.Error(err),
|
||||||
)
|
)
|
||||||
return err
|
return err
|
||||||
@@ -114,6 +122,7 @@ func (s *TokenStore) RemoveTokens(ctx context.Context, userID int64) error {
|
|||||||
|
|
||||||
logs.Info(ctx, funcName, "Token 已从 Redis 删除",
|
logs.Info(ctx, funcName, "Token 已从 Redis 删除",
|
||||||
zap.Int64("user_id", userID),
|
zap.Int64("user_id", userID),
|
||||||
|
zap.String("client_type", clientType),
|
||||||
)
|
)
|
||||||
return nil
|
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" // 后台管理端
|
||||||
|
)
|
||||||
@@ -15,12 +15,13 @@ const (
|
|||||||
ContextKeyUserID = "user_id" // Context 中存储当前用户 ID 的 Key
|
ContextKeyUserID = "user_id" // Context 中存储当前用户 ID 的 Key
|
||||||
ContextKeyUsername = "username" // Context 中存储当前用户名的 Key
|
ContextKeyUsername = "username" // Context 中存储当前用户名的 Key
|
||||||
ContextKeyRoles = "roles" // Context 中存储当前用户角色列表的 Key
|
ContextKeyRoles = "roles" // Context 中存储当前用户角色列表的 Key
|
||||||
|
ContextKeyClientType = "client_type" // Context 中存储客户端类型的 Key(frontend / admin)
|
||||||
)
|
)
|
||||||
|
|
||||||
// TokenValidator Token 有效性校验接口
|
// TokenValidator Token 有效性校验接口
|
||||||
// 由 AuthService 实现,中间件通过此接口检查 Token 是否在 Redis 中有效
|
// 由 AuthService 实现,中间件通过此接口检查 Token 是否在 Redis 中有效
|
||||||
type TokenValidator interface {
|
type TokenValidator interface {
|
||||||
ValidateAccessToken(ctx context.Context, userID int64, token string) bool
|
ValidateAccessToken(ctx context.Context, userID int64, clientType, token string) bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// JWTAuth JWT 认证中间件(有状态 JWT)
|
// JWTAuth JWT 认证中间件(有状态 JWT)
|
||||||
@@ -62,10 +63,17 @@ func JWTAuth(jwtCfg *config.JWTConfig, validator TokenValidator) gin.HandlerFunc
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 校验 Token 是否在 Redis 中有效(有状态 JWT 核心逻辑)
|
// 从 Claims 获取 clientType(兼容旧 Token 无该字段的情况)
|
||||||
if !validator.ValidateAccessToken(ctx, claims.UserID, tokenStr) {
|
clientType := claims.ClientType
|
||||||
|
if clientType == "" {
|
||||||
|
clientType = "frontend"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 校验 Token 是否在 Redis 中有效(有状态 JWT 核心逻辑,按 clientType 隔离)
|
||||||
|
if !validator.ValidateAccessToken(ctx, claims.UserID, clientType, tokenStr) {
|
||||||
logs.Warn(ctx, funcName, "Token 已失效(已登出或被覆盖)",
|
logs.Warn(ctx, funcName, "Token 已失效(已登出或被覆盖)",
|
||||||
zap.Int64("user_id", claims.UserID),
|
zap.Int64("user_id", claims.UserID),
|
||||||
|
zap.String("client_type", clientType),
|
||||||
zap.String("ip", c.ClientIP()),
|
zap.String("ip", c.ClientIP()),
|
||||||
)
|
)
|
||||||
utils.ResponseUnauthorized(c, "认证已失效,请重新登录")
|
utils.ResponseUnauthorized(c, "认证已失效,请重新登录")
|
||||||
@@ -76,6 +84,7 @@ func JWTAuth(jwtCfg *config.JWTConfig, validator TokenValidator) gin.HandlerFunc
|
|||||||
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)
|
||||||
|
c.Set(ContextKeyClientType, clientType)
|
||||||
|
|
||||||
c.Next()
|
c.Next()
|
||||||
}
|
}
|
||||||
@@ -141,3 +150,16 @@ func GetCurrentUsername(c *gin.Context) (string, bool) {
|
|||||||
username, ok := val.(string)
|
username, ok := val.(string)
|
||||||
return username, ok
|
return username, ok
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetCurrentClientType 从 Gin Context 获取当前客户端类型(frontend / admin)
|
||||||
|
func GetCurrentClientType(c *gin.Context) string {
|
||||||
|
val, exists := c.Get(ContextKeyClientType)
|
||||||
|
if !exists {
|
||||||
|
return "frontend"
|
||||||
|
}
|
||||||
|
clientType, ok := val.(string)
|
||||||
|
if !ok {
|
||||||
|
return "frontend"
|
||||||
|
}
|
||||||
|
return clientType
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ type Claims struct {
|
|||||||
UserID int64 `json:"user_id"` // 用户 ID
|
UserID int64 `json:"user_id"` // 用户 ID
|
||||||
Username string `json:"username"` // 用户名
|
Username string `json:"username"` // 用户名
|
||||||
Roles []string `json:"roles"` // 用户角色代码列表
|
Roles []string `json:"roles"` // 用户角色代码列表
|
||||||
|
ClientType string `json:"client_type"` // 客户端类型:frontend / admin
|
||||||
jwt.RegisteredClaims
|
jwt.RegisteredClaims
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,14 +23,15 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// GenerateToken 生成 Access Token
|
// GenerateToken 生成 Access Token
|
||||||
// 包含 UserID、Username、Roles,有效期由配置中的 access_expire_min 决定
|
// 包含 UserID、Username、Roles、ClientType,有效期由配置中的 access_expire_min 决定
|
||||||
func GenerateToken(cfg *config.JWTConfig, userID int64, username string, roles []string) (string, error) {
|
func GenerateToken(cfg *config.JWTConfig, userID int64, username string, roles []string, clientType string) (string, error) {
|
||||||
expireTime := time.Now().Add(time.Duration(cfg.AccessExpireMin) * time.Minute)
|
expireTime := time.Now().Add(time.Duration(cfg.AccessExpireMin) * time.Minute)
|
||||||
|
|
||||||
claims := &Claims{
|
claims := &Claims{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
Username: username,
|
Username: username,
|
||||||
Roles: roles,
|
Roles: roles,
|
||||||
|
ClientType: clientType,
|
||||||
RegisteredClaims: jwt.RegisteredClaims{
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
ExpiresAt: jwt.NewNumericDate(expireTime),
|
ExpiresAt: jwt.NewNumericDate(expireTime),
|
||||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||||
@@ -43,12 +45,13 @@ func GenerateToken(cfg *config.JWTConfig, userID int64, username string, roles [
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GenerateRefreshToken 生成 Refresh Token
|
// GenerateRefreshToken 生成 Refresh Token
|
||||||
// 仅包含 UserID,有效期由配置中的 refresh_expire_day 决定
|
// 包含 UserID 和 ClientType,有效期由配置中的 refresh_expire_day 决定
|
||||||
func GenerateRefreshToken(cfg *config.JWTConfig, userID int64) (string, error) {
|
func GenerateRefreshToken(cfg *config.JWTConfig, userID int64, clientType string) (string, error) {
|
||||||
expireTime := time.Now().Add(time.Duration(cfg.RefreshExpireDay) * 24 * time.Hour)
|
expireTime := time.Now().Add(time.Duration(cfg.RefreshExpireDay) * 24 * time.Hour)
|
||||||
|
|
||||||
claims := &Claims{
|
claims := &Claims{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
|
ClientType: clientType,
|
||||||
RegisteredClaims: jwt.RegisteredClaims{
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
ExpiresAt: jwt.NewNumericDate(expireTime),
|
ExpiresAt: jwt.NewNumericDate(expireTime),
|
||||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||||
|
|||||||
@@ -139,7 +139,7 @@
|
|||||||
|
|
||||||
**权限:** 需认证
|
**权限:** 需认证
|
||||||
|
|
||||||
**说明:** 采用有状态 JWT 方案,Token 存储在 Redis 中(`echo:auth:token:{user_id}` 和 `echo:auth:refresh:{user_id}`)。登出时服务端会从 Redis 中删除该用户的 Access Token 和 Refresh Token,使其立即失效。客户端也应同步清除本地存储的 Token。
|
**说明:** 采用有状态 JWT 方案,Token 按 clientType 隔离存储在 Redis 中(`echo:auth:token:frontend:{user_id}` 和 `echo:auth:refresh:frontend:{user_id}`)。登出时服务端只删除前台端的 Token,不影响管理端。客户端也应同步清除本地存储的 Token。
|
||||||
|
|
||||||
**成功响应:**
|
**成功响应:**
|
||||||
```json
|
```json
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
> **适用范围**:EchoChat 项目全端(Go 后端 + admin 管理端 + frontend 用户端)
|
> **适用范围**:EchoChat 项目全端(Go 后端 + admin 管理端 + frontend 用户端)
|
||||||
> **创建日期**:2026-03-02
|
> **创建日期**:2026-03-02
|
||||||
> **最后更新**:2026-03-02
|
> **最后更新**:2026-03-02(新增前后台 Token 隔离规范)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -202,3 +202,66 @@ if err != nil {
|
|||||||
| 用户不存在 | 404 | 用户不存在 | 显示 message |
|
| 用户不存在 | 404 | 用户不存在 | 显示 message |
|
||||||
| 服务器内部错误 | 500 | 获取用户列表失败 | 显示 message |
|
| 服务器内部错误 | 500 | 获取用户列表失败 | 显示 message |
|
||||||
| 网络异常 | - | (无响应体) | 显示前端 fallback 文案 |
|
| 网络异常 | - | (无响应体) | 显示前端 fallback 文案 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、前后台 Token 隔离规范
|
||||||
|
|
||||||
|
### 6.1 核心原则
|
||||||
|
|
||||||
|
前台用户端(frontend)和后台管理端(admin)的 Token **必须在 Redis 中完全隔离**,同一用户可以同时在两端保持登录状态,互不影响。
|
||||||
|
|
||||||
|
### 6.2 Redis Key 格式
|
||||||
|
|
||||||
|
```
|
||||||
|
echo:auth:token:{client_type}:{user_id} → Access Token
|
||||||
|
echo:auth:refresh:{client_type}:{user_id} → Refresh Token
|
||||||
|
```
|
||||||
|
|
||||||
|
| client_type | 说明 | 示例 Key |
|
||||||
|
|------------|------|----------|
|
||||||
|
| `frontend` | 前台用户端 | `echo:auth:token:frontend:1` |
|
||||||
|
| `admin` | 后台管理端 | `echo:auth:token:admin:1` |
|
||||||
|
|
||||||
|
### 6.3 JWT Claims 中的 client_type
|
||||||
|
|
||||||
|
JWT Token 的 Claims 中包含 `client_type` 字段,用于:
|
||||||
|
- 中间件校验时定位正确的 Redis key
|
||||||
|
- 登出时只删除当前端的 Token
|
||||||
|
- Refresh Token 刷新时保持 client_type 不变
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"user_id": 1,
|
||||||
|
"username": "admin_test",
|
||||||
|
"roles": ["user", "admin"],
|
||||||
|
"client_type": "admin",
|
||||||
|
"sub": "access",
|
||||||
|
"exp": 1709400000,
|
||||||
|
"iss": "echochat"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.4 API 路由区分
|
||||||
|
|
||||||
|
| 端 | 登录 API | 说明 |
|
||||||
|
|----|---------|------|
|
||||||
|
| 前台 | `POST /api/v1/auth/login` | clientType=frontend |
|
||||||
|
| 管理端 | `POST /api/v1/admin/auth/login` | clientType=admin,额外检查管理员角色 |
|
||||||
|
|
||||||
|
### 6.5 登出行为
|
||||||
|
|
||||||
|
- 前台登出:只删除 `echo:auth:token:frontend:{user_id}`,不影响管理端
|
||||||
|
- 管理端登出:只删除 `echo:auth:token:admin:{user_id}`,不影响前台
|
||||||
|
- 管理端禁用用户:应删除该用户两端的所有 Token(由管理模块负责)
|
||||||
|
|
||||||
|
### 6.6 检查清单
|
||||||
|
|
||||||
|
新增认证相关功能时,必须确认:
|
||||||
|
|
||||||
|
- [ ] Login 方法传递了正确的 clientType
|
||||||
|
- [ ] 前端调用了正确的登录 API 端点
|
||||||
|
- [ ] Redis key 包含 clientType 前缀
|
||||||
|
- [ ] JWT Claims 中包含 client_type 字段
|
||||||
|
- [ ] 登出时只删除对应 clientType 的 Token
|
||||||
|
- [ ] Token 刷新时保持原 clientType 不变
|
||||||
|
|||||||
@@ -573,9 +573,10 @@ COMMENT ON COLUMN admin_operation_logs.created_at IS '操作时间';
|
|||||||
### 4.2 Redis 数据结构
|
### 4.2 Redis 数据结构
|
||||||
|
|
||||||
```
|
```
|
||||||
# 用户认证
|
# 用户认证(按 client_type 隔离前台和管理端)
|
||||||
echo:auth:token:{user_id} → JWT Token (STRING, TTL 7天)
|
echo:auth:token:{client_type}:{user_id} → JWT Access Token (STRING, TTL 由配置决定)
|
||||||
echo:auth:refresh:{user_id} → Refresh Token (STRING, TTL 30天)
|
echo:auth:refresh:{client_type}:{user_id} → Refresh Token (STRING, TTL 由配置决定)
|
||||||
|
# client_type: frontend(前台用户端)/ admin(后台管理端)
|
||||||
|
|
||||||
# 用户在线状态
|
# 用户在线状态
|
||||||
echo:user:online → 在线用户集合 (SET)
|
echo:user:online → 在线用户集合 (SET)
|
||||||
|
|||||||
@@ -507,11 +507,11 @@ type UserInfo struct {
|
|||||||
**Step 4: 创建 Token Redis 存储管理**
|
**Step 4: 创建 Token Redis 存储管理**
|
||||||
|
|
||||||
创建 `app/auth/service/token_store.go`:
|
创建 `app/auth/service/token_store.go`:
|
||||||
- 遵循设计方案中的 Redis Key 规范:`echo:auth:token:{user_id}` / `echo:auth:refresh:{user_id}`
|
- Redis Key 按 clientType 隔离:`echo:auth:token:{client_type}:{user_id}` / `echo:auth:refresh:{client_type}:{user_id}`
|
||||||
- `SaveTokens(ctx, userID, accessToken, refreshToken)` — 存入 Redis(覆盖旧 Token,实现单设备登录)
|
- `SaveTokens(ctx, userID, clientType, accessToken, refreshToken)` — 存入 Redis(按 clientType 隔离)
|
||||||
- `ValidateAccessToken(ctx, userID, token)` — 校验 Access Token 与 Redis 一致
|
- `ValidateAccessToken(ctx, userID, clientType, token)` — 校验 Access Token 与 Redis 一致
|
||||||
- `ValidateRefreshToken(ctx, userID, token)` — 校验 Refresh Token 与 Redis 一致
|
- `ValidateRefreshToken(ctx, userID, clientType, token)` — 校验 Refresh Token 与 Redis 一致
|
||||||
- `RemoveTokens(ctx, userID)` — 登出时删除 Redis 中的 Token
|
- `RemoveTokens(ctx, userID, clientType)` — 登出时删除指定 clientType 的 Token
|
||||||
|
|
||||||
**Step 5: 创建认证服务**
|
**Step 5: 创建认证服务**
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
|
|
||||||
### 后端(Go)
|
### 后端(Go)
|
||||||
1. **框架组合**:Gin + GORM + Wire + Zap + Viper
|
1. **框架组合**:Gin + GORM + Wire + Zap + Viper
|
||||||
2. **JWT 策略**:有状态 JWT,Token 存储在 Redis(`echo:auth:token:{user_id}`)
|
2. **JWT 策略**:有状态 JWT,Token 按 clientType 隔离存储在 Redis(`echo:auth:token:{frontend|admin}:{user_id}`)
|
||||||
3. **密码加密**:bcrypt
|
3. **密码加密**:bcrypt
|
||||||
4. **数据库时间精度**:`TIMESTAMP(0)` 精确到秒
|
4. **数据库时间精度**:`TIMESTAMP(0)` 精确到秒
|
||||||
5. **API 响应格式**:统一 `{ "code": 0, "message": "success", "data": ... }`
|
5. **API 响应格式**:统一 `{ "code": 0, "message": "success", "data": ... }`
|
||||||
|
|||||||
Reference in New Issue
Block a user