# WebShell JWT Refresh Token 安全增强方案

本文档是对 `web_shell_jwt.md` 的安全增强补充，重点解决 **Refresh Token 长期有效且存储在 Cookie 中** 的安全风险问题。

**状态**: 设计方案 — 待评审

---

**目录**

- [总述](#总述)
- [问题分析](#问题分析)
- [安全增强策略](#安全增强策略)
- [核心设计](#核心设计)
- [实现细节](#实现细节)
- [API 变更](#api-变更)
- [配置扩展](#配置扩展)
- [持久化设计](#持久化设计)
- [验证策略](#验证策略)

---

## 总述

### 方案概述

本方案为 liner 项目的 WebShell JWT 认证系统提供 **企业级 Refresh Token 安全增强**，采用业界最佳实践的多层防御策略，在保持无状态 JWT 架构优势的同时，解决长期 Refresh Token 的安全风险。

### 整体架构

```mermaid
flowchart LR
    subgraph 客户端
        Browser[浏览器]
        Cookie1[🔒 Refresh Token]
        Cookie2[🔒 Fingerprint]
    end

    subgraph 认证层
        Login[登录服务]
        Refresh[刷新服务]
        Logout[登出服务]
    end

    subgraph 状态层
        Store[(Token State Store)]
        Persist[📁 持久化]
    end

    Browser -->|POST /login| Login
    Login -->|双 Cookie| Browser
    Login -->|存储状态| Store

    Browser -->|POST /refresh| Refresh
    Refresh -->|验证 + Rotation| Refresh
    Refresh -->|新双 Cookie| Browser
    Refresh -->|更新状态| Store

    Browser -->|POST /logout| Logout
    Logout -->|撤销链| Store

    Store <-->|定时同步| Persist
```

### 核心安全机制

| 机制 | 描述 | 防护能力 |
|------|------|----------|
| **Token Rotation** | 每次刷新颁发新 Token，旧 Token 立即失效 | 🛡️ 防窃取后持续滥用 |
| **重放检测** | 检测已轮换 Token 的重复使用，触发链撤销 | 🛡️ 防 Token 复制攻击 |
| **刷新次数限制** | 单链最大刷新 N 次后强制重新登录 | 🛡️ 防无限会话延长 |
| **指纹绑定** | 双 Cookie 验证，Token 与客户端环境绑定 | 🛡️ 防跨设备/环境滥用 |
| **服务端状态** | 轻量状态存储，支持主动撤销和审计 | 🛡️ 支持即时登出控制 |
| **持久化恢复** | 服务重启后自动恢复状态 | 🛡️ 保障服务连续性 |

### 方案优势

#### ✅ 安全性

- **多层防御**：组合多种安全机制，单一机制被突破不会导致完全失效
- **主动防护**：重放检测可主动发现并阻断攻击
- **即时撤销**：支持单点登出、强制失效、批量撤销
- **审计追踪**：Token 链机制支持完整的使用追踪

#### ✅ 性能

- **轻量存储**：仅存储活跃 Refresh Token 状态，内存占用低
- **高并发**：使用 `xsync.MapOf` 实现无锁并发访问
- **自动清理**：定期清理过期 Token，防止状态膨胀

#### ✅ 可靠性

- **持久化**：支持文件持久化，服务重启状态不丢失
- **优雅关闭**：关闭前自动保存状态
- **原子写入**：使用临时文件 + 重命名确保写入原子性

#### ✅ 架构适配

- **遵循 liner 模式**：复用 `FileLoader`、`xsync` 等既有组件
- **无侵入集成**：作为现有 JWT 方案的增强层
- **向后兼容**：可选启用各安全特性，渐进式采用

### 安全对比

| 特性 | 原方案 | 增强后 |
|------|--------|--------|
| Token 泄露后可撤销 | ❌ 无法撤销 | ✅ 即时撤销 |
| 重放攻击检测 | ❌ 无 | ✅ 自动检测并撤销链 |
| 刷新次数限制 | ❌ 无限刷新 | ✅ 可配置限制 |
| 跨设备使用检测 | ❌ 无 | ✅ 指纹绑定检测 |
| 单点登出 | ❌ 不支持 | ✅ 完整支持 |
| 服务重启影响 | ❌ Token 可能失效 | ✅ 状态自动恢复 |

---

## 问题分析

### 原方案风险与解决方案

| 风险类型 | 原风险描述 | 影响 | 解决方案 | 增强后状态 |
|----------|------------|------|----------|------------|
| **Token 泄露后无法撤销** | Refresh Token 有效期 7 天，泄露后攻击者可持续获取 Access Token | 🔴 高 | Token Rotation + 服务端状态 + 链撤销 | ✅ 已解决 |
| **无限刷新** | 同一 Token 可无限次刷新 | 🟡 中 | MaxRefreshTimes 限制 + 最大生命周期 7 天 | ✅ 已解决 |
| **无服务端状态** | 纯无状态 JWT 无法单点登出 | 🟡 中 | 轻量 TokenStateStore + 持久化 | ✅ 已解决 |
| **跨设备滥用** | Token 可在不同设备间复用 | 🟡 中 | 指纹绑定（双 Cookie 验证） | ✅ 已解决 |
| **长期有效窗口** | 7 天有效期风险窗口过长 | 🟡 中 | 滑动窗口：单次 1 天 + 最大 7 天 | ✅ 已解决 |
| **暴力刷新攻击** | 无速率限制 | 🟢 低 | RefreshRateLimiter 速率限制 | ✅ 已解决 |

### 攻击场景（增强后）

```mermaid
sequenceDiagram
    participant 攻击者
    participant 合法用户
    participant 服务器

    合法用户->>服务器: 登录获取 Refresh Token
    服务器-->>合法用户: 双 Cookie (refresh + fingerprint)

    Note over 攻击者: XSS/中间人攻击窃取 Cookie
    攻击者->>服务器: 使用窃取的 Refresh Token 刷新
    服务器-->>攻击者: 返回新 Token（旧 Token 立即失效）

    合法用户->>服务器: 使用已失效的 Refresh Token
    服务器-->>服务器: 检测到重放攻击 ⚠️
    服务器-->>合法用户: 401 + 撤销整个 Token 链
    服务器-->>攻击者: 后续请求全部失败 ✅

    Note over 合法用户: 感知异常，需重新登录
```

> [!TIP]
> 增强后，即使攻击者窃取 Token 并使用，合法用户下次请求会立即触发重放检测，**整个 Token 链被撤销**，攻击者无法继续使用。

---

## 安全增强策略

本方案采用 **多层防御** 策略，组合以下安全机制：

### 1. Refresh Token Rotation（令牌轮换）

每次使用 Refresh Token 时，同时颁发新的 Refresh Token 并使旧 Token 失效。

**优势**：
- 即使 Token 被窃取，攻击者使用后合法用户会立即感知（旧 Token 失效）
- 形成 Token 使用链，可追溯异常

### 2. 滑动窗口过期（Sliding Window Expiration）

缩短单次 Refresh Token 有效期（如 1 天），但支持续签至最大生命周期（如 7 天）。

**时间策略**：
```
登录时签发 Refresh Token:
├─ 单次有效期: 1 天
└─ 最大生命周期: 7 天（从首次登录计算）

每次刷新时:
├─ 颁发新 Token（有效期 = min(当前时间 + 1天, 首次签发 + 7天)）
└─ 旧 Token 立即失效（标记为已轮换）
```

**优势**：
- 缩短单次有效期，降低 Token 泄露后的风险窗口
- 保持用户体验，活跃用户无需频繁重新登录
- 强制最大生命周期，确保定期重新认证

### 3. MaxRefreshTimes（刷新次数限制）

限制单个 Refresh Token 链的最大刷新次数。

**优势**：
- 强制用户定期重新认证
- 降低长期 Token 滥用风险

### 4. 指纹绑定（Fingerprint Binding）

将 Refresh Token 与客户端指纹强绑定，双 Cookie 验证。

**优势**：
- Token 与环境绑定，跨设备/浏览器使用会失败
- 即使 Refresh Token 泄露，缺少指纹 Cookie 无法刷新

### 5. 轻量服务端状态

维护 Token 状态存储，支持撤销、审计和异常检测。

**旧 Token 立即失效机制**：
- 新 Token 颁发时，旧 Token 在服务端标记为 `IsRotated = true`
- 任何使用已轮换 Token 的请求立即拒绝
- 检测到重放攻击时，撤销整个 Token 链

**优势**：
- 支持主动撤销（登出、强制失效）
- 支持异常检测（IP 变化、频繁刷新等）
- 支持持久化，服务重启后状态恢复

---

## 核心设计

### 架构概览

```mermaid
flowchart TB
    subgraph 客户端
        A[浏览器]
        C1[Cookie: webshell_refresh]
        C2[Cookie: webshell_fp]
    end

    subgraph 服务端
        B[JWT Auth Handler]
        S[TokenStateStore]
        P[Persistence Layer]
    end

    A -->|登录| B
    B -->|生成 Token + 指纹| A
    B -->|记录状态| S
    S -->|定期持久化| P

    A -->|刷新请求| B
    B -->|验证双 Cookie| B
    B -->|检查状态| S
    B -->|Rotation: 新 Token| A
    B -->|更新状态| S
```

### 数据结构

```go
// RefreshTokenState 表示单个 Refresh Token 的状态
type RefreshTokenState struct {
    JTI              string `json:"jti"`               // JWT ID (唯一标识)
    Username         string `json:"username"`          // 用户名
    FingerprintH     string `json:"fph"`               // 指纹哈希
    IssuedAt         int64  `json:"iat"`               // 当前 Token 签发时间
    ExpiresAt        int64  `json:"exp"`               // 当前 Token 过期时间
    OriginalIssuedAt int64  `json:"oiat"`              // 链首次签发时间（用于计算最大生命周期）
    ChainID          string `json:"chain_id"`          // Token 链 ID（首次签发的 JTI）
    ChainIndex       int    `json:"chain_index"`       // 链中位置（刷新次数）
    LastUsedAt       int64  `json:"last_used_at"`      // 最后使用时间
    LastUsedIP       string `json:"last_used_ip"`      // 最后使用 IP
    IsRotated        bool   `json:"is_rotated"`        // 是否已轮换（旧 Token 立即失效标记）
}

// TokenStateStore 是 Token 状态存储的接口
type TokenStateStore interface {
    // Store 存储新的 Token 状态
    Store(ctx context.Context, state *RefreshTokenState) error

    // Load 加载 Token 状态
    Load(ctx context.Context, jti string) (*RefreshTokenState, error)

    // MarkRotated 标记 Token 已轮换
    MarkRotated(ctx context.Context, jti string) error

    // Delete 删除 Token 状态
    Delete(ctx context.Context, jti string) error

    // RevokeByUser 撤销用户所有 Token
    RevokeByUser(ctx context.Context, username string) error

    // RevokeByChain 撤销整个 Token 链
    RevokeByChain(ctx context.Context, chainID string) error

    // Cleanup 清理过期 Token
    Cleanup(ctx context.Context) error

    // Snapshot 生成快照（用于持久化）
    Snapshot() ([]byte, error)

    // Restore 从快照恢复
    Restore(data []byte) error
}
```

---

## 实现细节

### 1. 指纹生成与验证

```go
import (
    "crypto/rand"
    "crypto/sha256"
    "encoding/base64"
    "net/http"
)

// 生成安全随机指纹（登录时调用）
func generateFingerprint() (string, error) {
    b := make([]byte, 32)
    if _, err := rand.Read(b); err != nil {
        return "", err
    }
    return base64.RawURLEncoding.EncodeToString(b), nil
}

// 计算指纹哈希（存入 Token）
func hashFingerprint(fingerprint string) string {
    h := sha256.Sum256([]byte(fingerprint))
    return base64.RawURLEncoding.EncodeToString(h[:16])
}

// 设置双 Cookie
func setRefreshCookies(w http.ResponseWriter, refreshToken, fingerprint, path string, maxAge int) {
    // Refresh Token Cookie
    http.SetCookie(w, &http.Cookie{
        Name:     "webshell_refresh",
        Value:    refreshToken,
        Path:     path,
        HttpOnly: true,
        Secure:   true,
        SameSite: http.SameSiteStrictMode,
        MaxAge:   maxAge,
    })

    // Fingerprint Cookie（独立 Cookie，同样安全）
    http.SetCookie(w, &http.Cookie{
        Name:     "webshell_fp",
        Value:    fingerprint,
        Path:     path,
        HttpOnly: true,
        Secure:   true,
        SameSite: http.SameSiteStrictMode,
        MaxAge:   maxAge,
    })
}

// 验证指纹匹配
func verifyFingerprint(claims jwt.MapClaims, fingerprintFromCookie string) error {
    expectedHash, ok := claims["fph"].(string)
    if !ok || expectedHash == "" {
        return fmt.Errorf("missing fingerprint hash in token")
    }

    actualHash := hashFingerprint(fingerprintFromCookie)
    if expectedHash != actualHash {
        return fmt.Errorf("fingerprint mismatch")
    }

    return nil
}

// RefreshRateLimiter 刷新端点速率限制器
type RefreshRateLimiter struct {
    requests *xsync.MapOf[string, *rateLimitEntry]
    limit    int           // 每分钟最大请求数
    window   time.Duration // 时间窗口
}

type rateLimitEntry struct {
    count    int
    resetAt  int64
}

func NewRefreshRateLimiter(limit int) *RefreshRateLimiter {
    return &RefreshRateLimiter{
        requests: xsync.NewMapOf[string, *rateLimitEntry](),
        limit:    limit,
        window:   time.Minute,
    }
}

func (r *RefreshRateLimiter) Allow(clientIP string) bool {
    now := time.Now().Unix()

    entry, _ := r.requests.LoadOrCompute(clientIP, func() *rateLimitEntry {
        return &rateLimitEntry{count: 0, resetAt: now + int64(r.window.Seconds())}
    })

    // 窗口已过期，重置
    if now >= entry.resetAt {
        entry.count = 0
        entry.resetAt = now + int64(r.window.Seconds())
    }

    // 检查是否超限
    if entry.count >= r.limit {
        return false
    }

    entry.count++
    return true
}
```

### 2. Token Rotation 流程

```go
func (h *HTTPWebShellHandler) handleRefresh(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
    clientIP := getClientIP(r)

    // 0. 速率限制检查
    if h.jwtConfig.RefreshRateLimit > 0 {
        if !h.rateLimiter.Allow(clientIP) {
            h.writeAuthError(w, ErrRateLimitExceeded, "too many refresh requests", http.StatusTooManyRequests)
            return
        }
    }

    // 1. 获取双 Cookie
    refreshCookie, err := r.Cookie("webshell_refresh")
    if err != nil {
        h.writeAuthError(w, ErrTokenRevoked, "missing refresh token", http.StatusUnauthorized)
        return
    }

    fpCookie, err := r.Cookie("webshell_fp")
    if err != nil {
        h.writeAuthError(w, ErrFingerprintMismatch, "missing fingerprint", http.StatusUnauthorized)
        return
    }

    // 2. 解析 Refresh Token
    token, err := h.tokenService.ParseToken(refreshCookie.Value)
    if err != nil || !token.Valid {
        http.Error(w, "invalid refresh token", http.StatusUnauthorized)
        return
    }

    claims := token.Claims.(jwt.MapClaims)

    // 3. 验证 Token 类型
    if typ, _ := claims["typ"].(string); typ != "refresh" {
        http.Error(w, "wrong token type", http.StatusUnauthorized)
        return
    }

    // 4. 验证指纹
    if err := verifyFingerprint(claims, fpCookie.Value); err != nil {
        log.Warn().Err(err).Msg("fingerprint verification failed")
        http.Error(w, "fingerprint mismatch", http.StatusUnauthorized)
        return
    }

    // 5. 检查服务端状态
    jti := claims["jti"].(string)
    state, err := h.tokenStore.Load(ctx, jti)
    if err != nil {
        http.Error(w, "token not found or revoked", http.StatusUnauthorized)
        return
    }

    // 6. 检查是否已轮换（防重放）
    if state.IsRotated {
        // Token 已被使用过，可能是重放攻击
        // 撤销整个 Token 链
        log.Warn().
            Str("jti", jti).
            Str("chain_id", state.ChainID).
            Msg("replay attack detected, revoking entire chain")
        _ = h.tokenStore.RevokeByChain(ctx, state.ChainID)
        http.Error(w, "token already used, please re-login", http.StatusUnauthorized)
        return
    }

    // 7. 检查刷新次数限制
    maxTimes := h.jwtConfig.MaxRefreshTimes
    if maxTimes > 0 && state.ChainIndex >= maxTimes {
        log.Info().
            Str("username", state.Username).
            Int("chain_index", state.ChainIndex).
            Msg("max refresh times exceeded")
        _ = h.tokenStore.RevokeByChain(ctx, state.ChainID)
        http.Error(w, "max refresh times exceeded, please re-login", http.StatusUnauthorized)
        return
    }

    // 8. 标记旧 Token 为已轮换
    if err := h.tokenStore.MarkRotated(ctx, jti); err != nil {
        log.Error().Err(err).Msg("failed to mark token as rotated")
    }

    // 9. 生成新 Token
    username := claims["sub"].(string)

    // 新 Access Token
    accessToken, err := h.tokenService.GenerateAccessToken(username)
    if err != nil {
        h.writeAuthError(w, ErrInternal, "failed to generate access token", http.StatusInternalServerError)
        return
    }

    // 新 Refresh Token（继承 chainID、chainIndex + 1、OriginalIssuedAt）
    newRefreshToken, newState, err := h.tokenService.GenerateRefreshToken(
        username,
        fpCookie.Value,
        state.ChainID,
        state.ChainIndex+1,
        state.OriginalIssuedAt,  // 继承链首次签发时间（用于计算最大生命周期）
    )
    if err != nil {
        h.writeAuthError(w, ErrInternal, "failed to generate refresh token", http.StatusInternalServerError)
        return
    }

    // 10. 存储新 Token 状态
    newState.LastUsedIP = getClientIP(r)
    if err := h.tokenStore.Store(ctx, newState); err != nil {
        log.Error().Err(err).Msg("failed to store token state")
    }

    // 11. 设置新 Cookie
    setRefreshCookies(w, newRefreshToken, fpCookie.Value, h.Location, int(h.jwtConfig.RefreshExpire))

    // 12. 构建响应（包含会话即将过期提示）
    response := map[string]interface{}{
        "access_token": accessToken,
        "expires_in":   h.jwtConfig.AccessExpire,
        "token_type":   "Bearer",
    }

    // 计算剩余生命周期，提前提示客户端
    remaining := state.OriginalIssuedAt + h.jwtConfig.RefreshMaxLifetime - time.Now().Unix()
    if remaining < 86400 { // 不足 1 天时提示
        response["session_expires_soon"] = true
        response["session_remaining"] = remaining
    }

    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(response)
}
```

### 3. 登录流程更新

```go
func (h *HTTPWebShellHandler) handleLogin(w http.ResponseWriter, r *http.Request) {
    var req struct {
        Username string `json:"username"`
        Password string `json:"password"`
    }

    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        http.Error(w, "invalid request", http.StatusBadRequest)
        return
    }

    // 验证凭据（复用现有认证系统）
    var userInfo AuthUserInfo
    userInfo.Username = req.Username
    userInfo.Password = req.Password

    if err := h.userchecker.CheckAuthUser(r.Context(), &userInfo); err != nil {
        http.Error(w, "invalid credentials", http.StatusUnauthorized)
        return
    }

    // 检查 webshell 权限
    if allow := userInfo.Attrs["allow_webshell"]; allow != "1" {
        http.Error(w, "webshell access denied", http.StatusForbidden)
        return
    }

    // 生成指纹
    fingerprint, err := generateFingerprint()
    if err != nil {
        http.Error(w, "internal error", http.StatusInternalServerError)
        return
    }

    // 生成 Access Token
    accessToken, err := h.tokenService.GenerateAccessToken(req.Username)
    if err != nil {
        http.Error(w, "internal error", http.StatusInternalServerError)
        return
    }

    // 生成 Refresh Token（新链，chainIndex = 0）
    refreshToken, state, err := h.tokenService.GenerateRefreshToken(
        req.Username,
        fingerprint,
        "",  // 空 chainID 表示新链，将使用 JTI 作为 chainID
        0,
    )
    if err != nil {
        http.Error(w, "internal error", http.StatusInternalServerError)
        return
    }

    // 存储 Token 状态
    state.LastUsedIP = getClientIP(r)
    if err := h.tokenStore.Store(r.Context(), state); err != nil {
        log.Error().Err(err).Msg("failed to store token state")
    }

    // 设置双 Cookie
    setRefreshCookies(w, refreshToken, fingerprint, h.Location, int(h.jwtConfig.RefreshExpire))

    // 返回 Access Token
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(map[string]interface{}{
        "access_token": accessToken,
        "expires_in":   h.jwtConfig.AccessExpire,
        "token_type":   "Bearer",
    })
}
```

### 4. 登出流程

```go
func (h *HTTPWebShellHandler) handleLogout(w http.ResponseWriter, r *http.Request) {
    // 获取 Refresh Token
    cookie, err := r.Cookie("webshell_refresh")
    if err == nil {
        token, err := h.tokenService.ParseToken(cookie.Value)
        if err == nil && token.Valid {
            claims := token.Claims.(jwt.MapClaims)
            if chainID, ok := claims["chain_id"].(string); ok {
                // 撤销整个 Token 链
                _ = h.tokenStore.RevokeByChain(r.Context(), chainID)
            }
        }
    }

    // 清除 Cookie
    http.SetCookie(w, &http.Cookie{
        Name:     "webshell_refresh",
        Value:    "",
        Path:     h.Location,
        HttpOnly: true,
        Secure:   true,
        MaxAge:   -1,
    })

    http.SetCookie(w, &http.Cookie{
        Name:     "webshell_fp",
        Value:    "",
        Path:     h.Location,
        HttpOnly: true,
        Secure:   true,
        MaxAge:   -1,
    })

    w.WriteHeader(http.StatusNoContent)
}
```

---

## API 变更

### 新增端点

| 端点 | 方法 | 描述 |
|------|------|------|
| `/{location}/api/login` | POST | 用户登录，返回 Access Token + 设置双 Cookie |
| `/{location}/api/refresh` | POST | 刷新 Token（Rotation 模式） |
| `/{location}/api/logout` | POST | 登出，清除 Cookie + 撤销 Token 链 |

### Cookie 变更

| Cookie 名 | 描述 | 属性 |
|-----------|------|------|
| `webshell_refresh` | Refresh Token | HttpOnly, Secure, SameSite=Strict |
| `webshell_fp` | 客户端指纹 | HttpOnly, Secure, SameSite=Strict |

---

## 配置扩展

```yaml
jwt_auth:
  access_secret: "your-strong-32-bytes-secret-key"
  access_expire: 1800              # Access Token: 30 分钟
  prev_secret: ""                  # 密钥轮换时的旧密钥
  token_version: 1                 # 版本号

  # Refresh Token 滑动窗口配置
  refresh_expire: 86400            # 单次有效期: 1 天（缩短以降低风险）
  refresh_max_lifetime: 604800     # 最大生命周期: 7 天（从首次登录计算）

  # 安全增强配置
  enable_fingerprint: true         # 启用指纹绑定
  enable_rotation: true            # 启用 Token 轮换（旧 Token 立即失效）
  max_refresh_times: 100           # 单链最大刷新次数（0 = 无限制）
  refresh_rate_limit: 10           # 刷新端点速率限制：每分钟最大请求数

  # 状态存储配置
  state_store:
    type: "file"                   # file | memory
    file_path: "/var/lib/liner/jwt_state.json"  # 持久化文件路径
    sync_interval: 60              # 自动持久化间隔（秒）
    cleanup_interval: 3600         # 过期清理间隔（秒）
```

### 错误响应标准化

统一 JWT 认证错误响应格式，便于客户端处理：

```go
// 错误码定义
const (
    ErrTokenExpired        = "TOKEN_EXPIRED"         // Token 已过期
    ErrTokenRevoked        = "TOKEN_REVOKED"         // Token 已撤销
    ErrTokenReplay         = "TOKEN_REPLAY"          // 重放攻击检测
    ErrSessionExpired      = "SESSION_EXPIRED"       // 最大生命周期到期
    ErrMaxRefreshExceed    = "MAX_REFRESH_EXCEED"    // 超过最大刷新次数
    ErrFingerprintMismatch = "FINGERPRINT_MISMATCH"  // 指纹不匹配
    ErrRateLimitExceeded   = "RATE_LIMIT_EXCEEDED"   // 速率限制
    ErrInternal            = "INTERNAL_ERROR"        // 内部错误
)

// AuthErrorResponse 统一错误响应结构
type AuthErrorResponse struct {
    Code    string `json:"code"`              // 错误码
    Message string `json:"message"`           // 错误描述
    Retry   bool   `json:"retry,omitempty"`   // 是否可重试
}

// writeAuthError 统一错误响应写入
func (h *HTTPWebShellHandler) writeAuthError(w http.ResponseWriter, code, message string, status int) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    json.NewEncoder(w).Encode(AuthErrorResponse{
        Code:    code,
        Message: message,
        Retry:   code == ErrInternal, // 仅内部错误可重试
    })
}
```

**客户端错误处理示例**：

```javascript
async function refreshToken() {
    const response = await fetch('/webshell/api/refresh', { method: 'POST' });

    if (!response.ok) {
        const error = await response.json();
        switch (error.code) {
            case 'SESSION_EXPIRED':
            case 'MAX_REFRESH_EXCEED':
            case 'TOKEN_REPLAY':
                // 需要重新登录
                redirectToLogin();
                break;
            case 'RATE_LIMIT_EXCEEDED':
                // 稍后重试
                await delay(60000);
                return refreshToken();
            default:
                showError(error.message);
        }
    }

    const data = await response.json();

    // 处理会话即将过期提示
    if (data.session_expires_soon) {
        showWarning(`会话将在 ${Math.floor(data.session_remaining / 3600)} 小时后过期，请保存工作`);
    }

    return data.access_token;
}
```

### 滑动窗口过期时间计算

```go
// 计算 Refresh Token 过期时间
func calculateRefreshExpire(cfg *JwtAuth, originalIssuedAt int64) int64 {
    now := time.Now().Unix()

    // 滑动过期时间（当前时间 + 单次有效期）
    slideExpire := now + cfg.RefreshExpire

    // 最大生命周期过期时间（首次签发 + 最大生命周期）
    maxLifetimeExpire := originalIssuedAt + cfg.RefreshMaxLifetime

    // 取较小值，确保不超过最大生命周期
    if slideExpire > maxLifetimeExpire {
        return maxLifetimeExpire
    }
    return slideExpire
}
```

---

## 持久化设计

### 持久化接口

遵循 liner 项目既有的 `FileLoader` 模式，设计持久化接口：

```go
// TokenStatePersister 持久化接口
type TokenStatePersister interface {
    // Save 保存状态快照
    Save(data []byte) error

    // Load 加载状态快照
    Load() ([]byte, error)
}

// FileTokenStatePersister 文件持久化实现
type FileTokenStatePersister struct {
    Filename string
    Logger   *slog.Logger

    mu       sync.Mutex
}

func (p *FileTokenStatePersister) Save(data []byte) error {
    p.mu.Lock()
    defer p.mu.Unlock()

    // 写入临时文件后原子重命名
    tmpFile := p.Filename + ".tmp"
    if err := os.WriteFile(tmpFile, data, 0600); err != nil {
        return err
    }

    return os.Rename(tmpFile, p.Filename)
}

func (p *FileTokenStatePersister) Load() ([]byte, error) {
    return os.ReadFile(p.Filename)
}
```

### 内存存储实现

```go
// MemoryTokenStateStore 内存存储 + 持久化
type MemoryTokenStateStore struct {
    states    *xsync.MapOf[string, *RefreshTokenState]  // JTI -> State
    userIndex *xsync.MapOf[string, []string]            // Username -> []JTI
    chainIndex *xsync.MapOf[string, []string]           // ChainID -> []JTI

    persister       TokenStatePersister
    syncInterval    time.Duration
    cleanupInterval time.Duration

    stopCh chan struct{}
}

func NewMemoryTokenStateStore(persister TokenStatePersister, syncInterval, cleanupInterval time.Duration) *MemoryTokenStateStore {
    store := &MemoryTokenStateStore{
        states:      xsync.NewMapOf[string, *RefreshTokenState](),
        userIndex:   xsync.NewMapOf[string, []string](),
        chainIndex:  xsync.NewMapOf[string, []string](),
        persister:   persister,
        syncInterval:    syncInterval,
        cleanupInterval: cleanupInterval,
        stopCh:      make(chan struct{}),
    }

    // 尝试从持久化加载
    if persister != nil {
        if data, err := persister.Load(); err == nil && len(data) > 0 {
            if err := store.Restore(data); err != nil {
                log.Warn().Err(err).Msg("failed to restore token state from persistence")
            } else {
                log.Info().Msg("token state restored from persistence")
            }
        }
    }

    // 启动后台任务
    go store.backgroundTasks()

    return store
}

func (s *MemoryTokenStateStore) backgroundTasks() {
    syncTicker := time.NewTicker(s.syncInterval)
    cleanupTicker := time.NewTicker(s.cleanupInterval)
    defer syncTicker.Stop()
    defer cleanupTicker.Stop()

    for {
        select {
        case <-syncTicker.C:
            if s.persister != nil {
                if data, err := s.Snapshot(); err == nil {
                    if err := s.persister.Save(data); err != nil {
                        log.Error().Err(err).Msg("failed to persist token state")
                    }
                }
            }
        case <-cleanupTicker.C:
            _ = s.Cleanup(context.Background())
        case <-s.stopCh:
            // 关闭前最后一次持久化
            if s.persister != nil {
                if data, err := s.Snapshot(); err == nil {
                    _ = s.persister.Save(data)
                }
            }
            return
        }
    }
}

// Snapshot 生成 JSON 快照
func (s *MemoryTokenStateStore) Snapshot() ([]byte, error) {
    states := make(map[string]*RefreshTokenState)
    s.states.Range(func(jti string, state *RefreshTokenState) bool {
        states[jti] = state
        return true
    })
    return json.Marshal(states)
}

// Restore 从 JSON 快照恢复
func (s *MemoryTokenStateStore) Restore(data []byte) error {
    var states map[string]*RefreshTokenState
    if err := json.Unmarshal(data, &states); err != nil {
        return err
    }

    now := time.Now().Unix()
    for jti, state := range states {
        // 跳过已过期的
        if state.ExpiresAt < now {
            continue
        }
        s.states.Store(jti, state)
        // 重建索引
        s.addToUserIndex(state.Username, jti)
        s.addToChainIndex(state.ChainID, jti)
    }

    return nil
}

// Stop 停止后台任务（优雅关闭时调用）
func (s *MemoryTokenStateStore) Stop() {
    close(s.stopCh)
}
```

### 优雅关闭集成

```go
// 在 main.go 中集成
func main() {
    // ... 初始化代码 ...

    // 创建 Token 状态存储
    var tokenStore *MemoryTokenStateStore
    if cfg.JwtAuth != nil && cfg.JwtAuth.StateStore != nil {
        persister := &FileTokenStatePersister{
            Filename: cfg.JwtAuth.StateStore.FilePath,
            Logger:   log.DefaultLogger.Slog(),
        }
        tokenStore = NewMemoryTokenStateStore(
            persister,
            time.Duration(cfg.JwtAuth.StateStore.SyncInterval) * time.Second,
            time.Duration(cfg.JwtAuth.StateStore.CleanupInterval) * time.Second,
        )
    }

    // 优雅关闭
    sigCh := make(chan os.Signal, 1)
    signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)

    go func() {
        <-sigCh
        log.Info().Msg("shutting down...")

        if tokenStore != nil {
            tokenStore.Stop()  // 触发最后一次持久化
        }

        os.Exit(0)
    }()

    // ... 启动服务 ...
}
```

---

## 验证策略

### 单元测试

```go
// auth_jwt_test.go

func TestRefreshTokenRotation(t *testing.T) {
    store := NewMemoryTokenStateStore(nil, time.Hour, time.Hour)

    // 1. 创建初始 Token
    state := &RefreshTokenState{
        JTI:       "test-jti-1",
        Username:  "testuser",
        ChainID:   "test-jti-1",
        ChainIndex: 0,
        ExpiresAt: time.Now().Add(7 * 24 * time.Hour).Unix(),
    }
    err := store.Store(context.Background(), state)
    require.NoError(t, err)

    // 2. 标记为已轮换
    err = store.MarkRotated(context.Background(), "test-jti-1")
    require.NoError(t, err)

    // 3. 验证状态
    loaded, err := store.Load(context.Background(), "test-jti-1")
    require.NoError(t, err)
    assert.True(t, loaded.IsRotated)
}

func TestReplayAttackDetection(t *testing.T) {
    store := NewMemoryTokenStateStore(nil, time.Hour, time.Hour)

    // 创建已轮换的 Token
    state := &RefreshTokenState{
        JTI:       "rotated-jti",
        Username:  "testuser",
        ChainID:   "chain-1",
        IsRotated: true,
        ExpiresAt: time.Now().Add(time.Hour).Unix(),
    }
    _ = store.Store(context.Background(), state)

    // 尝试使用已轮换的 Token
    loaded, _ := store.Load(context.Background(), "rotated-jti")
    assert.True(t, loaded.IsRotated, "should detect replay attack")
}

func TestMaxRefreshTimes(t *testing.T) {
    store := NewMemoryTokenStateStore(nil, time.Hour, time.Hour)
    maxTimes := 10

    // 模拟刷新链
    chainID := "test-chain"
    for i := 0; i <= maxTimes; i++ {
        state := &RefreshTokenState{
            JTI:        fmt.Sprintf("jti-%d", i),
            ChainID:    chainID,
            ChainIndex: i,
            ExpiresAt:  time.Now().Add(time.Hour).Unix(),
        }
        _ = store.Store(context.Background(), state)
    }

    // 验证第 11 次应该被拒绝
    state, _ := store.Load(context.Background(), fmt.Sprintf("jti-%d", maxTimes))
    assert.Equal(t, maxTimes, state.ChainIndex)
}

func TestPersistenceAndRestore(t *testing.T) {
    tmpFile := filepath.Join(t.TempDir(), "jwt_state.json")
    persister := &FileTokenStatePersister{Filename: tmpFile}

    // 创建存储并添加数据
    store1 := NewMemoryTokenStateStore(persister, time.Minute, time.Hour)
    _ = store1.Store(context.Background(), &RefreshTokenState{
        JTI:       "persist-test",
        Username:  "testuser",
        ExpiresAt: time.Now().Add(time.Hour).Unix(),
    })

    // 手动触发持久化
    data, _ := store1.Snapshot()
    _ = persister.Save(data)

    // 创建新存储并恢复
    store2 := NewMemoryTokenStateStore(persister, time.Minute, time.Hour)
    loaded, err := store2.Load(context.Background(), "persist-test")
    require.NoError(t, err)
    assert.Equal(t, "testuser", loaded.Username)
}
```

### 集成测试

```bash
# 运行测试
go test -v -run TestRefreshToken ./...
go test -v -run TestPersistence ./...
```

### 手动验证步骤

1. **登录测试**
   ```bash
   curl -X POST http://localhost:8080/webshell/api/login \
     -H "Content-Type: application/json" \
     -d '{"username":"admin","password":"admin123"}' \
     -c cookies.txt -v
   # 验证: 返回 access_token，Cookie 中包含 webshell_refresh 和 webshell_fp
   ```

2. **刷新测试**
   ```bash
   curl -X POST http://localhost:8080/webshell/api/refresh \
     -b cookies.txt -c cookies.txt -v
   # 验证: 返回新 access_token，Cookie 被更新
   ```

3. **重放攻击测试**
   ```bash
   # 保存旧 Cookie
   cp cookies.txt old_cookies.txt

   # 正常刷新
   curl -X POST http://localhost:8080/webshell/api/refresh \
     -b cookies.txt -c cookies.txt

   # 尝试使用旧 Cookie（应失败）
   curl -X POST http://localhost:8080/webshell/api/refresh \
     -b old_cookies.txt
   # 验证: 返回 401，提示 token already used
   ```

4. **持久化测试**
   ```bash
   # 重启服务
   systemctl restart liner

   # 使用旧 Cookie 刷新（应成功，状态已恢复）
   curl -X POST http://localhost:8080/webshell/api/refresh \
     -b cookies.txt -c cookies.txt
   ```

---

## 总结

本方案通过 **Refresh Token Rotation + MaxRefreshTimes + 指纹绑定 + 服务端状态** 的组合策略，显著提升了 Refresh Token 的安全性：

| 特性 | 效果 |
|------|------|
| **Token Rotation** | 每次刷新颁发新 Token，旧 Token 立即失效 |
| **重放检测** | 检测到已轮换 Token 使用时，撤销整个 Token 链 |
| **刷新次数限制** | 防止无限刷新，强制定期重新认证 |
| **指纹绑定** | Token 与客户端环境绑定，跨设备使用失败 |
| **服务端状态** | 支持主动撤销、审计、异常检测 |
| **持久化** | 服务重启后状态恢复，不影响用户会话 |

所有实现遵循 liner 项目架构规范：
- 使用 `xsync.MapOf` 实现高性能并发存储
- 复用 `FileLoader` 模式的持久化设计
- 集成到现有 Handler 体系
- 遵循错误处理和日志规范
