Files
CamTalk/docs/12-鉴权体系设计.md
hhs 4ff8cec312 docs: 添加鉴权体系设计文档,更新认证相关文档
- 新增 12-鉴权体系设计.md,详细描述 JWT 双 token 轮转认证机制
- 更新架构设计文档,补充认证设计章节的安全机制和配置说明
- 更新接口文档,补充 Refresh Token Rotation 安全机制和前端集成示例
- 更新文档索引,添加新文档的推荐阅读顺序
2026-06-20 16:51:03 +08:00

16 KiB
Raw Blame History

鉴权体系设计

概述

CamTalk 采用 JWT 双 token 轮转认证机制,结合 bcrypt 密码哈希和 Refresh Token Rotation 安全策略,实现安全可靠的用户认证体系。

设计原则

  • 安全性access_token 短有效期15 分钟refresh_token 支持轮转防重放
  • 可靠性Refresh Token Rotation 机制,检测复用时自动吊销用户所有令牌
  • 可扩展性Repository 接口隔离存储层,支持内存和 PostgreSQL 双实现

整体架构

graph TB
    subgraph Client["客户端"]
        Browser["浏览器"]
    end

    subgraph AuthModule["Auth 模块"]
        Service["AuthService<br/>Register / Login / Refresh / Logout"]
        TokenMgr["TokenManager<br/>JWT 生成与验证"]
        Middleware["AuthMiddleware<br/>Gin 中间件"]
        Password["PasswordUtil<br/>bcrypt 哈希"]
    end

    subgraph Storage["存储层"]
        UserRepo["UserRepository<br/>用户数据"]
        TokenStore["RefreshToken 存储<br/>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 结构

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
  • RegisteredClaimsJWT 标准声明ExpiresAt, IssuedAt, Issuer, ID

TokenManager 配置

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

令牌生成

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"

令牌验证

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 哈希

func HashToken(token string) string

用途:对 refresh_token 做 SHA256 哈希后存储到数据库,避免直接存储原始 token。

2. 密码处理PasswordUtil

文件位置backend/internal/auth/password.go

密码哈希

func HashPassword(password string) (string, error)

实现

  • 算法bcrypt
  • Cost102^10 次迭代)
  • 返回base64 编码的哈希字符串

密码验证

func CheckPassword(hashedPassword, password string) error

实现

  • 使用 bcrypt.CompareHashAndPassword 验证
  • 返回 nil 表示匹配,否则返回错误

3. 认证服务AuthService

文件位置backend/internal/auth/service.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

func (s *authService) Register(ctx context.Context, req RegisterRequest) (*AuthResponse, error)

流程

  1. 检查用户名是否已存在(FindByUsername
  2. 如果存在,返回 ErrUsernameTaken
  3. 使用 bcrypt 哈希密码(HashPassword
  4. 创建用户记录(Create
  5. 生成 access_token + refresh_tokenGeneratePair
  6. 保存 refresh_token 的 SHA256 哈希到数据库(SaveRefreshToken
  7. 返回 AuthResponse

错误处理

  • ErrUsernameTaken:用户名已存在
  • 数据库错误:透传底层错误

登录流程Login

func (s *authService) Login(ctx context.Context, req LoginRequest) (*AuthResponse, error)

流程

  1. 根据用户名查找用户(FindByUsername
  2. 如果用户不存在,返回 ErrInvalidCredentials
  3. 验证密码(CheckPassword
  4. 如果密码错误,返回 ErrInvalidCredentials
  5. 生成 access_token + refresh_tokenGeneratePair
  6. 保存 refresh_token 的 SHA256 哈希到数据库(SaveRefreshToken
  7. 返回 AuthResponse

错误处理

  • ErrInvalidCredentials:用户名或密码错误(统一错误信息,防止枚举攻击)

刷新令牌流程Refresh— Refresh Token Rotation

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_tokenDeleteUserRefreshTokens
    • 返回 ErrRefreshTokenUsed
  5. 验证 token 归属的用户与 claims 一致
  6. 删除旧的 refresh_token 哈希(DeleteRefreshToken
  7. 生成新的 access_token + refresh_tokenGeneratePair
  8. 保存新的 refresh_token 哈希到数据库(SaveRefreshToken
  9. 查询用户信息(FindByID
  10. 返回 AuthResponse

安全机制

  • Token 轮转:每次 refresh 都会生成新的 token pair旧 refresh_token 立即失效
  • 复用检测:如果检测到已删除的 refresh_token 被复用,立即吊销该用户的所有 refresh_token
  • 强制重新登录:吊销后,该用户所有设备都需要重新登录

登出流程Logout

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

func AuthMiddleware(tokenMgr *TokenManager) gin.HandlerFunc

功能

  1. 从请求头提取 Authorization: Bearer <token>
  2. 验证 access_tokenValidateAccess
  3. 如果验证失败,返回 401 Unauthorized
  4. 如果验证成功,将 user_idusername 写入 Gin Context
  5. 调用 c.Next() 继续处理请求

错误响应

{
  "code": "INVALID_TOKEN",
  "message": "missing authorization header"
}
{
  "code": "INVALID_TOKEN",
  "message": "invalid authorization format"
}
{
  "code": "INVALID_TOKEN",
  "message": "invalid or expired token"
}

Context Key

  • ContextKeyUserID = "user_id"
  • ContextKeyUsername = "username"

使用示例

// 在路由中使用中间件
authorized := r.Group("/api")
authorized.Use(auth.AuthMiddleware(tokenMgr))
{
    authorized.GET("/conversations", handler.ListConversations)
    authorized.POST("/conversations", handler.CreateConversation)
}

数据模型

用户表users

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

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

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 风险)

请求拦截器

// 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 认证

// 建立 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_token15 分钟有效期,降低泄露风险
  • Refresh Token Rotation:每次 refresh 都生成新 token旧 token 立即失效
  • 复用检测:检测到已删除的 refresh_token 被复用时,吊销该用户的所有 token
  • SHA256 哈希存储:数据库只存储 refresh_token 的哈希值,不存储原始 token

3. 传输安全

  • HTTPS 强制:生产环境必须使用 HTTPS
  • CORS 限制:配置 AllowedOrigins 限制允许的域名
  • HttpOnly Cookierefresh_token 存储在 httpOnly Cookie 中,防止 XSS 攻击

4. 防攻击策略

  • 防暴力破解:可选的速率限制(RATE_LIMITED 错误码)
  • 防枚举攻击:登录失败时统一返回 INVALID_CREDENTIALS,不区分用户名不存在还是密码错误
  • 防重放攻击Refresh Token Rotation 确保每个 refresh_token 只能使用一次
  • 防 Token 泄露:检测到 token 复用时,立即吊销该用户的所有 token

配置说明

配置文件

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. 会话管理

可以扩展为:

  • 活跃会话列表
  • 远程登出其他会话
  • 会话过期策略

参考资料