532 lines
18 KiB
Markdown
532 lines
18 KiB
Markdown
# Design Document
|
||
|
||
## Overview
|
||
|
||
本设计文档描述 Token 刷新机制和图形验证码两个功能的技术实现方案。这两个功能将增强后台管理系统的安全性和用户体验。
|
||
|
||
### 功能概述
|
||
|
||
1. **Token 刷新机制**:采用双 Token 方案(Access Token + Refresh Token),实现无感刷新,避免用户频繁重新登录
|
||
2. **图形验证码**:在登录时要求输入验证码,防止暴力破解和机器人攻击
|
||
|
||
## 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 实体
|
||
|
||
```csharp
|
||
// 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 接口
|
||
|
||
```csharp
|
||
// 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 接口扩展
|
||
|
||
```csharp
|
||
// 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. 请求/响应模型
|
||
|
||
```csharp
|
||
// 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 接口扩展
|
||
|
||
```typescript
|
||
// 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 管理工具
|
||
|
||
```typescript
|
||
// 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. 请求拦截器增强
|
||
|
||
```typescript
|
||
// 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 表
|
||
|
||
```sql
|
||
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 | 刷新验证码 |
|
||
|
||
### 前端错误处理流程
|
||
|
||
```typescript
|
||
// 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
|
||
|
||
### 单元测试
|
||
|
||
1. **CaptchaService 测试**
|
||
- 验证码生成格式正确
|
||
- 验证码长度在 4-6 位之间
|
||
- 验证码只包含字母数字
|
||
- 验证码存储和过期机制
|
||
|
||
2. **AuthService 测试**
|
||
- 登录时验证码校验优先于密码校验
|
||
- RefreshToken 生成和存储
|
||
- Token 刷新逻辑
|
||
- Token 撤销逻辑
|
||
|
||
3. **前端工具函数测试**
|
||
- isTokenExpiringSoon() 边界条件
|
||
- Token 存储和读取
|
||
- 并发刷新防护
|
||
|
||
### 属性测试
|
||
|
||
使用 FsCheck (.NET) 进行属性测试:
|
||
|
||
1. **Property 3**: 生成随机过期时间,验证 isTokenExpiringSoon 逻辑
|
||
2. **Property 10**: 生成大量验证码,验证格式约束
|
||
3. **Property 13**: 验证单次使用约束
|
||
|
||
### 集成测试
|
||
|
||
1. **完整登录流程**
|
||
- 获取验证码 → 登录 → 获取双Token → 刷新Token → 登出
|
||
|
||
2. **Token 过期场景**
|
||
- AccessToken 过期后自动刷新
|
||
- RefreshToken 过期后跳转登录
|
||
|
||
3. **并发刷新场景**
|
||
- 多个请求同时触发刷新,只执行一次刷新
|