Files
CamTalk/docs/10-鉴权体系.md
hhs 239f8f9877 docs: 同步限流和鉴权文档的日志实现说明
- 更新限流文档:limiter 内部使用 trace.FromContext 自动记录日志
- 更新鉴权文档:Redis 降级策略使用 trace-aware 日志
- 引用 13-日志追踪.md 作为详细说明
- 移除过时的手动 logger.Log 调用示例
2026-06-21 23:19:11 +08:00

1112 lines
32 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 鉴权体系设计
## 概述
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
- Cost102^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 tokenrotation
_ = 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 tokenINSERT
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 tokenSELECT + 过期时间校验)
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 tokenDELETE
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. 写 RedisSET + SADDTTL 为 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. 删 RedisDEL + 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/)