# 鉴权体系设计 ## 概述 CamTalk 采用 JWT 双 token 轮转认证机制,结合 bcrypt 密码哈希和 Refresh Token Rotation 安全策略,实现安全可靠的用户认证体系。 **设计原则**: - **安全性**:access_token 短有效期(15 分钟),refresh_token 支持轮转防重放 - **可靠性**:Refresh Token Rotation 机制,检测复用时自动吊销用户所有令牌 - **可扩展性**:Repository 接口隔离存储层,支持内存和 PostgreSQL 双实现 ## 整体架构 ```mermaid graph TB subgraph Client["客户端"] Browser["浏览器"] end subgraph AuthModule["Auth 模块"] Service["AuthService
Register / Login / Refresh / Logout"] TokenMgr["TokenManager
JWT 生成与验证"] Middleware["AuthMiddleware
Gin 中间件"] Password["PasswordUtil
bcrypt 哈希"] end subgraph Storage["存储层"] UserRepo["UserRepository
用户数据"] TokenStore["RefreshToken 存储
SHA256 哈希"] end Browser -->|"POST /api/auth/*"| Service Service --> TokenMgr Service --> Password Service --> UserRepo Service --> TokenStore Middleware -->|"校验 access_token"| TokenMgr Middleware -->|"写入 user_id/username"| GinContext["Gin Context"] ``` ## 核心组件 ### 1. JWT 令牌管理(TokenManager) **文件位置**:`backend/internal/auth/jwt.go` #### Claims 结构 ```go type Claims struct { UserID string `json:"user_id"` Username string `json:"username"` TokenType string `json:"token_type"` // "access" | "refresh" jwt.RegisteredClaims } ``` **字段说明**: - `UserID`:用户唯一标识(UUID) - `Username`:用户名 - `TokenType`:令牌类型,用于区分 access 和 refresh token - `RegisteredClaims`:JWT 标准声明(ExpiresAt, IssuedAt, Issuer, ID) #### TokenManager 配置 ```go type TokenManager struct { secret []byte // JWT 签名密钥(HS256) accessTTL time.Duration // access_token 有效期(默认 15 分钟) refreshTTL time.Duration // refresh_token 有效期(默认 7 天) } func NewTokenManager(secret string, accessTTL, refreshTTL time.Duration) *TokenManager ``` #### 令牌生成 ```go func (tm *TokenManager) GeneratePair(userID, username string) (access, refresh string, err error) ``` **生成逻辑**: 1. **access_token**: - 签名算法:HS256 - 有效期:15 分钟 - 包含:UserID, Username, TokenType="access", ExpiresAt, IssuedAt, Issuer="camtalk" 2. **refresh_token**: - 签名算法:HS256 - 有效期:7 天 - 包含:UserID, Username, TokenType="refresh", ID=UUID(用于 DB 关联), ExpiresAt, IssuedAt, Issuer="camtalk" #### 令牌验证 ```go func (tm *TokenManager) ValidateAccess(tokenStr string) (*Claims, error) func (tm *TokenManager) ValidateRefresh(tokenStr string) (*Claims, error) ``` **验证逻辑**: 1. 解析 JWT,验证签名算法为 HMAC 2. 验证签名是否有效 3. 验证令牌是否过期 4. 验证 TokenType 是否匹配(access 或 refresh) 5. 返回 Claims 或错误 #### Token 哈希 ```go func HashToken(token string) string ``` **用途**:对 refresh_token 做 SHA256 哈希后存储到数据库,避免直接存储原始 token。 ### 2. 密码处理(PasswordUtil) **文件位置**:`backend/internal/auth/password.go` #### 密码哈希 ```go func HashPassword(password string) (string, error) ``` **实现**: - 算法:bcrypt - Cost:10(2^10 次迭代) - 返回:base64 编码的哈希字符串 #### 密码验证 ```go func CheckPassword(hashedPassword, password string) error ``` **实现**: - 使用 `bcrypt.CompareHashAndPassword` 验证 - 返回 nil 表示匹配,否则返回错误 ### 3. 认证服务(AuthService) **文件位置**:`backend/internal/auth/service.go` #### 接口定义 ```go type Service interface { Register(ctx context.Context, req RegisterRequest) (*AuthResponse, error) Login(ctx context.Context, req LoginRequest) (*AuthResponse, error) Refresh(ctx context.Context, req RefreshRequest) (*AuthResponse, error) Logout(ctx context.Context, userID, refreshToken string) error } ``` #### 注册流程(Register) ```go func (s *authService) Register(ctx context.Context, req RegisterRequest) (*AuthResponse, error) ``` **流程**: 1. 检查用户名是否已存在(`FindByUsername`) 2. 如果存在,返回 `ErrUsernameTaken` 3. 使用 bcrypt 哈希密码(`HashPassword`) 4. 创建用户记录(`Create`) 5. 生成 access_token + refresh_token(`GeneratePair`) 6. 保存 refresh_token 的 SHA256 哈希到数据库(`SaveRefreshToken`) 7. 返回 `AuthResponse` **错误处理**: - `ErrUsernameTaken`:用户名已存在 - 数据库错误:透传底层错误 #### 登录流程(Login) ```go func (s *authService) Login(ctx context.Context, req LoginRequest) (*AuthResponse, error) ``` **流程**: 1. 根据用户名查找用户(`FindByUsername`) 2. 如果用户不存在,返回 `ErrInvalidCredentials` 3. 验证密码(`CheckPassword`) 4. 如果密码错误,返回 `ErrInvalidCredentials` 5. 生成 access_token + refresh_token(`GeneratePair`) 6. 保存 refresh_token 的 SHA256 哈希到数据库(`SaveRefreshToken`) 7. 返回 `AuthResponse` **错误处理**: - `ErrInvalidCredentials`:用户名或密码错误(统一错误信息,防止枚举攻击) #### 刷新令牌流程(Refresh)— Refresh Token Rotation ```go func (s *authService) Refresh(ctx context.Context, req RefreshRequest) (*AuthResponse, error) ``` **流程**: 1. 验证 refresh_token 的签名和有效期(`ValidateRefresh`) 2. 计算 refresh_token 的 SHA256 哈希(`HashToken`) 3. 在数据库中查找该哈希(`FindRefreshToken`) 4. **如果哈希不存在**: - JWT 校验已通过但 DB 中不存在 → token 已被 rotation 删除 - 这是 **token 复用行为**,属于安全风险 - 吊销该用户的所有 refresh_token(`DeleteUserRefreshTokens`) - 返回 `ErrRefreshTokenUsed` 5. 验证 token 归属的用户与 claims 一致 6. 删除旧的 refresh_token 哈希(`DeleteRefreshToken`) 7. 生成新的 access_token + refresh_token(`GeneratePair`) 8. 保存新的 refresh_token 哈希到数据库(`SaveRefreshToken`) 9. 查询用户信息(`FindByID`) 10. 返回 `AuthResponse` **安全机制**: - **Token 轮转**:每次 refresh 都会生成新的 token pair,旧 refresh_token 立即失效 - **复用检测**:如果检测到已删除的 refresh_token 被复用,立即吊销该用户的所有 refresh_token - **强制重新登录**:吊销后,该用户所有设备都需要重新登录 #### 登出流程(Logout) ```go func (s *authService) Logout(ctx context.Context, userID, refreshToken string) error ``` **流程**: 1. 计算 refresh_token 的 SHA256 哈希(`HashToken`) 2. 从数据库删除该哈希(`DeleteRefreshToken`) ### 4. Gin 中间件(AuthMiddleware) **文件位置**:`backend/internal/auth/middleware.go` ```go func AuthMiddleware(tokenMgr *TokenManager) gin.HandlerFunc ``` **功能**: 1. 从请求头提取 `Authorization: Bearer ` 2. 验证 access_token(`ValidateAccess`) 3. 如果验证失败,返回 401 Unauthorized 4. 如果验证成功,将 `user_id` 和 `username` 写入 Gin Context 5. 调用 `c.Next()` 继续处理请求 **错误响应**: ```json { "code": "INVALID_TOKEN", "message": "missing authorization header" } ``` ```json { "code": "INVALID_TOKEN", "message": "invalid authorization format" } ``` ```json { "code": "INVALID_TOKEN", "message": "invalid or expired token" } ``` **Context Key**: - `ContextKeyUserID = "user_id"` - `ContextKeyUsername = "username"` **使用示例**: ```go // 在路由中使用中间件 authorized := r.Group("/api") authorized.Use(auth.AuthMiddleware(tokenMgr)) { authorized.GET("/conversations", handler.ListConversations) authorized.POST("/conversations", handler.CreateConversation) } ``` ## 数据模型 ### 用户表(users) ```sql CREATE TABLE users ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), username VARCHAR(64) UNIQUE NOT NULL, password_hash VARCHAR(255) NOT NULL, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); ``` ### Refresh Token 表(refresh_tokens) ```sql CREATE TABLE refresh_tokens ( token_hash VARCHAR(64) PRIMARY KEY, -- SHA256 哈希 user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, expires_at TIMESTAMP WITH TIME ZONE NOT NULL, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens(user_id); CREATE INDEX idx_refresh_tokens_expires_at ON refresh_tokens(expires_at); ``` ## Repository 接口 ### UserRepository ```go type UserRepository interface { // Create 创建用户,返回用户 ID Create(ctx context.Context, username, passwordHash string) (string, error) // FindByUsername 根据用户名查找用户 FindByUsername(ctx context.Context, username string) (*User, error) // FindByID 根据 ID 查找用户 FindByID(ctx context.Context, id string) (*User, error) // SaveRefreshToken 保存 refresh_token 哈希 SaveRefreshToken(ctx context.Context, userID, tokenHash string, expiresAt time.Time) error // FindRefreshToken 根据 token 哈希查找用户 ID FindRefreshToken(ctx context.Context, tokenHash string) (string, error) // DeleteRefreshToken 删除指定的 refresh_token DeleteRefreshToken(ctx context.Context, tokenHash string) error // DeleteUserRefreshTokens 删除用户的所有 refresh_token(用于检测复用时吊销) DeleteUserRefreshTokens(ctx context.Context, userID string) error } ``` ## 前端集成 ### Token 存储 **推荐方案**: - `access_token`:存储在内存中(JavaScript 变量) - `refresh_token`:存储在 `httpOnly` Cookie 中(防止 XSS 攻击) **备选方案**(开发环境): - 两者都存储在 `localStorage`(便于调试,但存在 XSS 风险) ### 请求拦截器 ```typescript // axios 请求拦截器 api.interceptors.request.use((config) => { const accessToken = getAccessToken(); if (accessToken) { config.headers.Authorization = `Bearer ${accessToken}`; } return config; }); // axios 响应拦截器 api.interceptors.response.use( (response) => response, async (error) => { const originalRequest = error.config; // 如果是 401 且不是 refresh 请求,尝试刷新 token if (error.response?.status === 401 && !originalRequest._retry) { originalRequest._retry = true; try { const refreshToken = getRefreshToken(); const response = await api.post('/api/auth/refresh', { refresh_token: refreshToken, }); const { access_token, refresh_token } = response.data; setAccessToken(access_token); setRefreshToken(refresh_token); // 重试原始请求 originalRequest.headers.Authorization = `Bearer ${access_token}`; return api(originalRequest); } catch (refreshError) { // 刷新失败,跳转登录页 clearTokens(); window.location.href = '/login'; return Promise.reject(refreshError); } } return Promise.reject(error); } ); ``` ### WebSocket 认证 ```typescript // 建立 WebSocket 连接时传递 access_token const wsUrl = `ws://${window.location.host}/ws?token=${accessToken}&conversation_id=${conversationId}`; const ws = new WebSocket(wsUrl); // 连接失败时(401),触发 token 刷新 ws.onerror = (error) => { console.error('WebSocket connection failed'); // 可能需要刷新 token 后重连 }; ``` ## 安全考虑 ### 1. 密码安全 - **bcrypt 算法**:使用 bcrypt 进行密码哈希,cost factor 为 10 - **盐值自动生成**:bcrypt 自动生成随机盐值,无需手动管理 - **防彩虹表**:每个密码的哈希值都不同,即使密码相同 ### 2. Token 安全 - **短期 access_token**:15 分钟有效期,降低泄露风险 - **Refresh Token Rotation**:每次 refresh 都生成新 token,旧 token 立即失效 - **复用检测**:检测到已删除的 refresh_token 被复用时,吊销该用户的所有 token - **SHA256 哈希存储**:数据库只存储 refresh_token 的哈希值,不存储原始 token ### 3. 传输安全 - **HTTPS 强制**:生产环境必须使用 HTTPS - **同源反代**:通过 Nginx 反向代理(生产)或 Vite proxy(开发)统一前后端到同一域名,浏览器层面无跨域问题 - **HttpOnly Cookie**:refresh_token 存储在 httpOnly Cookie 中,防止 XSS 攻击 ### 4. 防攻击策略 - **防暴力破解**:可选的速率限制(`RATE_LIMITED` 错误码) - **防枚举攻击**:登录失败时统一返回 `INVALID_CREDENTIALS`,不区分用户名不存在还是密码错误 - **防重放攻击**:Refresh Token Rotation 确保每个 refresh_token 只能使用一次 - **防 Token 泄露**:检测到 token 复用时,立即吊销该用户的所有 token ## 配置说明 ### 配置文件 ```yaml auth: jwt_secret: "" # JWT 签名密钥(必须通过环境变量设置) access_ttl: 15 # access_token 有效期(分钟) refresh_ttl: 10080 # refresh_token 有效期(分钟,7天) ``` ### 环境变量 | 环境变量 | 说明 | 示例 | |---------|------|------| | `CAMTALK_AUTH_JWT_SECRET` | JWT 签名密钥(必须) | `$(openssl rand -hex 32)` | | `CAMTALK_AUTH_ACCESS_TTL` | access_token 有效期(分钟) | `15` | | `CAMTALK_AUTH_REFRESH_TTL` | refresh_token 有效期(分钟) | `10080` | **安全要求**: - `JWT_SECRET` **必须**通过环境变量设置,不能写入配置文件 - 生产环境使用 `openssl rand -hex 32` 生成随机密钥 - 密钥长度建议至少 32 字节(256 位) ## 错误码 | 错误码 | HTTP 状态码 | 含义 | 客户端处理 | |--------|-----------|------|-----------| | `USERNAME_TAKEN` | 409 | 用户名已存在 | 提示换一个用户名 | | `INVALID_CREDENTIALS` | 401 | 用户名或密码错误 | 提示检查输入 | | `INVALID_TOKEN` | 401 | JWT 无效或已过期 | 尝试 refresh,失败则重新登录 | ## 测试用例 ### 单元测试 **文件位置**:`backend/internal/auth/jwt_test.go`, `backend/internal/auth/service_test.go` **测试覆盖**: - Token 生成和验证 - Token 过期处理 - Refresh Token Rotation - Token 复用检测和吊销 - 密码哈希和验证 - 边界条件和错误处理 ### 集成测试 **测试场景**: - 注册 → 登录 → 访问受保护资源 - Token 刷新流程 - Token 过期后自动刷新 - 并发刷新 token(竞态条件) - Token 复用检测和吊销 ## 监控指标 ### 关键指标 - **登录成功率**:登录成功次数 / 登录总次数 - **Token 刷新率**:refresh 请求次数 / 总请求数 - **Token 复用检测**:检测到 token 复用的次数(安全事件) - **认证延迟**:JWT 验证的平均耗时 ### 告警规则 - **Token 复用检测**:任何 token 复用事件都应触发告警 - **异常登录失败率**:短时间内大量登录失败可能表示暴力破解攻击 - **Token 刷新失败率**:refresh 失败率突然上升可能表示系统问题 ## 扩展点 ### 1. 多设备管理 当前实现支持同一用户在多个设备上登录(每个设备独立的 refresh_token)。可以扩展为: - 设备列表管理 - 单设备登录(踢出其他设备) - 设备信任等级 ### 2. OAuth 第三方登录 可以扩展 AuthService 支持 OAuth 2.0: - Google、GitHub 等第三方登录 - 绑定/解绑第三方账号 - 统一的用户身份管理 ### 3. 双因素认证(2FA) 可以扩展为: - TOTP(基于时间的一次性密码) - SMS 验证码 - 邮箱验证 ### 4. 会话管理 可以扩展为: - 活跃会话列表 - 远程登出其他会话 - 会话过期策略 ## 实际实现要点 ### 1. 模块结构 **后端核心文件**: ``` backend/internal/auth/ ├── jwt.go — TokenManager: JWT 生成/验证,Token 哈希 ├── password.go — bcrypt 密码哈希/验证 ├── service.go — AuthService: 注册/登录/刷新/登出业务逻辑 └── middleware.go — AuthMiddleware: Gin 中间件,提取并验证 access_token backend/internal/store/ ├── user.go — UserRepository 接口定义 ├── user_pg.go — PgUserRepository: PostgreSQL 实现 ├── user_mem.go — MemUserRepository: 内存实现(测试用) └── cached_user.go — CachedUserRepository: Redis 缓存装饰器 ``` **前端核心文件**: ``` frontend/src/lib/ ├── auth.tsx — AuthProvider: 认证状态管理 + 自动刷新 ├── api.ts — HTTP 客户端 + 401 拦截器 + 重试机制 └── storage.ts — localStorage 封装(token + 用户信息持久化) ``` ### 2. JWT 生成与验证实现 **TokenManager 初始化**(`backend/cmd/server/main.go`): ```go tokenMgr := auth.NewTokenManager( cfg.Auth.JWTSecret, // 从环境变量读取 time.Duration(cfg.Auth.AccessTTL) * time.Minute, // 默认 120 分钟 time.Duration(cfg.Auth.RefreshTTL) * time.Minute, // 默认 10080 分钟(7天) ) ``` **Token 生成逻辑**(`backend/internal/auth/jwt.go:49-86`): - **access_token**: - Claims: `UserID`, `Username`, `TokenType="access"`, `ExpiresAt`, `IssuedAt`, `Issuer="camtalk"` - 签名算法:`jwt.SigningMethodHS256` - 有效期:从配置读取(默认 120 分钟) - **refresh_token**: - Claims: 同 access_token + `ID=uuid.New().String()`(用于 DB 关联) - `TokenType="refresh"` - 有效期:从配置读取(默认 10080 分钟) **Token 验证逻辑**(`backend/internal/auth/jwt.go:113-128`): 1. 使用 `jwt.ParseWithClaims` 解析 token 2. 验证签名方法为 HMAC 3. 验证签名是否有效(使用 secret) 4. 验证 token 是否过期(自动检查 `ExpiresAt`) 5. 验证 `TokenType` 是否匹配(access 或 refresh) **Token 哈希**(`backend/internal/auth/jwt.go:130-134`): ```go func HashToken(token string) string { h := sha256.Sum256([]byte(token)) return hex.EncodeToString(h[:]) } ``` 用于将 refresh_token 哈希后存入数据库,避免明文存储。 ### 3. Refresh Token Rotation 实现 **核心流程**(`backend/internal/auth/service.go:156-211`): ```go func (s *authService) Refresh(ctx context.Context, req RefreshRequest) (*AuthResponse, error) { // 1. 验证 JWT 签名和有效期 claims, err := s.tokenMgr.ValidateRefresh(req.RefreshToken) if err != nil { return nil, ErrRefreshTokenUsed } // 2. 计算 token 的 SHA256 哈希 tokenHash := HashToken(req.RefreshToken) // 3. 在 DB 中查找该 hash userID, err := s.userRepo.FindRefreshToken(ctx, tokenHash) if err != nil { if errors.Is(err, store.ErrRefreshTokenNotFound) { // 复用检测:JWT 有效但 DB 中不存在 → 已被 rotation 删除 // 吊销该用户的所有 refresh token _ = s.userRepo.DeleteUserRefreshTokens(ctx, claims.UserID) return nil, ErrRefreshTokenUsed } return nil, err } // 4. 验证 user_id 一致性 if userID != claims.UserID { return nil, ErrRefreshTokenUsed } // 5. 删除旧 refresh token(rotation) _ = s.userRepo.DeleteRefreshToken(ctx, tokenHash) // 6. 生成新的 token pair access, refresh, err := s.tokenMgr.GeneratePair(claims.UserID, claims.Username) if err != nil { return nil, err } // 7. 保存新 refresh token if err := s.saveRefreshToken(ctx, claims.UserID, refresh); err != nil { return nil, err } // 8. 返回新 token return &AuthResponse{...}, nil } ``` **安全机制**: - 每次刷新都删除旧 token(第 5 步) - 如果检测到已删除的 token 被复用(第 3 步),立即吊销该用户的所有 refresh token - 强制所有设备重新登录 ### 4. PostgreSQL Repository 实现 **PgUserRepository**(`backend/internal/store/user_pg.go`): 使用 `pgx/v5` 作为 PostgreSQL 驱动,连接池为 `*pgxpool.Pool`。 **关键实现**: ```go // 保存 refresh token(INSERT) func (r *PgUserRepository) SaveRefreshToken(ctx context.Context, userID, tokenHash string, expiresAt time.Time) error { _, err := r.pool.Exec(ctx, `INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)`, userID, tokenHash, expiresAt, ) return err } // 查找 refresh token(SELECT + 过期时间校验) func (r *PgUserRepository) FindRefreshToken(ctx context.Context, tokenHash string) (string, error) { var userID string err := r.pool.QueryRow(ctx, `SELECT user_id FROM refresh_tokens WHERE token_hash = $1 AND expires_at > NOW()`, tokenHash, ).Scan(&userID) if errors.Is(err, pgx.ErrNoRows) { return "", ErrRefreshTokenNotFound } return userID, err } // 删除单个 refresh token(DELETE) func (r *PgUserRepository) DeleteRefreshToken(ctx context.Context, tokenHash string) error { _, err := r.pool.Exec(ctx, `DELETE FROM refresh_tokens WHERE token_hash = $1`, tokenHash, ) return err } // 删除用户的所有 refresh token(批量 DELETE,用于吊销) func (r *PgUserRepository) DeleteUserRefreshTokens(ctx context.Context, userID string) error { _, err := r.pool.Exec(ctx, `DELETE FROM refresh_tokens WHERE user_id = $1`, userID, ) return err } ``` **错误处理**: - `pgx.ErrNoRows` → 转换为业务错误 `ErrUserNotFound` / `ErrRefreshTokenNotFound` - 其他错误透传 ### 5. Redis 缓存装饰器实现 **CachedUserRepository**(`backend/internal/store/cached_user.go`): 采用装饰器模式,为 `UserRepository` 的 refresh token 操作增加 Redis 缓存层。 **缓存策略**: ```go // Redis Key 设计 const ( refreshTokenPrefix = "auth:refresh:" // auth:refresh:{token_hash} → user_id userRefreshPrefix = "auth:user_refresh:" // auth:user_refresh:{user_id} → Set ) ``` **写路径(Write-Through)**: ```go func (r *CachedUserRepository) SaveRefreshToken(ctx, userID, tokenHash, expiresAt) error { // 1. 先写 DB if err := r.inner.SaveRefreshToken(ctx, userID, tokenHash, expiresAt); err != nil { return err } // 2. 写 Redis(SET + SADD),TTL 为 token 剩余有效期 ttl := time.Until(expiresAt) pipe := r.rdb.Pipeline() pipe.Set(ctx, "auth:refresh:"+tokenHash, userID, ttl) pipe.SAdd(ctx, "auth:user_refresh:"+userID, tokenHash) _, _ = pipe.Exec(ctx) // Redis 失败不影响正确性 return nil } ``` **读路径(Read-Through)**: ```go func (r *CachedUserRepository) FindRefreshToken(ctx, tokenHash) (string, error) { // 1. 先查 Redis userID, err := r.rdb.Get(ctx, "auth:refresh:"+tokenHash).Result() if err == nil { return userID, nil // 缓存命中 } // 2. Redis miss,降级到 DB userID, err = r.inner.FindRefreshToken(ctx, tokenHash) if err != nil { return "", err } // 3. 异步回填 Redis go func() { pipe := r.rdb.Pipeline() pipe.Set(bgCtx, key, userID, r.backfillTTL) pipe.SAdd(bgCtx, "auth:user_refresh:"+userID, tokenHash) _, _ = pipe.Exec(bgCtx) }() return userID, nil } ``` **删除路径(双删)**: ```go func (r *CachedUserRepository) DeleteRefreshToken(ctx, tokenHash) error { // 1. 先从 Redis 获取 user_id userID, _ := r.rdb.Get(ctx, "auth:refresh:"+tokenHash).Result() // 2. 删 DB if err := r.inner.DeleteRefreshToken(ctx, tokenHash); err != nil { return err } // 3. 删 Redis(DEL + SREM) pipe := r.rdb.Pipeline() pipe.Del(ctx, "auth:refresh:"+tokenHash) if userID != "" { pipe.SRem(ctx, "auth:user_refresh:"+userID, tokenHash) } _, _ = pipe.Exec(ctx) return nil } ``` **降级策略**: - Redis 操作失败时使用 `trace.FromContext(ctx)` 记录 Warn 日志(带 trace_id),但不阻断主流程 - DB 是唯一真实数据源,Redis 仅用于加速 - 详见 `docs/13-日志追踪.md` — 存储层日志实现 ### 6. Gin 中间件实现 **AuthMiddleware**(`backend/internal/auth/middleware.go:18-54`): ```go func AuthMiddleware(tokenMgr *TokenManager) gin.HandlerFunc { return func(c *gin.Context) { // 1. 提取 Authorization header authHeader := c.GetHeader("Authorization") if authHeader == "" { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ "code": "INVALID_TOKEN", "message": "missing authorization header", }) return } // 2. 解析 Bearer token parts := strings.SplitN(authHeader, " ", 2) if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ "code": "INVALID_TOKEN", "message": "invalid authorization format", }) return } // 3. 验证 access token claims, err := tokenMgr.ValidateAccess(parts[1]) if err != nil { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ "code": "INVALID_TOKEN", "message": "invalid or expired token", }) return } // 4. 将用户信息写入 Gin Context c.Set("user_id", claims.UserID) c.Set("username", claims.Username) c.Next() } } ``` **使用方式**(在路由注册时): ```go authorized := r.Group("/api") authorized.Use(auth.AuthMiddleware(tokenMgr)) { authorized.GET("/conversations", handler.ListConversations) authorized.POST("/conversations", handler.CreateConversation) } ``` **获取用户信息**(在 handler 中): ```go func (h *Handler) ListConversations(c *gin.Context) { userID := c.GetString("user_id") // 从 context 获取 username := c.GetString("username") // ... } ``` ### 7. 前端集成实现 **AuthProvider**(`frontend/src/lib/auth.tsx`): 采用 React Context + 自动 token 刷新机制。 **初始化流程**(`useEffect`): ```typescript useEffect(() => { const init = async () => { const storedAccess = loadAccessToken(); const storedRefresh = loadRefreshToken(); const storedUser = loadUser(); if (!storedAccess || !storedRefresh || !storedUser) { setIsLoading(false); return; } // 检查 access token 是否过期 const payload = parseJwtPayload(storedAccess); const nowSec = Math.floor(Date.now() / 1000); if (payload?.exp && payload.exp > nowSec) { // access token 仍然有效 setUser(storedUser); setAccessToken(storedAccess); scheduleRefresh(storedAccess); // 安排自动刷新 } else { // access token 过期,尝试 refresh const res = await api.refreshToken(storedRefresh); if (res.data) { persistAuth(res.data.user, res.data.access_token, res.data.refresh_token); scheduleRefresh(res.data.access_token); } else { clearAuth(); } } setIsLoading(false); }; init(); }, []); ``` **自动刷新机制**(`scheduleRefresh`): ```typescript const scheduleRefresh = useCallback((access: string) => { clearRefreshTimer(); const payload = parseJwtPayload(access); if (!payload?.exp) return; const nowSec = Math.floor(Date.now() / 1000); // 提前 60 秒刷新(REFRESH_BUFFER_SEC) const delayMs = Math.max((payload.exp - nowSec - 60) * 1000, 5000); refreshTimerRef.current = setTimeout(async () => { const rt = loadRefreshToken(); if (!rt) return; const res = await api.refreshToken(rt); if (res.data) { persistAuth(res.data.user, res.data.access_token, res.data.refresh_token); scheduleRefresh(res.data.access_token); // 递归安排下次刷新 } else { clearAuth(); setUser(null); setAccessToken(null); } }, delayMs); }, [clearRefreshTimer, persistAuth]); ``` **401 拦截器**(`frontend/src/lib/api.ts:104-109`): ```typescript // 在 request 函数中 if (res.status === 401 && !_retry && !isPublicPath(path) && authCallbacks) { const refreshed = await refreshWithLock(); // 并发保护 if (refreshed) { return request(path, options, true); // 重试一次 } } ``` **并发刷新保护**(`refreshWithLock`): ```typescript let refreshPromise: Promise | null = null; async function refreshWithLock(): Promise { if (!refreshPromise) { refreshPromise = doRefresh().finally(() => { refreshPromise = null; }); } return refreshPromise; // 多个 401 共享同一个 refresh Promise } ``` **Token 存储**(`frontend/src/lib/storage.ts`): 当前实现使用 `localStorage` 存储 token(开发环境方便调试): ```typescript const ACCESS_TOKEN_KEY = "camtalk:access_token"; const REFRESH_TOKEN_KEY = "camtalk:refresh_token"; const USER_KEY = "camtalk:user"; export function saveAccessToken(token: string): void { localStorage.setItem(ACCESS_TOKEN_KEY, token); } export function loadAccessToken(): string | null { return localStorage.getItem(ACCESS_TOKEN_KEY); } // refresh token 和 user 信息同理 ``` **安全建议**:生产环境应改用 `httpOnly` Cookie 存储 refresh token,防止 XSS 攻击。 ### 8. 配置示例 **环境变量**(`backend/.env.example`): ```bash # 运行环境 APP_ENV=dev # dev / prod # JWT 认证(必须) CAMTALK_AUTH_JWT_SECRET=your-jwt-secret-here # 建议使用 openssl rand -hex 32 生成 # PostgreSQL(必须) CAMTALK_STORAGE_DSN=postgres://camtalk:password@localhost:5432/camtalk?sslmode=disable # Redis(可选,启用缓存时必须) CAMTALK_STORAGE_REDIS_ENABLED=true CAMTALK_REDIS_ADDR=localhost:6379 CAMTALK_REDIS_PASSWORD=your-redis-password # AI 服务 API Key(必须) CAMTALK_AI_STT_API_KEY=sk-your-stt-key CAMTALK_AI_LLM_API_KEY=sk-your-llm-key CAMTALK_AI_TTS_API_KEY=sk-your-tts-key ``` **配置文件**(`backend/config/config.yaml`): ```yaml auth: # jwt_secret 通过环境变量 CAMTALK_AUTH_JWT_SECRET 设置 access_ttl: 120 # Access Token 过期时间(分钟) refresh_ttl: 10080 # Refresh Token 过期时间(分钟),7 天 ``` **生产环境配置**(`backend/config/config.prod.yaml`): ```yaml ratelimit: enabled: true # 生产环境启用限流 login: capacity: 5 # 突发容量:允许连续 5 次登录尝试 rate: 0.1 # 填充速率:每 10 秒补充 1 次 ``` ### 9. 启动流程 **后端初始化**(`backend/cmd/server/main.go` 简化版): ```go // 1. 加载配置 cfg := loadConfig() // 2. 初始化 DB 连接池 pgPool := connectPostgreSQL(cfg.Storage.DSN) // 3. 创建 Repository pgUserRepo := store.NewPgUserRepository(pgPool) // 4. 如果启用 Redis,包装为缓存装饰器 var userRepo store.UserRepository = pgUserRepo if cfg.Storage.Redis.Enabled { rdb := redis.NewClient(&redis.Options{...}) userRepo = store.NewCachedUserRepository(pgUserRepo, rdb, 24*time.Hour) } // 5. 创建 TokenManager tokenMgr := auth.NewTokenManager( cfg.Auth.JWTSecret, time.Duration(cfg.Auth.AccessTTL) * time.Minute, time.Duration(cfg.Auth.RefreshTTL) * time.Minute, ) // 6. 创建 AuthService authSvc := auth.NewAuthService(tokenMgr, userRepo) // 7. 注册路由 r := gin.New() authHandler := handler.NewAuthHandler(authSvc) r.POST("/api/auth/register", authHandler.Register) r.POST("/api/auth/login", authHandler.Login) r.POST("/api/auth/refresh", authHandler.Refresh) authorized := r.Group("/api") authorized.Use(auth.AuthMiddleware(tokenMgr)) { authorized.POST("/api/auth/logout", authHandler.Logout) authorized.GET("/api/conversations", ...) } ``` **前端初始化**(`frontend/src/main.tsx`): ```tsx import { AuthProvider } from './lib/auth'; ReactDOM.createRoot(document.getElementById('root')!).render( ); ``` ## 参考资料 - [JWT 规范](https://tools.ietf.org/html/rfc7519) - [bcrypt 算法](https://en.wikipedia.org/wiki/Bcrypt) - [OWASP 认证备忘录](https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html) - [Refresh Token Rotation](https://auth0.com/blog/refresh-tokens-what-are-they-and-when-to-use-them/)