first commit
This commit is contained in:
185
docs/api/api-overview.md
Normal file
185
docs/api/api-overview.md
Normal file
@@ -0,0 +1,185 @@
|
||||
# API 接口概览
|
||||
|
||||
本文档提供所有 API 接口的快速参考。
|
||||
|
||||
## 基础信息
|
||||
|
||||
### Base URL
|
||||
|
||||
```
|
||||
开发环境: http://localhost:3000
|
||||
生产环境: https://api.yourdomain.com
|
||||
```
|
||||
|
||||
### 认证方式
|
||||
|
||||
本 API 使用 JWT(JSON Web Token)进行认证:
|
||||
|
||||
- **Access Token**:有效期 15 分钟,用于 API 请求认证
|
||||
- **Refresh Token**:有效期 7 天,用于获取新的 Access Token
|
||||
|
||||
### 认证 Header 格式
|
||||
|
||||
```http
|
||||
Authorization: Bearer <access_token>
|
||||
```
|
||||
|
||||
### 响应格式
|
||||
|
||||
所有接口返回统一的 JSON 格式:
|
||||
|
||||
**成功响应**:
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "Success",
|
||||
"data": { }
|
||||
}
|
||||
```
|
||||
|
||||
**错误响应**:
|
||||
```json
|
||||
{
|
||||
"code": 400,
|
||||
"message": "错误信息",
|
||||
"data": null
|
||||
}
|
||||
```
|
||||
|
||||
### 通用错误码
|
||||
|
||||
| 错误码 | 说明 |
|
||||
|-------|------|
|
||||
| 200 | 成功 |
|
||||
| 400 | 请求参数错误 |
|
||||
| 401 | 未授权(Token 无效或过期) |
|
||||
| 404 | 资源不存在 |
|
||||
| 500 | 服务器内部错误 |
|
||||
|
||||
## 接口列表
|
||||
|
||||
### 公开接口(无需认证)
|
||||
|
||||
| 方法 | 路径 | 说明 | 详细文档 |
|
||||
|------|------|------|----------|
|
||||
| GET | `/health` | 健康检查 | [查看详情](endpoints/public.md#get-health) |
|
||||
| GET | `/info` | 服务器信息 | [查看详情](endpoints/public.md#get-info) |
|
||||
| POST | `/auth/register` | 用户注册 | [查看详情](endpoints/public.md#post-authregister) |
|
||||
| POST | `/auth/login` | 用户登录 | [查看详情](endpoints/public.md#post-authlogin) |
|
||||
| POST | `/auth/refresh` | 刷新 Token | [查看详情](endpoints/public.md#post-authrefresh) |
|
||||
|
||||
### 需要认证的接口
|
||||
|
||||
| 方法 | 路径 | 说明 | 详细文档 |
|
||||
|------|------|------|----------|
|
||||
| POST | `/auth/delete` | 删除账号 | [查看详情](endpoints/protected.md#post-authdelete) |
|
||||
| POST | `/auth/delete-refresh-token` | 删除 Refresh Token | [查看详情](endpoints/protected.md#post-authdelete-refresh-token) |
|
||||
|
||||
## 认证流程简述
|
||||
|
||||
### 1. 注册/登录
|
||||
|
||||
用户注册或登录成功后,会返回 Access Token 和 Refresh Token:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "Success",
|
||||
"data": {
|
||||
"access_token": "eyJhbGciOiJIUzI1NiIs...",
|
||||
"refresh_token": "eyJhbGciOiJIUzI1NiIs..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 使用 Access Token
|
||||
|
||||
将 Access Token 添加到请求头:
|
||||
|
||||
```http
|
||||
GET /auth/delete
|
||||
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
|
||||
```
|
||||
|
||||
### 3. 刷新 Token
|
||||
|
||||
当 Access Token 过期时,使用 Refresh Token 获取新的 Token:
|
||||
|
||||
```bash
|
||||
POST /auth/refresh
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"refresh_token": "eyJhbGciOiJIUzI1NiIs..."
|
||||
}
|
||||
```
|
||||
|
||||
> 查看 [认证机制详解](authentication.md) 了解完整流程
|
||||
|
||||
## 快速示例
|
||||
|
||||
### 注册用户
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/auth/register \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"email": "user@example.com",
|
||||
"password": "password123"
|
||||
}'
|
||||
```
|
||||
|
||||
### 用户登录
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"email": "user@example.com",
|
||||
"password": "password123"
|
||||
}'
|
||||
```
|
||||
|
||||
### 访问受保护接口
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/auth/delete \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer <your_access_token>" \
|
||||
-d '{
|
||||
"user_id": "1234567890",
|
||||
"password": "password123"
|
||||
}'
|
||||
```
|
||||
|
||||
### 健康检查
|
||||
|
||||
```bash
|
||||
curl http://localhost:3000/health
|
||||
```
|
||||
|
||||
## 详细文档
|
||||
|
||||
- [公开接口详细文档](endpoints/public.md) - 所有公开接口的详细说明
|
||||
- [受保护接口详细文档](endpoints/protected.md) - 需要认证的接口详细说明
|
||||
- [认证机制详解](authentication.md) - JWT 认证流程和安全最佳实践
|
||||
- [前端集成示例](examples/frontend-integration.md) - JavaScript/TypeScript/React/Vue 集成代码示例
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [快速开始指南](../development/getting-started.md) - 安装和运行项目
|
||||
- [环境变量配置](../deployment/environment-variables.md) - 配置 API 服务器
|
||||
- [前端集成指南](examples/frontend-integration.md) - 前端开发集成示例
|
||||
|
||||
## 获取帮助
|
||||
|
||||
如果您在使用 API 时遇到问题:
|
||||
|
||||
1. 检查请求格式是否正确
|
||||
2. 确认 Token 是否有效(未过期)
|
||||
3. 查看日志输出获取详细错误信息
|
||||
4. 参考 [认证机制详解](authentication.md) 了解认证流程
|
||||
|
||||
---
|
||||
|
||||
**提示**:建议使用 Postman、Insomnia 或类似工具测试 API 接口。
|
||||
608
docs/api/authentication.md
Normal file
608
docs/api/authentication.md
Normal file
@@ -0,0 +1,608 @@
|
||||
# 认证机制详解
|
||||
|
||||
本文档详细说明 Web Rust Template 的 JWT 认证机制、安全特性和最佳实践。
|
||||
|
||||
## 目录
|
||||
|
||||
- [认证架构概述](#认证架构概述)
|
||||
- [双 Token 机制](#双-token-机制)
|
||||
- [认证流程](#认证流程)
|
||||
- [Token 管理](#token-管理)
|
||||
- [安全特性](#安全特性)
|
||||
- [最佳实践](#最佳实践)
|
||||
|
||||
---
|
||||
|
||||
## 认证架构概述
|
||||
|
||||
本系统采用 **JWT (JSON Web Token)** 进行用户认证,使用 **双 Token 机制**:
|
||||
|
||||
1. **Access Token**:短期有效,用于 API 请求认证
|
||||
2. **Refresh Token**:长期有效,用于获取新的 Access Token
|
||||
|
||||
### 架构特点
|
||||
|
||||
- ✅ **无状态认证**:服务器不存储会话信息,易于扩展
|
||||
- ✅ **安全性**:Token 泄露影响可控,自动过期
|
||||
- ✅ **用户体验**:Refresh Token 可减少用户登录次数
|
||||
- ✅ **可撤销性**:通过 Redis 存储 Refresh Token,支持主动撤销
|
||||
|
||||
---
|
||||
|
||||
## 双 Token 机制
|
||||
|
||||
### Access Token
|
||||
|
||||
**用途**:访问需要认证的 API 接口
|
||||
|
||||
**特点**:
|
||||
- 有效期:15 分钟(可配置)
|
||||
- 包含用户 ID 和 Token 类型信息
|
||||
- 不存储在服务器端(无状态)
|
||||
- 每次请求都通过 HTTP Header 传递
|
||||
|
||||
**格式**:
|
||||
```http
|
||||
Authorization: Bearer <access_token>
|
||||
```
|
||||
|
||||
### Refresh Token
|
||||
|
||||
**用途**:获取新的 Access Token
|
||||
|
||||
**特点**:
|
||||
- 有效期:7 天(可配置)
|
||||
- 存储在 Redis 中
|
||||
- 支持撤销和主动登出
|
||||
- 每次刷新会生成新的 Refresh Token
|
||||
|
||||
**存储位置**:
|
||||
- 前端:localStorage 或 sessionStorage
|
||||
- 后端:Redis(Key:`auth:refresh_token:<user_id>`)
|
||||
|
||||
---
|
||||
|
||||
## 认证流程
|
||||
|
||||
### 1. 用户注册流程
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User as 用户
|
||||
participant Frontend as 前端应用
|
||||
participant API as API 服务器
|
||||
participant DB as 数据库
|
||||
participant Redis as Redis
|
||||
|
||||
User->>Frontend: 输入邮箱和密码
|
||||
Frontend->>API: POST /auth/register
|
||||
API->>API: 验证邮箱格式
|
||||
API->>API: 生成用户 ID
|
||||
API->>API: 哈希密码(Argon2)
|
||||
API->>DB: 创建用户记录
|
||||
DB-->>API: 用户创建成功
|
||||
API->>API: 生成 Access Token (15min)
|
||||
API->>API: 生成 Refresh Token (7days)
|
||||
API->>Redis: 存储 Refresh Token
|
||||
Redis-->>API: 存储成功
|
||||
API-->>Frontend: 返回 Access Token + Refresh Token
|
||||
Frontend->>Frontend: 存储 Token 到 localStorage
|
||||
Frontend-->>User: 注册成功,自动登录
|
||||
```
|
||||
|
||||
**关键点**:
|
||||
- 密码使用 Argon2 算法哈希,不可逆
|
||||
- Refresh Token 存储在 Redis,支持撤销
|
||||
- 注册成功后自动登录,返回 Token
|
||||
|
||||
### 2. 用户登录流程
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User as 用户
|
||||
participant Frontend as 前端应用
|
||||
participant API as API 服务器
|
||||
participant DB as 数据库
|
||||
participant Redis as Redis
|
||||
|
||||
User->>Frontend: 输入邮箱和密码
|
||||
Frontend->>API: POST /auth/login
|
||||
API->>DB: 查询用户记录
|
||||
DB-->>API: 返回用户信息
|
||||
API->>API: 验证密码(Argon2)
|
||||
API->>API: 生成 Access Token (15min)
|
||||
API->>API: 生成 Refresh Token (7days)
|
||||
API->>Redis: 存储/更新 Refresh Token
|
||||
Redis-->>API: 存储成功
|
||||
API-->>Frontend: 返回 Access Token + Refresh Token
|
||||
Frontend->>Frontend: 存储 Token 到 localStorage
|
||||
Frontend-->>User: 登录成功
|
||||
```
|
||||
|
||||
**安全特性**:
|
||||
- 登录失败不返回具体错误信息(防止账号枚举)
|
||||
- 密码错误会记录日志用于风控
|
||||
- Refresh Token 每次登录都会更新
|
||||
|
||||
### 3. 访问受保护接口流程
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Frontend as 前端应用
|
||||
participant API as API 服务器
|
||||
participant Redis as Redis
|
||||
|
||||
Frontend->>API: GET /protected<br/>Authorization: Bearer <access_token>
|
||||
API->>API: 验证 JWT 签名
|
||||
API->>API: 检查 Token 类型
|
||||
API->>API: 检查 Token 过期时间
|
||||
alt Token 有效
|
||||
API-->>Frontend: 200 OK 返回数据
|
||||
else Token 无效或过期
|
||||
API-->>Frontend: 401 Unauthorized
|
||||
Frontend->>API: POST /auth/refresh
|
||||
API->>Redis: 获取 Refresh Token
|
||||
Redis-->>API: 返回 Refresh Token
|
||||
API->>API: 验证 Refresh Token
|
||||
API->>API: 生成新的 Token 对
|
||||
API->>Redis: 更新 Refresh Token
|
||||
API-->>Frontend: 返回新的 Token
|
||||
Frontend->>API: 重试原请求
|
||||
API-->>Frontend: 200 OK 返回数据
|
||||
end
|
||||
```
|
||||
|
||||
**关键点**:
|
||||
- 所有受保护接口都需要在 Header 中携带 Access Token
|
||||
- Token 过期时前端自动刷新并重试请求
|
||||
- 刷新成功后旧 Refresh Token 立即失效
|
||||
|
||||
### 4. Token 刷新流程
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Frontend as 前端应用
|
||||
participant API as API 服务器
|
||||
participant Redis as Redis
|
||||
|
||||
Frontend->>API: POST /auth/refresh<br/>{"refresh_token": "..."}
|
||||
API->>API: 验证 Refresh Token 签名
|
||||
API->>API: 检查 Token 类型(必须是 Refresh Token)
|
||||
API->>API: 检查 Token 过期时间
|
||||
API->>Redis: 检查 Refresh Token 是否存在
|
||||
alt Token 有效
|
||||
API->>API: 生成新的 Access Token (15min)
|
||||
API->>API: 生成新的 Refresh Token (7days)
|
||||
API->>Redis: 删除旧的 Refresh Token
|
||||
API->>Redis: 存储新的 Refresh Token
|
||||
API-->>Frontend: 返回新的 Token 对
|
||||
else Token 无效或过期
|
||||
API-->>Frontend: 401 Unauthorized
|
||||
Frontend->>Frontend: 清除 Token
|
||||
Frontend->>Frontend: 跳转到登录页
|
||||
end
|
||||
```
|
||||
|
||||
**Token 轮换**:
|
||||
- 每次刷新都会生成新的 Refresh Token
|
||||
- 旧的 Refresh Token 立即失效
|
||||
- 防止 Token 重放攻击
|
||||
|
||||
### 5. 用户登出流程
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User as 用户
|
||||
participant Frontend as 前端应用
|
||||
participant API as API 服务器
|
||||
participant Redis as Redis
|
||||
|
||||
User->>Frontend: 点击登出按钮
|
||||
Frontend->>API: POST /auth/delete-refresh-token<br/>Authorization: Bearer <access_token>
|
||||
API->>API: 验证 Access Token
|
||||
API->>API: 从 Token 中提取 user_id
|
||||
API->>Redis: 删除 Refresh Token
|
||||
Redis-->>API: 删除成功
|
||||
API-->>Frontend: 200 OK
|
||||
Frontend->>Frontend: 清除本地 Token
|
||||
Frontend->>Frontend: 跳转到登录页
|
||||
Frontend-->>User: 登出成功
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Token 管理
|
||||
|
||||
### Token 生成
|
||||
|
||||
```rust
|
||||
// src/utils/jwt.rs
|
||||
|
||||
// 生成 Access Token
|
||||
pub fn generate_access_token(
|
||||
user_id: &str,
|
||||
expiration_minutes: u64,
|
||||
jwt_secret: &str,
|
||||
) -> Result<String> {
|
||||
let expiration = Utc::now()
|
||||
.checked_add_signed(Duration::minutes(expiration_minutes as i64))
|
||||
.expect("invalid expiration timestamp")
|
||||
.timestamp() as usize;
|
||||
|
||||
let claims = Claims {
|
||||
sub: user_id.to_string(),
|
||||
exp: expiration,
|
||||
token_type: TokenType::Access,
|
||||
};
|
||||
|
||||
let token = encode(
|
||||
&Header::default(),
|
||||
&claims,
|
||||
&EncodingKey::from_secret(jwt_secret.as_ref()),
|
||||
)?;
|
||||
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
// 生成 Refresh Token
|
||||
pub fn generate_refresh_token(
|
||||
user_id: &str,
|
||||
expiration_days: i64,
|
||||
jwt_secret: &str,
|
||||
) -> Result<String> {
|
||||
let expiration = Utc::now()
|
||||
.checked_add_signed(Duration::days(expiration_days))
|
||||
.expect("invalid expiration timestamp")
|
||||
.timestamp() as usize;
|
||||
|
||||
let claims = Claims {
|
||||
sub: user_id.to_string(),
|
||||
exp: expiration,
|
||||
token_type: TokenType::Refresh,
|
||||
};
|
||||
|
||||
let token = encode(
|
||||
&Header::default(),
|
||||
&claims,
|
||||
&EncodingKey::from_secret(jwt_secret.as_ref()),
|
||||
)?;
|
||||
|
||||
Ok(token)
|
||||
}
|
||||
```
|
||||
|
||||
### Token 验证
|
||||
|
||||
```rust
|
||||
// src/infra/middleware/auth.rs
|
||||
|
||||
pub async fn auth_middleware(
|
||||
State(state): State<AppState>,
|
||||
mut request: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, ErrorResponse> {
|
||||
// 1. 提取 Authorization header
|
||||
let auth_header = request
|
||||
.headers()
|
||||
.get("Authorization")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.ok_or_else(|| ErrorResponse::new("缺少 Authorization header".to_string()))?;
|
||||
|
||||
// 2. 验证 Bearer 格式
|
||||
if !auth_header.starts_with("Bearer ") {
|
||||
return Err(ErrorResponse::new("Authorization header 格式错误".to_string()));
|
||||
}
|
||||
|
||||
let token = &auth_header[7..]; // 跳过 "Bearer "
|
||||
|
||||
// 3. 验证 JWT 签名和过期时间
|
||||
let claims = decode_token(token, &state.config.auth.jwt_secret)?;
|
||||
|
||||
// 4. 检查 Token 类型(必须是 Access Token)
|
||||
if claims.token_type != TokenType::Access {
|
||||
return Err(ErrorResponse::new("Token 类型错误".to_string()));
|
||||
}
|
||||
|
||||
// 5. 将 user_id 添加到请求扩展中
|
||||
let user_id = claims.sub;
|
||||
request.extensions_mut().insert(user_id);
|
||||
|
||||
// 6. 继续处理请求
|
||||
Ok(next.run(request).await)
|
||||
}
|
||||
```
|
||||
|
||||
### Refresh Token 存储
|
||||
|
||||
```rust
|
||||
// src/services/auth_service.rs
|
||||
|
||||
async fn save_refresh_token(&self, user_id: &str, refresh_token: &str, expiration_days: i64) -> Result<()> {
|
||||
let key = RedisKey::new(BusinessType::Auth)
|
||||
.add_identifier("refresh_token")
|
||||
.add_identifier(user_id);
|
||||
|
||||
let expiration_seconds = expiration_days * 24 * 3600;
|
||||
|
||||
self.redis_client
|
||||
.set_ex(&key.build(), refresh_token, expiration_seconds as u64)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Redis 保存失败: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
**Redis Key 设计**:
|
||||
```
|
||||
auth:refresh_token:<user_id>
|
||||
```
|
||||
|
||||
**过期时间**:7 天(与 Refresh Token 有效期一致)
|
||||
|
||||
---
|
||||
|
||||
## 安全特性
|
||||
|
||||
### 1. 密码安全
|
||||
|
||||
**Argon2 哈希**:
|
||||
- 使用 Argon2 算法(内存 hard,抗 GPU/ASIC 破解)
|
||||
- 自动生成随机盐值
|
||||
- 哈希结果不可逆
|
||||
|
||||
```rust
|
||||
// src/services/auth_service.rs
|
||||
|
||||
pub fn hash_password(&self, password: &str) -> Result<String> {
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
let argon2 = Argon2::default();
|
||||
let password_hash = argon2
|
||||
.hash_password(password.as_bytes(), &salt)
|
||||
.map_err(|e| anyhow::anyhow!("密码哈希失败: {}", e))?
|
||||
.to_string();
|
||||
Ok(password_hash)
|
||||
}
|
||||
```
|
||||
|
||||
### 2. JWT 安全
|
||||
|
||||
**签名算法**:HS256 (HMAC-SHA256)
|
||||
|
||||
**Claims 结构**:
|
||||
```rust
|
||||
pub struct Claims {
|
||||
pub sub: String, // 用户 ID
|
||||
pub exp: usize, // 过期时间(Unix 时间戳)
|
||||
pub token_type: TokenType, // Token 类型(Access/Refresh)
|
||||
}
|
||||
```
|
||||
|
||||
**安全措施**:
|
||||
- 使用强密钥(至少 32 位随机字符串)
|
||||
- Token 包含过期时间
|
||||
- Token 类型区分(防止混用)
|
||||
- 签名验证防止篡改
|
||||
|
||||
### 3. Refresh Token 安全
|
||||
|
||||
**存储安全**:
|
||||
- 存储在 Redis 中,支持快速撤销
|
||||
- 每次刷新生成新 Token,旧 Token 失效
|
||||
- 支持主动登出,删除 Refresh Token
|
||||
|
||||
**使用限制**:
|
||||
- Refresh Token 只能使用一次
|
||||
- 过期后无法续期
|
||||
- 需要重新登录
|
||||
|
||||
### 4. 防护措施
|
||||
|
||||
**防重放攻击**:
|
||||
- Refresh Token 单次使用
|
||||
- 刷新后立即失效
|
||||
|
||||
**防 Token 泄露**:
|
||||
- Access Token 短期有效(15 分钟)
|
||||
- 只通过 HTTPS 传输
|
||||
- 不在 URL 中传递
|
||||
|
||||
**防暴力破解**:
|
||||
- 限制登录频率(可选实现)
|
||||
- 记录失败尝试(日志)
|
||||
- 密码哈希使用 Argon2
|
||||
|
||||
---
|
||||
|
||||
## 最佳实践
|
||||
|
||||
### 前端集成
|
||||
|
||||
#### 1. Token 存储
|
||||
|
||||
**推荐方案**:
|
||||
```typescript
|
||||
// 存储 Token
|
||||
localStorage.setItem('access_token', access_token);
|
||||
localStorage.setItem('refresh_token', refresh_token);
|
||||
|
||||
// 读取 Token
|
||||
const accessToken = localStorage.getItem('access_token');
|
||||
const refreshToken = localStorage.getItem('refresh_token');
|
||||
|
||||
// 清除 Token
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
```
|
||||
|
||||
#### 2. 请求拦截器
|
||||
|
||||
```typescript
|
||||
// 添加 Token 到请求头
|
||||
api.interceptors.request.use((config) => {
|
||||
const accessToken = localStorage.getItem('access_token');
|
||||
if (accessToken) {
|
||||
config.headers.Authorization = `Bearer ${accessToken}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
```
|
||||
|
||||
#### 3. 响应拦截器(自动刷新 Token)
|
||||
|
||||
```typescript
|
||||
// 处理 401 错误并自动刷新
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
const originalRequest = error.config;
|
||||
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
originalRequest._retry = true;
|
||||
|
||||
try {
|
||||
const refreshToken = localStorage.getItem('refresh_token');
|
||||
const response = await axios.post('/auth/refresh', {
|
||||
refresh_token: refreshToken,
|
||||
});
|
||||
|
||||
const { access_token, refresh_token } = response.data.data;
|
||||
|
||||
localStorage.setItem('access_token', access_token);
|
||||
localStorage.setItem('refresh_token', refresh_token);
|
||||
|
||||
// 重试原请求
|
||||
originalRequest.headers.Authorization = `Bearer ${access_token}`;
|
||||
return axios(originalRequest);
|
||||
} catch (refreshError) {
|
||||
// 刷新失败,跳转登录页
|
||||
localStorage.clear();
|
||||
window.location.href = '/login';
|
||||
return Promise.reject(refreshError);
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
### 后端开发
|
||||
|
||||
#### 1. 密码强度要求
|
||||
|
||||
```rust
|
||||
// 验证密码强度
|
||||
fn validate_password(password: &str) -> Result<()> {
|
||||
if password.len() < 8 {
|
||||
return Err(anyhow!("密码长度至少 8 位"));
|
||||
}
|
||||
if password.len() > 100 {
|
||||
return Err(anyhow!("密码长度最多 100 位"));
|
||||
}
|
||||
// 可添加更多规则(如必须包含大小写、数字等)
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. JWT 密钥管理
|
||||
|
||||
**开发环境**:
|
||||
|
||||
使用 `config/` 目录下的配置文件:
|
||||
|
||||
```bash
|
||||
# 方式1:使用默认配置(推荐)
|
||||
# JWT 密钥已在 config/default.toml 中配置
|
||||
|
||||
# 方式2:创建本地配置文件
|
||||
cp config/default.toml config/local.toml
|
||||
# 编辑 config/local.toml,修改 jwt_secret
|
||||
nano config/local.toml
|
||||
|
||||
# 运行
|
||||
cargo run -- -c config/local.toml
|
||||
```
|
||||
|
||||
**生产环境**:
|
||||
```bash
|
||||
# 使用强随机密钥
|
||||
AUTH_JWT_SECRET=$(openssl rand -base64 32)
|
||||
```
|
||||
|
||||
#### 3. Token 过期时间配置
|
||||
|
||||
```bash
|
||||
# Access Token:15 分钟(推荐)
|
||||
AUTH_ACCESS_TOKEN_EXPIRATION_MINUTES=15
|
||||
|
||||
# Refresh Token:7 天(推荐)
|
||||
AUTH_REFRESH_TOKEN_EXPIRATION_DAYS=7
|
||||
```
|
||||
|
||||
**建议**:
|
||||
- Access Token:5-30 分钟(权衡安全性和用户体验)
|
||||
- Refresh Token:7-30 天(根据应用安全要求)
|
||||
|
||||
### 生产部署
|
||||
|
||||
#### 1. HTTPS 强制
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name api.yourdomain.com;
|
||||
return 301 https://$server_name$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name api.yourdomain.com;
|
||||
# SSL 配置...
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. CORS 配置
|
||||
|
||||
开发环境可以允许所有来源:
|
||||
|
||||
```rust
|
||||
CorsLayer::new()
|
||||
.allow_origin(Any)
|
||||
.allow_methods(Any)
|
||||
.allow_headers(Any)
|
||||
```
|
||||
|
||||
生产环境应该限制允许的来源:
|
||||
|
||||
```rust
|
||||
CorsLayer::new()
|
||||
.allow_origin("https://yourdomain.com".parse::<HeaderValue>().unwrap())
|
||||
.allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE])
|
||||
.allow_headers([HeaderName::from_static("content-type"), HeaderName::from_static("authorization")])
|
||||
```
|
||||
|
||||
#### 3. 速率限制
|
||||
|
||||
防止暴力破解和 DDoS 攻击(需要额外实现):
|
||||
|
||||
```rust
|
||||
// 使用 governor 库实现速率限制
|
||||
use governor::{Quota, RateLimiter};
|
||||
|
||||
let limiter = RateLimiter::direct(Quota::per_second(5));
|
||||
// 每秒最多 5 个请求
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [公开接口文档](endpoints/public.md) - 注册、登录、刷新 Token 接口
|
||||
- [受保护接口文档](endpoints/protected.md) - 需要认证的接口
|
||||
- [前端集成示例](examples/frontend-integration.md) - 完整的前端集成代码
|
||||
- [环境变量配置](../deployment/environment-variables.md) - 认证相关配置说明
|
||||
|
||||
---
|
||||
|
||||
**提示**:生产环境部署前务必修改 JWT 密钥为强随机字符串!
|
||||
368
docs/api/endpoints/public.md
Normal file
368
docs/api/endpoints/public.md
Normal file
@@ -0,0 +1,368 @@
|
||||
# 公开接口文档
|
||||
|
||||
本文档详细说明所有无需认证即可访问的 API 接口。
|
||||
|
||||
## 目录
|
||||
|
||||
- [GET /health - 健康检查](#get-health)
|
||||
- [GET /info - 服务器信息](#get-info)
|
||||
- [POST /auth/register - 用户注册](#post-authregister)
|
||||
- [POST /auth/login - 用户登录](#post-authlogin)
|
||||
- [POST /auth/refresh - 刷新 Token](#post-authrefresh)
|
||||
|
||||
---
|
||||
|
||||
## GET /health
|
||||
|
||||
健康检查端点,用于检查服务是否正常运行。
|
||||
|
||||
### 请求
|
||||
|
||||
```http
|
||||
GET /health
|
||||
```
|
||||
|
||||
**请求参数**:无
|
||||
|
||||
**请求头**:无特殊要求
|
||||
|
||||
### 响应
|
||||
|
||||
**成功响应 (200)**:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok"
|
||||
}
|
||||
```
|
||||
|
||||
或服务不可用时:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "unavailable"
|
||||
}
|
||||
```
|
||||
|
||||
### 示例
|
||||
|
||||
```bash
|
||||
curl http://localhost:3000/health
|
||||
```
|
||||
|
||||
### 错误码
|
||||
|
||||
| 错误码 | 说明 |
|
||||
|-------|------|
|
||||
| 500 | 服务器内部错误 |
|
||||
|
||||
---
|
||||
|
||||
## GET /info
|
||||
|
||||
获取服务器基本信息,包括应用名称、版本、状态等。
|
||||
|
||||
### 请求
|
||||
|
||||
```http
|
||||
GET /info
|
||||
```
|
||||
|
||||
**请求参数**:无
|
||||
|
||||
**请求头**:无特殊要求
|
||||
|
||||
### 响应
|
||||
|
||||
**成功响应 (200)**:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "web-rust-template",
|
||||
"version": "0.1.0",
|
||||
"status": "running",
|
||||
"timestamp": 1704112800
|
||||
}
|
||||
```
|
||||
|
||||
### 字段说明
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| name | string | 应用名称 |
|
||||
| version | string | 应用版本 |
|
||||
| status | string | 运行状态 |
|
||||
| timestamp | number | 当前时间戳(Unix 时间戳) |
|
||||
|
||||
### 示例
|
||||
|
||||
```bash
|
||||
curl http://localhost:3000/info
|
||||
```
|
||||
|
||||
### 错误码
|
||||
|
||||
| 错误码 | 说明 |
|
||||
|-------|------|
|
||||
| 500 | 服务器内部错误 |
|
||||
|
||||
---
|
||||
|
||||
## POST /auth/register
|
||||
|
||||
创建新用户账户。注册成功后自动登录,返回 Access Token 和 Refresh Token。
|
||||
|
||||
### 请求
|
||||
|
||||
```http
|
||||
POST /auth/register
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
**请求参数**:
|
||||
|
||||
```json
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"password": "password123"
|
||||
}
|
||||
```
|
||||
|
||||
### 字段说明
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| email | string | 是 | 用户邮箱,必须是有效的邮箱格式 |
|
||||
| password | string | 是 | 用户密码,建议长度至少 8 位 |
|
||||
|
||||
### 响应
|
||||
|
||||
**成功响应 (200)**:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "Success",
|
||||
"data": {
|
||||
"email": "user@example.com",
|
||||
"created_at": "2026-02-13T12:00:00.000Z",
|
||||
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 响应字段说明
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| email | string | 用户邮箱 |
|
||||
| created_at | string | 账号创建时间(ISO 8601 格式) |
|
||||
| access_token | string | Access Token,有效期 15 分钟 |
|
||||
| refresh_token | string | Refresh Token,有效期 7 天 |
|
||||
|
||||
### 示例
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/auth/register \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"email": "user@example.com",
|
||||
"password": "password123"
|
||||
}'
|
||||
```
|
||||
|
||||
### 错误码
|
||||
|
||||
| 错误码 | 说明 |
|
||||
|-------|------|
|
||||
| 400 | 请求参数错误(邮箱格式错误、密码长度不够) |
|
||||
| 409 | 邮箱已被注册 |
|
||||
| 500 | 服务器内部错误 |
|
||||
|
||||
### 注意事项
|
||||
|
||||
- 邮箱地址将作为用户的唯一标识符
|
||||
- 密码会使用 Argon2 算法进行哈希存储
|
||||
- 注册成功后会自动生成 Access Token 和 Refresh Token
|
||||
- Refresh Token 会存储在 Redis 中,用于后续刷新 Token
|
||||
|
||||
---
|
||||
|
||||
## POST /auth/login
|
||||
|
||||
用户登录。验证成功后返回 Access Token 和 Refresh Token。
|
||||
|
||||
### 请求
|
||||
|
||||
```http
|
||||
POST /auth/login
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
**请求参数**:
|
||||
|
||||
```json
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"password": "password123"
|
||||
}
|
||||
```
|
||||
|
||||
### 字段说明
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| email | string | 是 | 用户邮箱 |
|
||||
| password | string | 是 | 用户密码 |
|
||||
|
||||
### 响应
|
||||
|
||||
**成功响应 (200)**:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "Success",
|
||||
"data": {
|
||||
"id": "1234567890",
|
||||
"email": "user@example.com",
|
||||
"created_at": "2026-02-13T12:00:00.000Z",
|
||||
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 响应字段说明
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | string | 用户 ID(10 位数字) |
|
||||
| email | string | 用户邮箱 |
|
||||
| created_at | string | 账号创建时间(ISO 8601 格式) |
|
||||
| access_token | string | Access Token,有效期 15 分钟 |
|
||||
| refresh_token | string | Refresh Token,有效期 7 天 |
|
||||
|
||||
### 示例
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"email": "user@example.com",
|
||||
"password": "password123"
|
||||
}'
|
||||
```
|
||||
|
||||
### 错误码
|
||||
|
||||
| 错误码 | 说明 |
|
||||
|-------|------|
|
||||
| 400 | 请求参数错误 |
|
||||
| 401 | 邮箱或密码错误 |
|
||||
| 500 | 服务器内部错误 |
|
||||
|
||||
### 注意事项
|
||||
|
||||
- 登录失败不会返回具体的错误信息(如"邮箱不存在"或"密码错误"),统一返回"邮箱或密码错误"
|
||||
- 密码错误次数过多可能会被临时限制(取决于具体实现)
|
||||
- 登录成功后会生成新的 Token 对,旧的 Token 会失效
|
||||
|
||||
---
|
||||
|
||||
## POST /auth/refresh
|
||||
|
||||
使用 Refresh Token 获取新的 Access Token 和 Refresh Token。
|
||||
|
||||
### 请求
|
||||
|
||||
```http
|
||||
POST /auth/refresh
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
**请求参数**:
|
||||
|
||||
```json
|
||||
{
|
||||
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
|
||||
}
|
||||
```
|
||||
|
||||
### 字段说明
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| refresh_token | string | 是 | Refresh Token |
|
||||
|
||||
### 响应
|
||||
|
||||
**成功响应 (200)**:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "Success",
|
||||
"data": {
|
||||
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 响应字段说明
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| access_token | string | 新的 Access Token,有效期 15 分钟 |
|
||||
| refresh_token | string | 新的 Refresh Token,有效期 7 天 |
|
||||
|
||||
### 示例
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/auth/refresh \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
|
||||
}'
|
||||
```
|
||||
|
||||
### 错误码
|
||||
|
||||
| 错误码 | 说明 |
|
||||
|-------|------|
|
||||
| 400 | 请求参数错误 |
|
||||
| 401 | Refresh Token 无效或已过期 |
|
||||
| 500 | 服务器内部错误 |
|
||||
|
||||
### 注意事项
|
||||
|
||||
- 每次刷新会生成新的 Refresh Token,旧的 Refresh Token 会立即失效
|
||||
- Refresh Token 只能使用一次,重复使用会返回错误
|
||||
- Refresh Token 有效期为 7 天,过期后需要重新登录
|
||||
- Refresh Token 存储在 Redis 中,服务器重启不会丢失(如果 Redis 持久化配置正确)
|
||||
|
||||
### Token 刷新策略建议
|
||||
|
||||
**前端实现建议**:
|
||||
|
||||
1. 在每次 API 请求失败(401 错误)时尝试刷新 Token
|
||||
2. 刷新成功后重试原请求
|
||||
3. 刷新失败则跳转到登录页
|
||||
4. 不要在 Token 即将过期时主动刷新,而是在使用时检查有效性
|
||||
|
||||
查看 [前端集成示例](../examples/frontend-integration.md) 了解完整的实现代码。
|
||||
|
||||
---
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [受保护接口文档](protected.md) - 需要认证的接口说明
|
||||
- [认证机制详解](../authentication.md) - 完整的认证流程说明
|
||||
- [API 概览](../api-overview.md) - 所有接口快速索引
|
||||
- [前端集成示例](../examples/frontend-integration.md) - 前端集成代码示例
|
||||
|
||||
---
|
||||
|
||||
**提示**:建议使用 Postman、Insomnia 或类似工具测试 API 接口。
|
||||
768
docs/api/examples/frontend-integration.md
Normal file
768
docs/api/examples/frontend-integration.md
Normal file
@@ -0,0 +1,768 @@
|
||||
# 前端集成示例
|
||||
|
||||
本文档提供完整的前端集成代码示例,包括 JavaScript/TypeScript、React 和 Vue。
|
||||
|
||||
## 目录
|
||||
|
||||
- [TypeScript 基础示例](#typescript-基础示例)
|
||||
- [React 集成示例](#react-集成示例)
|
||||
- [Vue 集成示例](#vue-集成示例)
|
||||
- [Token 存储建议](#token-存储建议)
|
||||
- [错误处理](#错误处理)
|
||||
|
||||
---
|
||||
|
||||
## TypeScript 基础示例
|
||||
|
||||
### 认证客户端类
|
||||
|
||||
以下是一个完整的 TypeScript 认证客户端实现,包含注册、登录、Token 刷新等功能:
|
||||
|
||||
```typescript
|
||||
interface ApiResponse<T> {
|
||||
code: number;
|
||||
message: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
interface RegisterData {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
interface LoginData {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
interface RegisterResponse {
|
||||
email: string;
|
||||
created_at: string;
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
}
|
||||
|
||||
interface LoginResponse {
|
||||
id: string;
|
||||
email: string;
|
||||
created_at: string;
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
}
|
||||
|
||||
interface RefreshResponse {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
}
|
||||
|
||||
class AuthClient {
|
||||
private baseURL: string;
|
||||
private accessToken: string | null = null;
|
||||
private refreshToken: string | null = null;
|
||||
|
||||
constructor(baseURL: string = 'http://localhost:3000') {
|
||||
this.baseURL = baseURL;
|
||||
// 从 localStorage 加载 Token
|
||||
this.accessToken = localStorage.getItem('access_token');
|
||||
this.refreshToken = localStorage.getItem('refresh_token');
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户注册
|
||||
*/
|
||||
async register(email: string, password: string): Promise<RegisterResponse> {
|
||||
const response = await fetch(`${this.baseURL}/auth/register`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
|
||||
const result: ApiResponse<RegisterResponse> = await response.json();
|
||||
|
||||
if (result.code === 200) {
|
||||
this.saveTokens(result.data.access_token, result.data.refresh_token);
|
||||
return result.data;
|
||||
}
|
||||
|
||||
throw new Error(result.message);
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户登录
|
||||
*/
|
||||
async login(email: string, password: string): Promise<LoginResponse> {
|
||||
const response = await fetch(`${this.baseURL}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
|
||||
const result: ApiResponse<LoginResponse> = await response.json();
|
||||
|
||||
if (result.code === 200) {
|
||||
this.saveTokens(result.data.access_token, result.data.refresh_token);
|
||||
return result.data;
|
||||
}
|
||||
|
||||
throw new Error(result.message);
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新 Token
|
||||
*/
|
||||
async refreshTokens(): Promise<void> {
|
||||
if (!this.refreshToken) {
|
||||
throw new Error('No refresh token available');
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.baseURL}/auth/refresh`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ refresh_token: this.refreshToken }),
|
||||
});
|
||||
|
||||
const result: ApiResponse<RefreshResponse> = await response.json();
|
||||
|
||||
if (result.code === 200) {
|
||||
this.saveTokens(result.data.access_token, result.data.refresh_token);
|
||||
} else {
|
||||
this.clearTokens();
|
||||
throw new Error(result.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发起需要认证的请求
|
||||
*/
|
||||
async authenticatedFetch(url: string, options?: RequestInit): Promise<Response> {
|
||||
if (!this.accessToken) {
|
||||
throw new Error('No access token available');
|
||||
}
|
||||
|
||||
let response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
...options?.headers,
|
||||
'Authorization': `Bearer ${this.accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
// Token 过期,尝试刷新
|
||||
if (response.status === 401) {
|
||||
try {
|
||||
await this.refreshTokens();
|
||||
// 重试原请求
|
||||
response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
...options?.headers,
|
||||
'Authorization': `Bearer ${this.accessToken}`,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
// 刷新失败,清除 Token 并抛出错误
|
||||
this.clearTokens();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存 Token 到 localStorage
|
||||
*/
|
||||
private saveTokens(accessToken: string, refreshToken: string): void {
|
||||
this.accessToken = accessToken;
|
||||
this.refreshToken = refreshToken;
|
||||
localStorage.setItem('access_token', accessToken);
|
||||
localStorage.setItem('refresh_token', refreshToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除 Token
|
||||
*/
|
||||
private clearTokens(): void {
|
||||
this.accessToken = null;
|
||||
this.refreshToken = null;
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
}
|
||||
|
||||
/**
|
||||
* 登出
|
||||
*/
|
||||
logout(): void {
|
||||
this.clearTokens();
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否已登录
|
||||
*/
|
||||
isAuthenticated(): boolean {
|
||||
return this.accessToken !== null;
|
||||
}
|
||||
}
|
||||
|
||||
// 使用示例
|
||||
const authClient = new AuthClient();
|
||||
|
||||
// 注册
|
||||
try {
|
||||
const result = await authClient.register('user@example.com', 'password123');
|
||||
console.log('注册成功:', result);
|
||||
} catch (error) {
|
||||
console.error('注册失败:', error);
|
||||
}
|
||||
|
||||
// 登录
|
||||
try {
|
||||
const result = await authClient.login('user@example.com', 'password123');
|
||||
console.log('登录成功:', result);
|
||||
} catch (error) {
|
||||
console.error('登录失败:', error);
|
||||
}
|
||||
|
||||
// 访问受保护接口
|
||||
try {
|
||||
const response = await authClient.authenticatedFetch(
|
||||
'http://localhost:3000/auth/delete',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ user_id: '1234567890', password: 'password123' }),
|
||||
}
|
||||
);
|
||||
const data = await response.json();
|
||||
console.log('请求成功:', data);
|
||||
} catch (error) {
|
||||
console.error('请求失败:', error);
|
||||
}
|
||||
|
||||
// 登出
|
||||
authClient.logout();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## React 集成示例
|
||||
|
||||
### AuthContext Provider
|
||||
|
||||
```typescript
|
||||
// AuthContext.tsx
|
||||
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
accessToken: string | null;
|
||||
isAuthenticated: boolean;
|
||||
login: (email: string, password: string) => Promise<void>;
|
||||
register: (email: string, password: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [accessToken, setAccessToken] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// 从 localStorage 加载 Token
|
||||
const storedAccessToken = localStorage.getItem('access_token');
|
||||
const storedUser = localStorage.getItem('user');
|
||||
|
||||
if (storedAccessToken && storedUser) {
|
||||
setAccessToken(storedAccessToken);
|
||||
setUser(JSON.parse(storedUser));
|
||||
}
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
const login = async (email: string, password: string) => {
|
||||
const response = await fetch('http://localhost:3000/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.code === 200) {
|
||||
const userData: User = {
|
||||
id: result.data.id,
|
||||
email: result.data.email,
|
||||
created_at: result.data.created_at,
|
||||
};
|
||||
|
||||
setUser(userData);
|
||||
setAccessToken(result.data.access_token);
|
||||
|
||||
localStorage.setItem('access_token', result.data.access_token);
|
||||
localStorage.setItem('refresh_token', result.data.refresh_token);
|
||||
localStorage.setItem('user', JSON.stringify(userData));
|
||||
} else {
|
||||
throw new Error(result.message);
|
||||
}
|
||||
};
|
||||
|
||||
const register = async (email: string, password: string) => {
|
||||
const response = await fetch('http://localhost:3000/auth/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.code === 200) {
|
||||
const userData: User = {
|
||||
id: result.data.id || '',
|
||||
email: result.data.email,
|
||||
created_at: result.data.created_at,
|
||||
};
|
||||
|
||||
setUser(userData);
|
||||
setAccessToken(result.data.access_token);
|
||||
|
||||
localStorage.setItem('access_token', result.data.access_token);
|
||||
localStorage.setItem('refresh_token', result.data.refresh_token);
|
||||
localStorage.setItem('user', JSON.stringify(userData));
|
||||
} else {
|
||||
throw new Error(result.message);
|
||||
}
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
setUser(null);
|
||||
setAccessToken(null);
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
localStorage.removeItem('user');
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
user,
|
||||
accessToken,
|
||||
isAuthenticated: !!accessToken,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
loading,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within AuthProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
```
|
||||
|
||||
### API Hook(带 Token 刷新)
|
||||
|
||||
```typescript
|
||||
// useApi.ts
|
||||
import { useCallback } from 'react';
|
||||
import { useAuth } from './AuthContext';
|
||||
|
||||
export function useApi() {
|
||||
const { accessToken, setAccessToken, logout } = useAuth();
|
||||
|
||||
const fetchWithAuth = useCallback(
|
||||
async (url: string, options?: RequestInit): Promise<Response> => {
|
||||
if (!accessToken) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
let response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
...options?.headers,
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
// Token 过期,尝试刷新
|
||||
if (response.status === 401) {
|
||||
const refreshToken = localStorage.getItem('refresh_token');
|
||||
|
||||
if (refreshToken) {
|
||||
const refreshResponse = await fetch('http://localhost:3000/auth/refresh', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ refresh_token: refreshToken }),
|
||||
});
|
||||
|
||||
const refreshResult = await refreshResponse.json();
|
||||
|
||||
if (refreshResult.code === 200) {
|
||||
setAccessToken(refreshResult.data.access_token);
|
||||
localStorage.setItem('access_token', refreshResult.data.access_token);
|
||||
localStorage.setItem('refresh_token', refreshResult.data.refresh_token);
|
||||
|
||||
// 重试原请求
|
||||
response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
...options?.headers,
|
||||
'Authorization': `Bearer ${refreshResult.data.access_token}`,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// 刷新失败,登出
|
||||
logout();
|
||||
throw new Error('Session expired');
|
||||
}
|
||||
} else {
|
||||
logout();
|
||||
throw new Error('Session expired');
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
},
|
||||
[accessToken, setAccessToken, logout]
|
||||
);
|
||||
|
||||
return { fetchWithAuth };
|
||||
}
|
||||
```
|
||||
|
||||
### 登录组件示例
|
||||
|
||||
```typescript
|
||||
// Login.tsx
|
||||
import React, { useState } from 'react';
|
||||
import { useAuth } from './AuthContext';
|
||||
|
||||
export function Login() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { login } = useAuth();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await login(email, password);
|
||||
// 登录成功,路由跳转
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '登录失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<h2>登录</h2>
|
||||
{error && <div style={{ color: 'red' }}>{error}</div>}
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="邮箱"
|
||||
required
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="密码"
|
||||
required
|
||||
/>
|
||||
<button type="submit" disabled={loading}>
|
||||
{loading ? '登录中...' : '登录'}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Vue 集成示例
|
||||
|
||||
### Auth Composable
|
||||
|
||||
```typescript
|
||||
// composables/useAuth.ts
|
||||
import { ref, computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const user = ref<User | null>(null);
|
||||
const accessToken = ref<string | null>(null);
|
||||
const router = useRouter();
|
||||
|
||||
const isAuthenticated = computed(() => !!accessToken.value);
|
||||
|
||||
// 初始化:从 localStorage 加载
|
||||
const init = () => {
|
||||
const storedAccessToken = localStorage.getItem('access_token');
|
||||
const storedUser = localStorage.getItem('user');
|
||||
|
||||
if (storedAccessToken && storedUser) {
|
||||
accessToken.value = storedAccessToken;
|
||||
user.value = JSON.parse(storedUser);
|
||||
}
|
||||
};
|
||||
|
||||
const login = async (email: string, password: string) => {
|
||||
const response = await fetch('http://localhost:3000/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.code === 200) {
|
||||
const userData: User = {
|
||||
id: result.data.id,
|
||||
email: result.data.email,
|
||||
created_at: result.data.created_at,
|
||||
};
|
||||
|
||||
user.value = userData;
|
||||
accessToken.value = result.data.access_token;
|
||||
|
||||
localStorage.setItem('access_token', result.data.access_token);
|
||||
localStorage.setItem('refresh_token', result.data.refresh_token);
|
||||
localStorage.setItem('user', JSON.stringify(userData));
|
||||
} else {
|
||||
throw new Error(result.message);
|
||||
}
|
||||
};
|
||||
|
||||
const register = async (email: string, password: string) => {
|
||||
const response = await fetch('http://localhost:3000/auth/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.code === 200) {
|
||||
const userData: User = {
|
||||
id: result.data.id || '',
|
||||
email: result.data.email,
|
||||
created_at: result.data.created_at,
|
||||
};
|
||||
|
||||
user.value = userData;
|
||||
accessToken.value = result.data.access_token;
|
||||
|
||||
localStorage.setItem('access_token', result.data.access_token);
|
||||
localStorage.setItem('refresh_token', result.data.refresh_token);
|
||||
localStorage.setItem('user', JSON.stringify(userData));
|
||||
} else {
|
||||
throw new Error(result.message);
|
||||
}
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
user.value = null;
|
||||
accessToken.value = null;
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
localStorage.removeItem('user');
|
||||
router.push('/login');
|
||||
};
|
||||
|
||||
return {
|
||||
user,
|
||||
accessToken,
|
||||
isAuthenticated,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
init,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Axios 拦截器示例
|
||||
|
||||
```typescript
|
||||
// api/axios.ts
|
||||
import axios from 'axios';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: 'http://localhost:3000',
|
||||
});
|
||||
|
||||
// 请求拦截器:添加 Authorization header
|
||||
api.interceptors.request.use((config) => {
|
||||
const accessToken = localStorage.getItem('access_token');
|
||||
if (accessToken) {
|
||||
config.headers.Authorization = `Bearer ${accessToken}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
// 响应拦截器:处理 401 错误并刷新 Token
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
const originalRequest = error.config;
|
||||
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
originalRequest._retry = true;
|
||||
|
||||
const refreshToken = localStorage.getItem('refresh_token');
|
||||
|
||||
if (refreshToken) {
|
||||
try {
|
||||
const response = await axios.post('/auth/refresh', {
|
||||
refresh_token: refreshToken,
|
||||
});
|
||||
|
||||
if (response.data.code === 200) {
|
||||
const { access_token, refresh_token } = response.data.data;
|
||||
|
||||
localStorage.setItem('access_token', access_token);
|
||||
localStorage.setItem('refresh_token', refresh_token);
|
||||
|
||||
// 重试原请求
|
||||
originalRequest.headers.Authorization = `Bearer ${access_token}`;
|
||||
return axios(originalRequest);
|
||||
}
|
||||
} catch (refreshError) {
|
||||
// 刷新失败,清除 Token
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
window.location.href = '/login';
|
||||
return Promise.reject(refreshError);
|
||||
}
|
||||
} else {
|
||||
// 没有 Refresh Token,跳转到登录页
|
||||
window.location.href = '/login';
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export default api;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Token 存储建议
|
||||
|
||||
### localStorage vs sessionStorage vs Cookie
|
||||
|
||||
| 存储方式 | 优点 | 缺点 | 推荐场景 |
|
||||
|---------|------|------|----------|
|
||||
| localStorage | 数据持久化,刷新页面不丢失 | 容易受到 XSS 攻击 | Access Token、Refresh Token |
|
||||
| sessionStorage | 关闭标签页自动清除 | 刷新页面会丢失 | 不推荐 |
|
||||
| Cookie | 可设置 HttpOnly 防止 XSS | 容易受到 CSRF 攻击 | 服务器渲染场景 |
|
||||
|
||||
### 推荐方案
|
||||
|
||||
**前端应用(SPA)**:
|
||||
- Access Token:localStorage
|
||||
- Refresh Token:localStorage
|
||||
- 添加适当的 XSS 防护(内容安全策略、输入验证)
|
||||
|
||||
**安全性要求高的场景**:
|
||||
- Access Token:内存(React Context/Vue Reactive)
|
||||
- Refresh Token:HttpOnly Cookie(需要后端支持)
|
||||
|
||||
---
|
||||
|
||||
## 错误处理
|
||||
|
||||
### 通用错误处理
|
||||
|
||||
```typescript
|
||||
async function handleApiCall<T>(
|
||||
apiCall: () => Promise<T>,
|
||||
onError?: (error: Error) => void
|
||||
): Promise<T | null> {
|
||||
try {
|
||||
return await apiCall();
|
||||
} catch (error) {
|
||||
if (onError) {
|
||||
onError(error as Error);
|
||||
} else {
|
||||
console.error('API 调用失败:', error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 使用示例
|
||||
const result = await handleApiCall(
|
||||
() => authClient.login('user@example.com', 'password123'),
|
||||
(error) => {
|
||||
alert(`登录失败: ${error.message}`);
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
### 网络错误重试
|
||||
|
||||
```typescript
|
||||
async function fetchWithRetry(
|
||||
url: string,
|
||||
options?: RequestInit,
|
||||
maxRetries: number = 3
|
||||
): Promise<Response> {
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
try {
|
||||
return await fetch(url, options);
|
||||
} catch (error) {
|
||||
if (i === maxRetries - 1) {
|
||||
throw error;
|
||||
}
|
||||
// 等待后重试
|
||||
await new Promise(resolve => setTimeout(resolve, 1000 * (i + 1)));
|
||||
}
|
||||
}
|
||||
throw new Error('Max retries reached');
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [公开接口文档](../endpoints/public.md) - API 接口详细说明
|
||||
- [认证机制详解](../authentication.md) - JWT 认证流程
|
||||
- [受保护接口文档](../endpoints/protected.md) - 需要认证的接口
|
||||
|
||||
---
|
||||
|
||||
**提示**:以上示例代码仅供参考,实际使用时请根据项目需求调整。
|
||||
Reference in New Issue
Block a user