16 KiB
16 KiB
鉴权体系设计
概述
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 tokenRegisteredClaims:JWT 标准声明(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)
生成逻辑:
-
access_token:
- 签名算法:HS256
- 有效期:15 分钟
- 包含:UserID, Username, TokenType="access", ExpiresAt, IssuedAt, Issuer="camtalk"
-
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)
验证逻辑:
- 解析 JWT,验证签名算法为 HMAC
- 验证签名是否有效
- 验证令牌是否过期
- 验证 TokenType 是否匹配(access 或 refresh)
- 返回 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
- Cost:10(2^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)
流程:
- 检查用户名是否已存在(
FindByUsername) - 如果存在,返回
ErrUsernameTaken - 使用 bcrypt 哈希密码(
HashPassword) - 创建用户记录(
Create) - 生成 access_token + refresh_token(
GeneratePair) - 保存 refresh_token 的 SHA256 哈希到数据库(
SaveRefreshToken) - 返回
AuthResponse
错误处理:
ErrUsernameTaken:用户名已存在- 数据库错误:透传底层错误
登录流程(Login)
func (s *authService) Login(ctx context.Context, req LoginRequest) (*AuthResponse, error)
流程:
- 根据用户名查找用户(
FindByUsername) - 如果用户不存在,返回
ErrInvalidCredentials - 验证密码(
CheckPassword) - 如果密码错误,返回
ErrInvalidCredentials - 生成 access_token + refresh_token(
GeneratePair) - 保存 refresh_token 的 SHA256 哈希到数据库(
SaveRefreshToken) - 返回
AuthResponse
错误处理:
ErrInvalidCredentials:用户名或密码错误(统一错误信息,防止枚举攻击)
刷新令牌流程(Refresh)— Refresh Token Rotation
func (s *authService) Refresh(ctx context.Context, req RefreshRequest) (*AuthResponse, error)
流程:
- 验证 refresh_token 的签名和有效期(
ValidateRefresh) - 计算 refresh_token 的 SHA256 哈希(
HashToken) - 在数据库中查找该哈希(
FindRefreshToken) - 如果哈希不存在:
- JWT 校验已通过但 DB 中不存在 → token 已被 rotation 删除
- 这是 token 复用行为,属于安全风险
- 吊销该用户的所有 refresh_token(
DeleteUserRefreshTokens) - 返回
ErrRefreshTokenUsed
- 验证 token 归属的用户与 claims 一致
- 删除旧的 refresh_token 哈希(
DeleteRefreshToken) - 生成新的 access_token + refresh_token(
GeneratePair) - 保存新的 refresh_token 哈希到数据库(
SaveRefreshToken) - 查询用户信息(
FindByID) - 返回
AuthResponse
安全机制:
- Token 轮转:每次 refresh 都会生成新的 token pair,旧 refresh_token 立即失效
- 复用检测:如果检测到已删除的 refresh_token 被复用,立即吊销该用户的所有 refresh_token
- 强制重新登录:吊销后,该用户所有设备都需要重新登录
登出流程(Logout)
func (s *authService) Logout(ctx context.Context, userID, refreshToken string) error
流程:
- 计算 refresh_token 的 SHA256 哈希(
HashToken) - 从数据库删除该哈希(
DeleteRefreshToken)
4. Gin 中间件(AuthMiddleware)
文件位置:backend/internal/auth/middleware.go
func AuthMiddleware(tokenMgr *TokenManager) gin.HandlerFunc
功能:
- 从请求头提取
Authorization: Bearer <token> - 验证 access_token(
ValidateAccess) - 如果验证失败,返回 401 Unauthorized
- 如果验证成功,将
user_id和username写入 Gin Context - 调用
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:存储在httpOnlyCookie 中(防止 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_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
配置说明
配置文件
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. 会话管理
可以扩展为:
- 活跃会话列表
- 远程登出其他会话
- 会话过期策略