Become a sponsor

概述
JWT(JSON Web Token)是系统的核心认证机制。采用双令牌设计:access_token 用于接口认证(2小时),refresh_token 用于令牌刷新(7天)。无状态、可水平扩展,前后端分离场景下的标准方案。
┌──────────┐ ┌──────────┐
│ 用户登录 │──── 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 |
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 | 请求头前缀 |
密钥安全
生产环境务必通过 .env 设置 JWT.SECRET 为强随机密钥:
php -r "echo bin2hex(random_bytes(32));"对 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无效: 原始信息 |
负责登录业务流程,调用 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 |
{
"uid": 1,
"username": "admin",
"type": "access",
"iss": "rxthinkcmf",
"iat": 1727000000,
"exp": 1727007200
}{
"uid": 1,
"username": "admin",
"type": "refresh",
"iss": "rxthinkcmf",
"iat": 1727000000,
"exp": 1727604800
}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": "..." }
}
}安全设计
withoutGlobalScope 查询但不返回,统一走"用户名或密码错误"请求进入 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)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. 返回新令牌对"刷新即换新"策略
刷新时旧 refresh_token 不显式作废,新旧令牌在各自有效期内都可用。如需一次性刷新(旧令牌刷新后立即失效),应结合 Redis 黑名单机制。
// 登录
const response = await login({ username, password, code, key });
localStorage.setItem('access_token', response.data.accessToken);
localStorage.setItem('refresh_token', response.data.refreshToken);// 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;
});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 | 用户不存在或已被禁用 |