# 鉴权体系设计

## 概述

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<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 结构

```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 <token>`
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<token_hash>
)
```

**写路径（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<T>(path, options, true);  // 重试一次
    }
}
```

**并发刷新保护**（`refreshWithLock`）：

```typescript
let refreshPromise: Promise<boolean> | null = null;

async function refreshWithLock(): Promise<boolean> {
    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(
  <React.StrictMode>
    <AuthProvider>
      <App />
    </AuthProvider>
  </React.StrictMode>
);
```

## 参考资料

- [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/)
