签入版本
This commit is contained in:
@@ -0,0 +1,330 @@
|
||||
# 5.1 用户认证(JWT)
|
||||
|
||||
:::tip 概述
|
||||
JWT(JSON Web Token)是系统的核心认证机制。采用双令牌设计:access_token 用于接口认证(2小时),refresh_token 用于令牌刷新(7天)。无状态、可水平扩展,前后端分离场景下的标准方案。
|
||||
:::
|
||||
|
||||
## 双令牌机制
|
||||
|
||||
```text
|
||||
┌──────────┐ ┌──────────┐
|
||||
│ 用户登录 │──── username ────►│ │
|
||||
│ │──── password ────►│JwtService│
|
||||
│ │──── code/key ────►│ .login()│
|
||||
└──────────┘ └────┬─────┘
|
||||
│
|
||||
┌─────────────────┼─────────────────┐
|
||||
▼ ▼ ▼
|
||||
access_token refresh_token user info
|
||||
(2小时有效) (7天有效) (基础信息)
|
||||
```
|
||||
|
||||
### 两种令牌的区别
|
||||
|
||||
| 特性 | access_token | refresh_token |
|
||||
|------|-------------|---------------|
|
||||
| 用途 | 接口认证(每个 API 请求携带) | 刷新令牌(仅在 access_token 过期时使用) |
|
||||
| 有效期 | 2 小时(7200 秒) | 7 天(604800 秒) |
|
||||
| payload.type | `access` | `refresh` |
|
||||
| 传递方式 | `Authorization: Bearer <token>` | POST `/oauth2/token` 请求体 |
|
||||
| 校验方 | AuthMiddleware | JwtService::refreshToken |
|
||||
|
||||
### 令牌流转全生命周期
|
||||
|
||||
```text
|
||||
1. 登录
|
||||
POST /api/login {username, password, code, key}
|
||||
│
|
||||
▼
|
||||
JwtService::login()
|
||||
│ 签发 access_token + refresh_token
|
||||
▼
|
||||
前端存储到 localStorage
|
||||
|
||||
2. 请求 API
|
||||
GET /api/user/page
|
||||
Authorization: Bearer <access_token>
|
||||
│
|
||||
▼
|
||||
AuthMiddleware::handle()
|
||||
│ 解析 → 校验签名 → 校验有效期 → 校验 type=access
|
||||
│ 注入 $request->userInfo
|
||||
▼
|
||||
Controller → Logic → Model → 响应
|
||||
|
||||
3. Token 过期
|
||||
前端响应拦截器检测 code=401
|
||||
│
|
||||
▼
|
||||
POST /api/oauth2/token {grant_type: refresh_token, refresh_token: <refresh_token>}
|
||||
│
|
||||
▼
|
||||
JwtService::refreshToken()
|
||||
│ 校验 type=refresh → 校验用户状态 → 签发新令牌对
|
||||
▼
|
||||
前端更新 localStorage 中的令牌
|
||||
|
||||
4. 登出
|
||||
前端清除 localStorage 中的令牌
|
||||
(JWT 无状态,服务端无需处理)
|
||||
```
|
||||
|
||||
## 配置项
|
||||
|
||||
| 配置键 | 环境变量 | 默认值 | 说明 |
|
||||
|--------|---------|--------|------|
|
||||
| `jwt.secret` | `JWT.SECRET` | 内置默认密钥 | JWT 签名密钥,**生产环境务必修改** |
|
||||
| `jwt.algo` | — | `HS256` | 签名算法 |
|
||||
| `jwt.access_ttl` | `JWT.ACCESS_TTL` | `7200` | access_token 有效期(秒),2 小时 |
|
||||
| `jwt.refresh_ttl` | `JWT.REFRESH_TTL` | `604800` | refresh_token 有效期(秒),7 天 |
|
||||
| `jwt.issuer` | — | `rxthinkcmf` | 签发者标识(iss 字段) |
|
||||
| `jwt.header_name` | — | `Authorization` | 请求头名称 |
|
||||
| `jwt.header_prefix` | — | `Bearer ` | 请求头前缀 |
|
||||
|
||||
:::warning 密钥安全
|
||||
生产环境务必通过 `.env` 设置 `JWT.SECRET` 为强随机密钥:
|
||||
```bash
|
||||
php -r "echo bin2hex(random_bytes(32));"
|
||||
```
|
||||
:::
|
||||
|
||||
## 核心类说明
|
||||
|
||||
### Jwt 工具类(extend/jwt/Jwt.php)
|
||||
|
||||
对 `firebase/php-jwt` 的薄封装,只做令牌的编解码与提取,不涉及业务逻辑。
|
||||
|
||||
| 方法 | 说明 | 调用方 |
|
||||
|------|------|--------|
|
||||
| `encode($payload, $ttl)` | 签发 Token(自动补齐 iss/iat/exp) | 内部方法 |
|
||||
| `decode($token)` | 解码 Token(校验签名+有效期) | AuthMiddleware、JwtService |
|
||||
| `getTokenFromHeader()` | 从请求头提取 Token | AuthMiddleware |
|
||||
| `createAccessToken($uid, $username)` | 签发 access_token | JwtService |
|
||||
| `createRefreshToken($uid, $username)` | 签发 refresh_token | JwtService |
|
||||
|
||||
**异常处理:** `decode()` 将 firebase 的具体异常统一转为 `RuntimeException`(code=401):
|
||||
|
||||
| 原始异常 | 转换后消息 |
|
||||
|---------|-----------|
|
||||
| `ExpiredException` | token已过期 |
|
||||
| `SignatureInvalidException` | token签名无效 |
|
||||
| 其他异常 | token无效: 原始信息 |
|
||||
|
||||
### JwtService 服务类(app/service/JwtService.php)
|
||||
|
||||
负责登录业务流程,调用 Jwt 工具类签发与刷新令牌。
|
||||
|
||||
| 方法 | 说明 | 返回值 |
|
||||
|------|------|--------|
|
||||
| `login($username, $password)` | 用户登录,签发令牌对 | `['access_token', 'refresh_token', 'token_type', 'expires_in', 'user']` |
|
||||
| `refreshToken($refreshToken)` | 用 refresh_token 换新令牌对 | `['access_token', 'refresh_token', 'token_type', 'expires_in']` |
|
||||
| `parseToken($token)` | 解析 access_token 获取 payload | `object`(含 uid、username、exp) |
|
||||
| `checkToken($token)` | 检测令牌有效性(不抛异常) | `['valid' => true/false, ...]` |
|
||||
| `revokeToken($token)` | 吊销令牌(当前简化实现) | `bool` |
|
||||
|
||||
## Token Payload 结构
|
||||
|
||||
### access_token payload
|
||||
|
||||
```json
|
||||
{
|
||||
"uid": 1,
|
||||
"username": "admin",
|
||||
"type": "access",
|
||||
"iss": "rxthinkcmf",
|
||||
"iat": 1727000000,
|
||||
"exp": 1727007200
|
||||
}
|
||||
```
|
||||
|
||||
### refresh_token payload
|
||||
|
||||
```json
|
||||
{
|
||||
"uid": 1,
|
||||
"username": "admin",
|
||||
"type": "refresh",
|
||||
"iss": "rxthinkcmf",
|
||||
"iat": 1727000000,
|
||||
"exp": 1727604800
|
||||
}
|
||||
```
|
||||
|
||||
## 登录流程详解
|
||||
|
||||
```text
|
||||
POST /api/login
|
||||
{ "username": "admin", "password": "123456", "code": "a3Bx", "key": "xxx" }
|
||||
│
|
||||
▼
|
||||
1. CaptchaService::check($code, $key)
|
||||
│ 验证码错误 → {"code":1, "msg":"验证码错误"}
|
||||
▼
|
||||
2. User::withoutGlobalScope(['soft_delete'])->where('username', $username)->find()
|
||||
│ 不存在 → {"code":1, "msg":"用户名或密码错误"}
|
||||
▼
|
||||
3. $user->status != 1
|
||||
│ 禁用 → {"code":1, "msg":"账号已被禁用"}
|
||||
▼
|
||||
4. PasswordService::verify($password, $user->password, $user->salt)
|
||||
│ 不匹配 → {"code":1, "msg":"用户名或密码错误"}
|
||||
▼
|
||||
5. Jwt::createAccessToken($uid, $username) + Jwt::createRefreshToken(...)
|
||||
│
|
||||
▼
|
||||
6. 返回
|
||||
{
|
||||
"code": 0,
|
||||
"msg": "登录成功",
|
||||
"data": {
|
||||
"accessToken": "eyJ...",
|
||||
"refreshToken": "eyJ...",
|
||||
"tokenType": "Bearer",
|
||||
"expiresIn": 7200,
|
||||
"user": { "id": 1, "username": "admin", "realname": "管理员", "avatar": "..." }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
:::tip 安全设计
|
||||
- 用户不存在和密码错误统一提示"用户名或密码错误",避免暴露账号是否存在
|
||||
- 验证码校验在密码校验之前,防止暴力破解
|
||||
- 已删除用户通过 `withoutGlobalScope` 查询但不返回,统一走"用户名或密码错误"
|
||||
:::
|
||||
|
||||
## Token 解析流程(AuthMiddleware)
|
||||
|
||||
```text
|
||||
请求进入 AuthMiddleware
|
||||
│
|
||||
▼
|
||||
1. 检查排除路由(login/captcha/oauth2 等直接放行)
|
||||
│
|
||||
▼
|
||||
2. Jwt::getTokenFromHeader()
|
||||
│ 从 Authorization 头提取 Token
|
||||
│ 空 → {"code":401, "msg":"未提供认证令牌"}
|
||||
▼
|
||||
3. Jwt::decode($token)
|
||||
│ 过期 → {"code":401, "msg":"token已过期"}
|
||||
│ 签名无效 → {"code":401, "msg":"token签名无效"}
|
||||
▼
|
||||
4. 校验 $decoded->type === 'access'
|
||||
│ 非 access → {"code":401, "msg":"无效的认证令牌"}
|
||||
▼
|
||||
5. $request->userInfo = $decoded
|
||||
│ 注入用户信息(uid、username 等)
|
||||
▼
|
||||
6. 读取 #[Permission] 注解 → 校验权限
|
||||
│ 无权限 → {"code":403, "msg":"无访问权限"}
|
||||
▼
|
||||
7. 放行 → $next($request)
|
||||
```
|
||||
|
||||
## Token 刷新流程
|
||||
|
||||
```text
|
||||
POST /api/oauth2/token
|
||||
{ "grant_type": "refresh_token", "refresh_token": "eyJ..." }
|
||||
│
|
||||
▼
|
||||
1. Jwt::decode($refreshToken)
|
||||
│ 过期/无效 → {"code":1, "msg":"无效的refresh_token"}
|
||||
▼
|
||||
2. 校验 $decoded->type === 'refresh'
|
||||
│ 非 refresh → {"code":1, "msg":"无效的refresh_token"}
|
||||
▼
|
||||
3. User::find($decoded->uid)
|
||||
│ 不存在或禁用 → {"code":1, "msg":"用户不存在或已被禁用"}
|
||||
▼
|
||||
4. 签发新 access_token + 新 refresh_token
|
||||
│
|
||||
▼
|
||||
5. 返回新令牌对
|
||||
```
|
||||
|
||||
:::tip "刷新即换新"策略
|
||||
刷新时旧 refresh_token 不显式作废,新旧令牌在各自有效期内都可用。如需一次性刷新(旧令牌刷新后立即失效),应结合 Redis 黑名单机制。
|
||||
:::
|
||||
|
||||
## 前端集成
|
||||
|
||||
### 登录与存储
|
||||
|
||||
```typescript
|
||||
// 登录
|
||||
const response = await login({ username, password, code, key });
|
||||
localStorage.setItem('access_token', response.data.accessToken);
|
||||
localStorage.setItem('refresh_token', response.data.refreshToken);
|
||||
```
|
||||
|
||||
### 请求拦截器(自动携带 Token)
|
||||
|
||||
```typescript
|
||||
// src/utils/http/axios/index.ts
|
||||
axios.interceptors.request.use(config => {
|
||||
const token = localStorage.getItem('access_token');
|
||||
if (token) {
|
||||
config.headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
```
|
||||
|
||||
### 响应拦截器(自动刷新 Token)
|
||||
|
||||
```typescript
|
||||
axios.interceptors.response.use(
|
||||
response => response,
|
||||
async error => {
|
||||
const { config, response } = error;
|
||||
|
||||
if (response?.status === 401 && !config._retry) {
|
||||
config._retry = true;
|
||||
|
||||
// 尝试用 refresh_token 刷新
|
||||
const refreshToken = localStorage.getItem('refresh_token');
|
||||
if (refreshToken) {
|
||||
try {
|
||||
const res = await refreshTokenApi(refreshToken);
|
||||
localStorage.setItem('access_token', res.data.accessToken);
|
||||
localStorage.setItem('refresh_token', res.data.refreshToken);
|
||||
|
||||
// 用新 Token 重试原请求
|
||||
config.headers['Authorization'] = `Bearer ${res.data.accessToken}`;
|
||||
return axios(config);
|
||||
} catch (e) {
|
||||
// 刷新失败,跳转登录
|
||||
localStorage.clear();
|
||||
router.push('/login');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
## 安全建议
|
||||
|
||||
| 建议 | 说明 |
|
||||
|------|------|
|
||||
| 生产环境修改密钥 | `JWT.SECRET` 使用 32 字节以上随机字符串 |
|
||||
| HTTPS 传输 | Token 明文传输,必须使用 HTTPS |
|
||||
| 合理设置有效期 | access_token 不宜过长(2小时),refresh_token 按需调整 |
|
||||
| 敏感操作二次验证 | 修改密码、删除数据等操作建议额外校验 |
|
||||
| Token 黑名单 | 生产环境建议用 Redis 实现 Token 吊销(当前为简化实现) |
|
||||
| 前端安全存储 | 避免将 Token 存入 Cookie(防 CSRF),推荐 localStorage |
|
||||
|
||||
## 错误响应汇总
|
||||
|
||||
| 场景 | HTTP 状态 | code | msg |
|
||||
|------|----------|------|-----|
|
||||
| 未提供 Token | 200 | 401 | 未提供认证令牌 |
|
||||
| Token 过期 | 200 | 401 | token已过期 |
|
||||
| Token 签名无效 | 200 | 401 | token签名无效 |
|
||||
| Token 类型错误 | 200 | 401 | 无效的认证令牌 |
|
||||
| 无权限 | 200 | 403 | 无访问权限 |
|
||||
| refresh_token 无效 | 200 | 1 | 无效的refresh_token |
|
||||
| 用户被禁用 | 200 | 1 | 用户不存在或已被禁用 |
|
||||
Reference in New Issue
Block a user