- 更新限流文档:limiter 内部使用 trace.FromContext 自动记录日志 - 更新鉴权文档:Redis 降级策略使用 trace-aware 日志 - 引用 13-日志追踪.md 作为详细说明 - 移除过时的手动 logger.Log 调用示例
1112 lines
32 KiB
Markdown
1112 lines
32 KiB
Markdown
# 鉴权体系设计
|
||
|
||
## 概述
|
||
|
||
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/)
|