18 KiB
Design Document
Overview
本设计文档描述 Token 刷新机制和图形验证码两个功能的技术实现方案。这两个功能将增强后台管理系统的安全性和用户体验。
功能概述
- Token 刷新机制:采用双 Token 方案(Access Token + Refresh Token),实现无感刷新,避免用户频繁重新登录
- 图形验证码:在登录时要求输入验证码,防止暴力破解和机器人攻击
Architecture
整体架构
┌─────────────────────────────────────────────────────────────────┐
│ Frontend (Vue3) │
├─────────────────────────────────────────────────────────────────┤
│ Login Page │ Request Interceptor │ Token Manager │
│ - 验证码显示 │ - 自动刷新Token │ - 存储管理 │
│ - 验证码刷新 │ - 401处理 │ - 过期检测 │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Backend (ASP.NET Core) │
├─────────────────────────────────────────────────────────────────┤
│ AuthController │
│ - POST /captcha 获取验证码 │
│ - POST /login 登录(含验证码校验) │
│ - POST /refresh 刷新Token │
│ - POST /logout 登出(使RefreshToken失效) │
├─────────────────────────────────────────────────────────────────┤
│ Services │
│ - CaptchaService 验证码生成与校验 │
│ - AuthService 认证服务(扩展) │
│ - RefreshTokenService RefreshToken管理 │
├─────────────────────────────────────────────────────────────────┤
│ Data Storage │
│ - MemoryCache 验证码临时存储(5分钟过期) │
│ - Database RefreshToken持久化存储 │
└─────────────────────────────────────────────────────────────────┘
Token 刷新流程
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Client │ │ Server │ │ Database │
└────┬─────┘ └────┬─────┘ └────┬─────┘
│ │ │
│ 1. Login │ │
│───────────────>│ │
│ │ Save Refresh │
│ │───────────────>│
│ AccessToken │ │
│ RefreshToken │ │
│<───────────────│ │
│ │ │
│ 2. API Call │ │
│ (with AT) │ │
│───────────────>│ │
│ Response │ │
│<───────────────│ │
│ │ │
│ 3. AT Expired │ │
│ Refresh Token │ │
│───────────────>│ │
│ │ Validate RT │
│ │───────────────>│
│ │ Update RT │
│ │<───────────────│
│ New AT + RT │ │
│<───────────────│ │
│ │ │
│ 4. Logout │ │
│───────────────>│ │
│ │ Revoke RT │
│ │───────────────>│
│ Success │ │
│<───────────────│ │
Components and Interfaces
后端组件
1. RefreshToken 实体
// Entities/RefreshToken.cs
public class RefreshToken
{
public long Id { get; set; }
public long AdminUserId { get; set; }
public string TokenHash { get; set; } // Token哈希值(不存明文)
public DateTime ExpiresAt { get; set; } // 过期时间
public DateTime CreatedAt { get; set; } // 创建时间
public string CreatedByIp { get; set; } // 创建时的IP
public DateTime? RevokedAt { get; set; } // 撤销时间
public string? RevokedByIp { get; set; } // 撤销时的IP
public string? ReplacedByToken { get; set; } // 被替换的新Token哈希
public bool IsExpired => DateTime.Now >= ExpiresAt;
public bool IsRevoked => RevokedAt != null;
public bool IsActive => !IsRevoked && !IsExpired;
public AdminUser AdminUser { get; set; }
}
2. ICaptchaService 接口
// Services/ICaptchaService.cs
public interface ICaptchaService
{
/// <summary>
/// 生成验证码
/// </summary>
/// <returns>验证码Key和Base64图片</returns>
CaptchaResult Generate();
/// <summary>
/// 验证验证码
/// </summary>
/// <param name="captchaKey">验证码Key</param>
/// <param name="captchaCode">用户输入的验证码</param>
/// <returns>是否验证通过</returns>
bool Validate(string captchaKey, string captchaCode);
}
public class CaptchaResult
{
public string CaptchaKey { get; set; } // 验证码唯一标识
public string CaptchaImage { get; set; } // Base64编码的图片
}
3. IAuthService 接口扩展
// Services/IAuthService.cs (扩展)
public interface IAuthService
{
// 现有方法...
/// <summary>
/// 登录(含验证码校验)
/// </summary>
Task<LoginResponse> LoginAsync(LoginRequest request, string ipAddress);
/// <summary>
/// 刷新Token
/// </summary>
Task<RefreshTokenResponse> RefreshTokenAsync(string refreshToken, string ipAddress);
/// <summary>
/// 撤销RefreshToken(登出)
/// </summary>
Task RevokeTokenAsync(string refreshToken, string ipAddress);
/// <summary>
/// 撤销用户所有RefreshToken(强制下线)
/// </summary>
Task RevokeAllTokensAsync(long adminUserId, string ipAddress);
}
4. 请求/响应模型
// Models/Auth/LoginRequest.cs (扩展)
public class LoginRequest
{
public string Username { get; set; }
public string Password { get; set; }
public string CaptchaKey { get; set; } // 新增
public string CaptchaCode { get; set; } // 新增
}
// Models/Auth/LoginResponse.cs (扩展)
public class LoginResponse
{
public string AccessToken { get; set; } // 改名
public string RefreshToken { get; set; } // 新增
public int ExpiresIn { get; set; } // AccessToken过期秒数
public AdminUserInfo UserInfo { get; set; }
}
// Models/Auth/RefreshTokenRequest.cs (新增)
public class RefreshTokenRequest
{
public string RefreshToken { get; set; }
}
// Models/Auth/RefreshTokenResponse.cs (新增)
public class RefreshTokenResponse
{
public string AccessToken { get; set; }
public string RefreshToken { get; set; }
public int ExpiresIn { get; set; }
}
前端组件
1. API 接口扩展
// api/auth.ts (扩展)
// 验证码响应
export interface CaptchaResponse {
captchaKey: string
captchaImage: string // base64
}
// 登录请求(扩展)
export interface LoginRequest {
username: string
password: string
captchaKey: string
captchaCode: string
}
// 登录响应(扩展)
export interface LoginResponse {
accessToken: string
refreshToken: string
expiresIn: number
userInfo: UserInfo
}
// 获取验证码
export function getCaptcha(): Promise<ApiResponse<CaptchaResponse>>
// 刷新Token
export function refreshToken(refreshToken: string): Promise<ApiResponse<RefreshTokenResponse>>
2. Token 管理工具
// utils/auth.ts (扩展)
// 存储键名
const ACCESS_TOKEN_KEY = 'access_token'
const REFRESH_TOKEN_KEY = 'refresh_token'
const TOKEN_EXPIRES_KEY = 'token_expires'
// Access Token
export function getAccessToken(): string | null
export function setAccessToken(token: string): void
export function removeAccessToken(): void
// Refresh Token
export function getRefreshToken(): string | null
export function setRefreshToken(token: string): void
export function removeRefreshToken(): void
// Token 过期时间
export function getTokenExpires(): number | null
export function setTokenExpires(expiresIn: number): void
// 检查Token是否即将过期(5分钟内)
export function isTokenExpiringSoon(): boolean
// 清除所有Token
export function clearTokens(): void
3. 请求拦截器增强
// utils/request.ts (增强)
// 刷新Token的Promise(防止并发刷新)
let refreshPromise: Promise<any> | null = null
// 请求拦截器
service.interceptors.request.use(async (config) => {
// 跳过不需要Token的接口
if (config.url?.includes('/captcha') || config.url?.includes('/login')) {
return config
}
// 检查Token是否即将过期
if (isTokenExpiringSoon() && !refreshPromise) {
refreshPromise = doRefreshToken()
await refreshPromise
refreshPromise = null
}
const token = getAccessToken()
if (token) {
config.headers['Authorization'] = `Bearer ${token}`
}
return config
})
// 响应拦截器
service.interceptors.response.use(
(response) => { /* ... */ },
async (error) => {
// 401 时尝试刷新Token
if (error.response?.status === 401) {
const refreshToken = getRefreshToken()
if (refreshToken && !error.config._retry) {
error.config._retry = true
try {
await doRefreshToken()
return service(error.config)
} catch {
// 刷新失败,跳转登录
clearTokens()
router.push('/login')
}
}
}
return Promise.reject(error)
}
)
Data Models
数据库表结构
refresh_tokens 表
CREATE TABLE refresh_tokens (
Id BIGINT IDENTITY(1,1) PRIMARY KEY,
AdminUserId BIGINT NOT NULL,
TokenHash NVARCHAR(256) NOT NULL,
ExpiresAt DATETIME2 NOT NULL,
CreatedAt DATETIME2 NOT NULL DEFAULT GETDATE(),
CreatedByIp NVARCHAR(50) NULL,
RevokedAt DATETIME2 NULL,
RevokedByIp NVARCHAR(50) NULL,
ReplacedByToken NVARCHAR(256) NULL,
CONSTRAINT FK_RefreshTokens_AdminUsers
FOREIGN KEY (AdminUserId) REFERENCES admin_users(Id) ON DELETE CASCADE,
INDEX IX_RefreshTokens_AdminUserId (AdminUserId),
INDEX IX_RefreshTokens_TokenHash (TokenHash)
);
缓存结构
验证码缓存
Key: captcha:{captchaKey}
Value: {code}
TTL: 300 seconds (5 minutes)
Correctness Properties
A property is a characteristic or behavior that should hold true across all valid executions of a system—essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.
Property 1: Login returns dual tokens with correct structure
For any successful login request with valid credentials and captcha, the response SHALL contain both an AccessToken (non-empty string) and a RefreshToken (non-empty string), with ExpiresIn representing the AccessToken lifetime in seconds.
Validates: Requirements 13.1
Property 2: Refresh token persistence
For any successful login, a RefreshToken record SHALL be created in the database with AdminUserId matching the logged-in user, TokenHash being a non-empty hash, ExpiresAt being approximately 7 days in the future, and CreatedAt being the current time.
Validates: Requirements 13.2
Property 3: Token expiration detection
For any token expiration time, the isTokenExpiringSoon() function SHALL return true if and only if the remaining time is less than or equal to 5 minutes.
Validates: Requirements 13.3
Property 4: Valid refresh token issues new tokens
For any valid (non-expired, non-revoked) RefreshToken, calling the refresh endpoint SHALL return a new AccessToken and optionally a new RefreshToken, both being non-empty strings.
Validates: Requirements 13.4
Property 5: Invalid refresh token returns 401
For any invalid RefreshToken (non-existent, expired, or revoked), calling the refresh endpoint SHALL return HTTP 401 Unauthorized.
Validates: Requirements 13.5
Property 6: Logout invalidates refresh token
For any valid RefreshToken, after calling the logout endpoint with that token, the token SHALL be marked as revoked (RevokedAt is set) and subsequent refresh attempts SHALL fail with 401.
Validates: Requirements 13.6
Property 7: Account disable revokes all tokens
For any admin user with one or more active RefreshTokens, when the account is disabled, ALL RefreshTokens for that user SHALL be marked as revoked.
Validates: Requirements 13.7
Property 8: Force logout revokes all user tokens
For any admin user, calling the revoke-all-tokens endpoint SHALL mark ALL RefreshTokens for that user as revoked, regardless of their previous state.
Validates: Requirements 13.8
Property 9: Captcha generation format
For any captcha generation request, the response SHALL contain a CaptchaKey (non-empty string) and a CaptchaImage (valid base64 encoded string starting with "data:image/").
Validates: Requirements 14.2, 14.8
Property 10: Captcha code characteristics
For any generated captcha, the code SHALL be alphanumeric (letters and digits only) and have a length between 4 and 6 characters inclusive.
Validates: Requirements 14.2
Property 11: Captcha storage with TTL
For any generated captcha, the code SHALL be stored in cache with the captcha key, and SHALL be retrievable within 5 minutes of generation, and SHALL NOT be retrievable after 5 minutes.
Validates: Requirements 14.3
Property 12: Captcha validation before password check
For any login request with invalid captcha (wrong code or expired), the Auth_Service SHALL return a captcha error WITHOUT checking the password, meaning the login failure count SHALL NOT increase.
Validates: Requirements 14.4, 14.5
Property 13: Captcha single-use enforcement
For any captcha code, after ONE validation attempt (whether successful or failed), the captcha SHALL be removed from cache and subsequent validation attempts with the same captcha key SHALL fail.
Validates: Requirements 14.6
Error Handling
Token 刷新错误处理
| 错误场景 | HTTP 状态码 | 错误码 | 处理方式 |
|---|---|---|---|
| RefreshToken 不存在 | 401 | INVALID_REFRESH_TOKEN | 跳转登录页 |
| RefreshToken 已过期 | 401 | REFRESH_TOKEN_EXPIRED | 跳转登录页 |
| RefreshToken 已撤销 | 401 | REFRESH_TOKEN_REVOKED | 跳转登录页 |
| 用户账号已禁用 | 401 | ACCOUNT_DISABLED | 跳转登录页 |
验证码错误处理
| 错误场景 | HTTP 状态码 | 错误码 | 处理方式 |
|---|---|---|---|
| 验证码Key不存在 | 400 | CAPTCHA_INVALID | 刷新验证码 |
| 验证码已过期 | 400 | CAPTCHA_EXPIRED | 刷新验证码 |
| 验证码错误 | 400 | CAPTCHA_WRONG | 刷新验证码 |
前端错误处理流程
// 401 错误处理
if (error.response?.status === 401) {
const refreshToken = getRefreshToken()
// 有 RefreshToken 且未重试过,尝试刷新
if (refreshToken && !error.config._retry) {
error.config._retry = true
try {
await doRefreshToken()
return service(error.config) // 重试原请求
} catch {
// 刷新失败,清除Token并跳转登录
clearTokens()
router.push('/login')
}
} else {
// 无 RefreshToken 或刷新失败,直接跳转登录
clearTokens()
router.push('/login')
}
}
Testing Strategy
单元测试
-
CaptchaService 测试
- 验证码生成格式正确
- 验证码长度在 4-6 位之间
- 验证码只包含字母数字
- 验证码存储和过期机制
-
AuthService 测试
- 登录时验证码校验优先于密码校验
- RefreshToken 生成和存储
- Token 刷新逻辑
- Token 撤销逻辑
-
前端工具函数测试
- isTokenExpiringSoon() 边界条件
- Token 存储和读取
- 并发刷新防护
属性测试
使用 FsCheck (.NET) 进行属性测试:
- Property 3: 生成随机过期时间,验证 isTokenExpiringSoon 逻辑
- Property 10: 生成大量验证码,验证格式约束
- Property 13: 验证单次使用约束
集成测试
-
完整登录流程
- 获取验证码 → 登录 → 获取双Token → 刷新Token → 登出
-
Token 过期场景
- AccessToken 过期后自动刷新
- RefreshToken 过期后跳转登录
-
并发刷新场景
- 多个请求同时触发刷新,只执行一次刷新