diff --git a/.cursor/rules/project-context.mdc b/.cursor/rules/project-context.mdc index 77d7c54..1149bfd 100644 --- a/.cursor/rules/project-context.mdc +++ b/.cursor/rules/project-context.mdc @@ -33,7 +33,7 @@ EchoChat 是一个实时音视频通讯平台,包含三个子项目: 4. **模块系统**:前端统一使用 ESM(`export`/`import`),禁止 CommonJS 5. **Go 常量命名**:camelCase(`UserStatusActive`),非大写下划线 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 必须有详细注释 9. **文档同步**:代码变更后必须同步更新 docs/ 下相关文档 10. **验证方式**:使用 Playwright MCP 进行页面自动化验证 diff --git a/admin/src/api/auth.js b/admin/src/api/auth.js index 472db81..f761c00 100644 --- a/admin/src/api/auth.js +++ b/admin/src/api/auth.js @@ -1,21 +1,24 @@ /** * 管理后台认证 API * - * 对应后端路由:/api/v1/auth/* - * 管理员登录使用与普通用户相同的登录接口, - * 权限区分由 JWT 中的 role 字段和 RequireRole 中间件控制 + * 登录接口:/api/v1/admin/auth/login(管理端专用,后端检查管理员角色) + * 其他接口:/api/v1/auth/*(与前台共用,通过 JWT 中的 client_type 区分) + * + * Token 在 Redis 中按 client_type 隔离存储: + * - 管理端:echo:auth:token:admin:{user_id} + * - 前台:echo:auth:token:frontend:{user_id} */ import request from '@/utils/request' /** * 管理员登录 - * POST /api/v1/auth/login + * POST /api/v1/admin/auth/login * * @param {Object} data - { account, password } * @returns {Promise} { token, refresh_token, expires_in, user } */ export const login = (data) => { - return request.post('/api/v1/auth/login', data) + return request.post('/api/v1/admin/auth/login', data) } /** diff --git a/backend/go-service/app/auth/controller/auth_controller.go b/backend/go-service/app/auth/controller/auth_controller.go index d9d3796..f850d36 100644 --- a/backend/go-service/app/auth/controller/auth_controller.go +++ b/backend/go-service/app/auth/controller/auth_controller.go @@ -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 } diff --git a/backend/go-service/app/auth/service/auth_service.go b/backend/go-service/app/auth/service/auth_service.go index 3451bb8..2ba3f54 100644 --- a/backend/go-service/app/auth/service/auth_service.go +++ b/backend/go-service/app/auth/service/auth_service.go @@ -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 } diff --git a/backend/go-service/app/auth/service/token_store.go b/backend/go-service/app/auth/service/token_store.go index f332855..2677281 100644 --- a/backend/go-service/app/auth/service/token_store.go +++ b/backend/go-service/app/auth/service/token_store.go @@ -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 } diff --git a/backend/go-service/app/constants/client_type.go b/backend/go-service/app/constants/client_type.go new file mode 100644 index 0000000..9ad762c --- /dev/null +++ b/backend/go-service/app/constants/client_type.go @@ -0,0 +1,7 @@ +package constants + +// 客户端类型,用于区分前台用户端和后台管理端的 Token 存储 +const ( + ClientTypeFrontend = "frontend" // 前台用户端 + ClientTypeAdmin = "admin" // 后台管理端 +) diff --git a/backend/go-service/pkg/middleware/auth.go b/backend/go-service/pkg/middleware/auth.go index 7fd5dd4..1696ba2 100644 --- a/backend/go-service/pkg/middleware/auth.go +++ b/backend/go-service/pkg/middleware/auth.go @@ -12,15 +12,16 @@ import ( ) const ( - ContextKeyUserID = "user_id" // Context 中存储当前用户 ID 的 Key - ContextKeyUsername = "username" // Context 中存储当前用户名的 Key - ContextKeyRoles = "roles" // Context 中存储当前用户角色列表的 Key + ContextKeyUserID = "user_id" // Context 中存储当前用户 ID 的 Key + ContextKeyUsername = "username" // Context 中存储当前用户名的 Key + ContextKeyRoles = "roles" // Context 中存储当前用户角色列表的 Key + ContextKeyClientType = "client_type" // Context 中存储客户端类型的 Key(frontend / admin) ) // TokenValidator Token 有效性校验接口 // 由 AuthService 实现,中间件通过此接口检查 Token 是否在 Redis 中有效 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) @@ -62,10 +63,17 @@ func JWTAuth(jwtCfg *config.JWTConfig, validator TokenValidator) gin.HandlerFunc return } - // 校验 Token 是否在 Redis 中有效(有状态 JWT 核心逻辑) - if !validator.ValidateAccessToken(ctx, claims.UserID, tokenStr) { + // 从 Claims 获取 clientType(兼容旧 Token 无该字段的情况) + clientType := claims.ClientType + if clientType == "" { + clientType = "frontend" + } + + // 校验 Token 是否在 Redis 中有效(有状态 JWT 核心逻辑,按 clientType 隔离) + if !validator.ValidateAccessToken(ctx, claims.UserID, clientType, tokenStr) { logs.Warn(ctx, funcName, "Token 已失效(已登出或被覆盖)", zap.Int64("user_id", claims.UserID), + zap.String("client_type", clientType), zap.String("ip", c.ClientIP()), ) utils.ResponseUnauthorized(c, "认证已失效,请重新登录") @@ -76,6 +84,7 @@ func JWTAuth(jwtCfg *config.JWTConfig, validator TokenValidator) gin.HandlerFunc c.Set(ContextKeyUserID, claims.UserID) c.Set(ContextKeyUsername, claims.Username) c.Set(ContextKeyRoles, claims.Roles) + c.Set(ContextKeyClientType, clientType) c.Next() } @@ -141,3 +150,16 @@ func GetCurrentUsername(c *gin.Context) (string, bool) { username, ok := val.(string) 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 +} diff --git a/backend/go-service/pkg/utils/jwt.go b/backend/go-service/pkg/utils/jwt.go index fd790ff..f79d915 100644 --- a/backend/go-service/pkg/utils/jwt.go +++ b/backend/go-service/pkg/utils/jwt.go @@ -10,9 +10,10 @@ import ( // Claims 自定义 JWT Claims,在标准 Claims 基础上扩展业务字段 type Claims struct { - UserID int64 `json:"user_id"` // 用户 ID - Username string `json:"username"` // 用户名 - Roles []string `json:"roles"` // 用户角色代码列表 + UserID int64 `json:"user_id"` // 用户 ID + Username string `json:"username"` // 用户名 + Roles []string `json:"roles"` // 用户角色代码列表 + ClientType string `json:"client_type"` // 客户端类型:frontend / admin jwt.RegisteredClaims } @@ -22,14 +23,15 @@ var ( ) // GenerateToken 生成 Access Token -// 包含 UserID、Username、Roles,有效期由配置中的 access_expire_min 决定 -func GenerateToken(cfg *config.JWTConfig, userID int64, username string, roles []string) (string, error) { +// 包含 UserID、Username、Roles、ClientType,有效期由配置中的 access_expire_min 决定 +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) claims := &Claims{ - UserID: userID, - Username: username, - Roles: roles, + UserID: userID, + Username: username, + Roles: roles, + ClientType: clientType, RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(expireTime), IssuedAt: jwt.NewNumericDate(time.Now()), @@ -43,12 +45,13 @@ func GenerateToken(cfg *config.JWTConfig, userID int64, username string, roles [ } // GenerateRefreshToken 生成 Refresh Token -// 仅包含 UserID,有效期由配置中的 refresh_expire_day 决定 -func GenerateRefreshToken(cfg *config.JWTConfig, userID int64) (string, error) { +// 包含 UserID 和 ClientType,有效期由配置中的 refresh_expire_day 决定 +func GenerateRefreshToken(cfg *config.JWTConfig, userID int64, clientType string) (string, error) { expireTime := time.Now().Add(time.Duration(cfg.RefreshExpireDay) * 24 * time.Hour) claims := &Claims{ - UserID: userID, + UserID: userID, + ClientType: clientType, RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(expireTime), IssuedAt: jwt.NewNumericDate(time.Now()), diff --git a/docs/api/frontend/auth.md b/docs/api/frontend/auth.md index 08e660b..91f5d01 100644 --- a/docs/api/frontend/auth.md +++ b/docs/api/frontend/auth.md @@ -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 diff --git a/docs/conventions/frontend-backend-integration.md b/docs/conventions/frontend-backend-integration.md index 6303a1b..501171e 100644 --- a/docs/conventions/frontend-backend-integration.md +++ b/docs/conventions/frontend-backend-integration.md @@ -2,7 +2,7 @@ > **适用范围**:EchoChat 项目全端(Go 后端 + admin 管理端 + frontend 用户端) > **创建日期**:2026-03-02 -> **最后更新**:2026-03-02 +> **最后更新**:2026-03-02(新增前后台 Token 隔离规范) --- @@ -202,3 +202,66 @@ if err != nil { | 用户不存在 | 404 | 用户不存在 | 显示 message | | 服务器内部错误 | 500 | 获取用户列表失败 | 显示 message | | 网络异常 | - | (无响应体) | 显示前端 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 不变 diff --git a/docs/plans/2026-02-27-echochat-system-design.md b/docs/plans/2026-02-27-echochat-system-design.md index 94c166c..cc92461 100644 --- a/docs/plans/2026-02-27-echochat-system-design.md +++ b/docs/plans/2026-02-27-echochat-system-design.md @@ -573,9 +573,10 @@ COMMENT ON COLUMN admin_operation_logs.created_at IS '操作时间'; ### 4.2 Redis 数据结构 ``` -# 用户认证 -echo:auth:token:{user_id} → JWT Token (STRING, TTL 7天) -echo:auth:refresh:{user_id} → Refresh Token (STRING, TTL 30天) +# 用户认证(按 client_type 隔离前台和管理端) +echo:auth:token:{client_type}:{user_id} → JWT Access Token (STRING, TTL 由配置决定) +echo:auth:refresh:{client_type}:{user_id} → Refresh Token (STRING, TTL 由配置决定) +# client_type: frontend(前台用户端)/ admin(后台管理端) # 用户在线状态 echo:user:online → 在线用户集合 (SET) diff --git a/docs/plans/2026-02-27-phase1-foundation-and-auth.md b/docs/plans/2026-02-27-phase1-foundation-and-auth.md index f0fe1d6..67df510 100644 --- a/docs/plans/2026-02-27-phase1-foundation-and-auth.md +++ b/docs/plans/2026-02-27-phase1-foundation-and-auth.md @@ -507,11 +507,11 @@ type UserInfo struct { **Step 4: 创建 Token Redis 存储管理** 创建 `app/auth/service/token_store.go`: -- 遵循设计方案中的 Redis Key 规范:`echo:auth:token:{user_id}` / `echo:auth:refresh:{user_id}` -- `SaveTokens(ctx, userID, accessToken, refreshToken)` — 存入 Redis(覆盖旧 Token,实现单设备登录) -- `ValidateAccessToken(ctx, userID, token)` — 校验 Access Token 与 Redis 一致 -- `ValidateRefreshToken(ctx, userID, token)` — 校验 Refresh Token 与 Redis 一致 -- `RemoveTokens(ctx, userID)` — 登出时删除 Redis 中的 Token +- Redis Key 按 clientType 隔离:`echo:auth:token:{client_type}:{user_id}` / `echo:auth:refresh:{client_type}:{user_id}` +- `SaveTokens(ctx, userID, clientType, accessToken, refreshToken)` — 存入 Redis(按 clientType 隔离) +- `ValidateAccessToken(ctx, userID, clientType, token)` — 校验 Access Token 与 Redis 一致 +- `ValidateRefreshToken(ctx, userID, clientType, token)` — 校验 Refresh Token 与 Redis 一致 +- `RemoveTokens(ctx, userID, clientType)` — 登出时删除指定 clientType 的 Token **Step 5: 创建认证服务** diff --git a/docs/progress/CURRENT_STATUS.md b/docs/progress/CURRENT_STATUS.md index cda7cee..bf50a04 100644 --- a/docs/progress/CURRENT_STATUS.md +++ b/docs/progress/CURRENT_STATUS.md @@ -29,7 +29,7 @@ ### 后端(Go) 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 4. **数据库时间精度**:`TIMESTAMP(0)` 精确到秒 5. **API 响应格式**:统一 `{ "code": 0, "message": "success", "data": ... }`