认证与鉴权
手机号注册 / 登录(密码、验证码双模式)、JWT 令牌机制、拦截规则、401 行为与登出。
2.1 认证机制总览
- 登录成功签发 Token
服务端以
JwtUtil(JJWT 0.11.5,HS256)签发 JWT,密钥来自app.jwt.secret(支持JWT_SECRET环境变量覆盖,要求 ≥32 字节)。 - 写入 Redis 会话
Token 与用户映射存入 Redis:
auth:token:{token} → User;登出即删除该键。 - 下发双通道凭证
响应体返回
token与expiresIn,同时通过Set-Cookie: auth_token={token}下发 Cookie,浏览器端免手动携带。 - 请求拦截鉴权
AuthTokenInterceptor对/api/**生效:先取Authorization: Bearer {token},缺失时回退auth_tokenCookie。 - 上下文注入与清理
校验通过后将用户写入
UserContext(ThreadLocal),请求结束(afterCompletion)自动清理。
1. 取 Token:Authorization: Bearer {token} 优先,其次 auth_token Cookie
2. JWT 解析(JwtUtil.parse + isExpired)
3. Redis 查询 auth:token:{token} 获取用户
4. 失败/过期/不存在 → 401,UserContext 不注入
// Content-Type: application/json { "code": 401, "message": "Missing token" // 或 "Token expired" / "Session expired" / "Invalid token" }
⚠️ 注意:鉴权拦截器返回的 401 是裸 JSON(不含 success/data/error 字段),
与业务层统一包装结构不同。客户端统一处理时建议对 HTTP 状态码 401 直接判定为未登录。
以下路径不经过登录校验,其余 /api/** 一律要求有效 Token:
集成方须知:支付回调(/api/payment/notify/**)与会员下单均为免认证接口,
部署到公网时应通过网关 / 防火墙策略保护下单接口,回调接口依赖签名校验保证安全。
2.2 接口总览(前缀 /api/auth)
2.3 接口详情
/api/auth/register/phone
手机号 + 密码注册新账号。手机号唯一,重复注册报错。
| 字段 | 类型 | 必填 | 校验 / 说明 |
|---|---|---|---|
phone | String | 必填 | 正则 ^\+?[0-9]{6,20}$,支持国际区号前缀 + |
password | String | 必填 | 长度 6–64 |
nickname | String | 可选 | 最长 100 字符 |
{
"id": 1, "username": "13800138000", "phone": "13800138000",
"nickname": "小明", "avatarUrl": null,
"role": "USER", "status": "ACTIVE"
}
/api/auth/register/phone-code
手机号 + 验证码注册。先调用「发送注册验证码」获取短信验证码。
| 字段 | 类型 | 必填 | 校验 / 说明 |
|---|---|---|---|
phone | String | 必填 | 同上正则 |
password | String | 必填 | 长度 6–64 |
code | String | 必填 | 正则 ^[0-9]{4,8}$(4–8 位数字) |
当前版本中 login/phone-code 与 register/phone-code 的验证码比对逻辑暂被注释(源码 TODO:待接入阿里云短信服务后恢复),
且验证码通过后端日志打印(开发演示模式)。生产交付时必须恢复校验逻辑并接入真实短信通道,详见随附 README「短信验证码接入指引」。
/api/auth/code/register
发送注册验证码。6 位数字,有效期 300 秒,发送冷却 60 秒。
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
phone | String | 必填 | 手机号 |
{
"sent": true,
"cooldownSeconds": 60, // 发送冷却
"expiresInSeconds": 300, // 验证码有效期
"message": "注册验证码已发送" // 冷却中为 "发送过于频繁,请稍后再试"
}
/api/auth/login/phone
手机号 + 密码登录。
| 字段 | 类型 | 必填 | 校验 |
|---|---|---|---|
phone | String | 必填 | 手机号正则 |
password | String | 必填 | 长度 6–64 |
{
"user": { "id": 1, "username": "13800138000", "phone": "13800138000", "nickname": "小明", ... },
"token": "eyJhbGciOiJIUzI1NiJ9...",
"expiresIn": 86400
}
/api/auth/login/phone-code
验证码登录,支持两种模式:仅验证码;或同时携带密码做双重校验。
| 字段 | 类型 | 必填 | 校验 / 说明 |
|---|---|---|---|
phone | String | 必填 | 手机号正则 |
code | String | 必填 | 正则 ^[0-9]{4,8}$ |
password | String | 可选 | 长度 6–64;传入时走「验证码 + 密码」双校验(loginByPhone),不传则仅验证码登录 |
响应同 login/phone(LoginResponse)。
/api/auth/code/phone
发送登录验证码。规则与注册验证码一致(6 位 / 300 秒有效 / 60 秒冷却)。
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
phone | String | 必填 | 手机号 |
{
"sent": true,
"cooldownSeconds": 60,
"expiresInSeconds": 300,
"message": "验证码已发送" // 冷却中为 "发送过于频繁,请稍后再试"
}
/api/auth/logout
退出登录:尽力删除 Redis 中的 auth:token:{token} 会话,并通过 Set-Cookie: auth_token=; Path=/; Max-Age=0 清除浏览器 Cookie。
无请求体(携带当前 Token 即可)。
{ "success": true, "message": "已退出登录" }
登出为幂等操作:Token 已失效时同样返回成功。
2.4 完整调用示例
# 1. 注册 curl -X POST http://localhost:8090/api/auth/register/phone \ -H "Content-Type: application/json" \ -d '{"phone":"13800138000","password":"pass123456","nickname":"小明"}' # 2. 登录,取得 token TOKEN=$(curl -s -X POST http://localhost:8090/api/auth/login/phone \ -H "Content-Type: application/json" \ -d '{"phone":"13800138000","password":"pass123456"}' \ | jq -r '.data.token') # 3. 携带 token 调用业务接口 curl http://localhost:8090/api/translation/supported-languages \ -H "Authorization: Bearer $TOKEN" # 4. 未携带 token 的响应示例(HTTP 401) curl -i http://localhost:8090/api/user/profile # HTTP/1.1 401 # {"code":401,"message":"Missing token"}
const BASE = 'http://localhost:8090'; // 登录并保存 token const loginResp = await fetch(`${BASE}/api/auth/login/phone`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ phone: '13800138000', password: 'pass123456' }) }).then(r => r.json()); const token = loginResp.data.token; // 后续请求统一携带 Authorization 头 const profile = await fetch(`${BASE}/api/user/profile`, { headers: { 'Authorization': `Bearer ${token}` } }).then(r => r.json()); if (!profile.success) { throw new Error(profile.message); } console.log(profile.data);