HaniBlindBox/.kiro/specs/admin-system/design.md
2026-01-05 23:22:20 +08:00

18 KiB
Raw Blame History

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 实体

// 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

单元测试

  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. 并发刷新场景

    • 多个请求同时触发刷新,只执行一次刷新