用户授权
三层防御总览
看懂这一节,你需要先知道:HTTP 请求的结构(URL、Header、Body)、服务端收到请求后的基本处理流程(请求 → 处理 → 返回)。
一个系统里,"鉴权"其实要回答三个不同的问题:
- 你是谁?(认证)------ 你是不是本系统的用户
- 你能做什么?(功能权限)------ 你能不能删订单、能不能看后台
- 你能看哪些?(数据权限)------ 同为"普通用户",你只能看自己的订单,不能看别人的
很多新手把三层混在一起处理,结果就是:忘记加数据权限的 SQL 条件,普通用户也能查全表。分层的好处是每一层独立校验,漏了一层不会全崩。
请求(带 JWT)
│
▼
第一层:身份认证 ─── 你是谁?
解析 JWT → 得到 userId、role
没过 → 401,连门都进不来
│
▼
第二层:功能权限 ─── 你能做什么?
RBAC:用户→角色→权限标识
判断你有没有 "order:delete" 这个权限
没过 → 403,进来了但没资格
│
▼
第三层:数据权限 ─── 你能看哪些数据?
SQL 自动拼接 WHERE user_id = 当前用户
或者 dept_id IN (你的部门及子部门)
没过 → 返回空数据,看不到不该看的
第二层:功能权限(你能做什么)
看懂这一节,你需要先知道 :数据库多对多关系(一个学生选多门课 → 需要中间表)、Vue 基本指令(
v-if、v-permission)。
认证只解决了"你是谁",没解决"你能干什么"。如果不用权限模型,你就只能在每个方法里写 if (user.role == "admin")------几十个接口后这些 if 散得满项目都是。
3.1 RBAC 模型(推荐)
用户 → 角色 → 权限 三层解耦。不要把权限直接绑在用户身上------中间插一层"角色",人员变动时只改角色,不动权限。
3.2 权限标识字符串(perms)
格式:模块:功能:动作
order:view -- 查看订单
order:create -- 创建订单
order:delete -- 删除订单
system:user:add -- 新增用户
后端返回给前端的不是 role: 'admin',而是扁平的权限列表:
json
{ "permissions": ["order:view", "order:create", "system:user:add"] }
3.3 前端权限控制
html
<!-- ❌ 耦合具体角色 -->
<el-button v-if="userRole === 'admin'">删除</el-button>
<!-- ✅ 只关心能力,不关心身份 -->
<el-button v-permission="'order:delete'">删除</el-button>
v-if vs :disabled :用户完全不需要的功能用 v-if 隐藏(如普通员工看财务模块);用户知道但暂不满足条件用 :disabled 置灰(如填完表单才能提交)。
3.4 三种权限模型对比
| 模型 | 核心 | 适用 | 风险 |
|---|---|---|---|
| RBAC | 用户→角色→权限,手动分配 | 权限结构稳定(大多数项目) | 角色爆炸 |
| GBAC | 用户→部门→权限,加入部门自动获得 | 组织架构清晰 | 权限粒度粗 |
| ABAC | 用户属性→动态规则,属性匹配自动判定 | 精细化、动态场景 | 规则引擎复杂 |
实际项目:RBAC 打底,需要自动化时加 GBAC,需要精细化时加 ABAC。
第三层:数据权限(你能看哪些数据)
看懂这一节,你需要先知道 :SQL WHERE 条件过滤(
WHERE user_id = 1只查 user_id 为 1 的行)、MyBatis 拦截器 / AOP 切面的基本概念(自动给 SQL 加条件的机制)。
功能权限控制的是操作 (能不能点"删除"按钮),数据权限控制的是范围(能看到 10 条订单还是 1000 条)。你和同事都是"普通用户",功能权限一样,但你只能看自己下的订单,部门经理能看到整个部门的------这就是数据权限。
4.1 三种方案
| 方案 | 做法 | 适用 |
|---|---|---|
| 方案一:作用域字段 | 业务表加 user_id + dept_id + scope |
权限规则固定("只能看自己的"、"部门内共享") |
| 方案二:关联表 | 建 data_permission 表,后台可随时配置 |
权限规则多变 |
| 方案三:混合 | 固定规则用字段,可配置规则用关联表 | 既有固定也有可配置 |
4.2 数据权限范围(scope)
| scope | 含义 | SQL 逻辑 |
|---|---|---|
| 1 | 仅本人 | WHERE user_id = ? |
| 2 | 本部门 | WHERE dept_id = ? |
| 3 | 本部门及子部门 | WHERE dept_id IN (?) |
| 4 | 自定义 | 查 data_permission 表 |
| 5 | 全部 | 不加 WHERE |
选型速查
功能权限:我该用哪种模型?
| 你的情况 | 选 |
|---|---|
| 权限种类固定,管理员手动分配角色 | RBAC |
| 有组织架构,入职自动获得部门权限 | RBAC + GBAC |
| 规则复杂("财务部且职级>3 才能审批>10万") | RBAC + ABAC |
数据权限:三种方案怎么选?
| 你的情况 | 选 |
|---|---|
| 规则固定("只能看自己的"、"部门内共享") | 作用域字段 |
| 规则多变,需后台配置 | 关联表 |
| 既有固定也有可配置 | 混合方案 |
身份认证
用户打开
↓
前端检查有无 Token
├─ 无 → 跳登录页 → 输入账号密码 → POST /api/user/login
│ ↓
│ 服务端验证账号密码 → 生成 JWT
│ ↓
│ 返回 { token: "xxx", expiresIn: 7200 }
├─ 有 → 请求带 Authorization: Bearer xxx
↓
AdminAuthInterceptor.preHandle()
↓
解析 JWT → 校验签名 → 校验过期 → 校验角色
↓
存入 UserHolder (ThreadLocal)
↓
Controller / Service 通过 UserHolder.getUserId() 获取当前用户
↓
afterCompletion() 清理 UserHolder
看懂这一节,你需要先知道:加密和签名的区别(加密是"不让别人看",签名是"证明没被改过")、Base64 编码是什么、Cookie 和请求头的基本概念。
互联网默认是匿名的------服务器收到一个 HTTP 请求,它不知道这个请求是张三发的还是李四发的。认证就是解决这个问题:让服务器确认"这个请求背后的人是谁"。
做法:登录时服务器给你发一个"通行证"(Token),之后每次请求你都带上它,服务器验一下真假就知道你是谁了。
业务流程
统一认证
单点登录
第三方认证
Token 方案
你登录一次后,下一次请求服务器怎么知道还是你?
请求 1: POST /login 用户: alice, 密码: xxx → 服务器验证通过
请求 2: GET /order/list → 服务器:??你是谁
请求 3: POST /cart/add → 服务器:??你是谁
每次请求都是独立的,服务器不记得上一个请求是谁发的。
| 方案 | 做法 | 问题 |
|---|---|---|
| 每次请求都传账号密码 | 每个接口都带 username + password | 密码明文传输 N 次,泄露风险极大 |
| Session | 登录成功 → 服务端存 Session → 返回 SessionId → 浏览器 Cookie 自动带 | 服务端要存状态,集群部署要共享 Session |
| Token(你们的方案) | 登录成功 → 服务端签发一段加密字符串(内含用户身份 + 签名)→ 前端存着 → 后续请求放 Header 里 | 服务端不存任何东西,全靠签名校验真伪 |
Session vs Token
传统 Session 的工作方式:
- 用户登录成功,服务端在内存 / Redis 里存一份登录信息,叫 Session。
- 给客户端返回一个
sessionId,存在 Cookie 里。 - 下次请求带 Cookie,服务端拿 sessionId 去查对应的 Session,就知道是谁。
Session 的痛点:
- 状态存在服务端,多台服务器就要做 Session 共享,扩容麻烦。
- Cookie 受同源策略限制,跨域、移动端支持不好。
- 容易被 CSRF 攻击利用。
| 维度 | Session | Token |
|---|---|---|
| 状态存储 | 服务端(内存/Redis) | 客户端(Token 本身) |
| 扩展性 | 需 Session 共享(Redis / 粘性会话) | 天然无状态,易于水平扩展 |
| 跨域 | Cookie 受同源限制 | HTTP Header 携带,天然跨域 |
| 移动端 | Cookie 支持差 | 原生支持好 |
| 注销控制 | 服务端销毁即可 | 需配合黑名单 / 短有效期 |
| CSRF | 需额外防护 | Token 不在 Cookie 中,天然免疫 |
Session + Redis
登录成功 → Redis 存 { token: userInfo }(TTL 自动过期)
→ 返回 token 给前端
→ 下次请求带 token,服务端去 Redis 查
还是服务端存状态 ,只是从内存挪到了 Redis,解决了重启丢失和集群共享的问题。但每次请求都要查一次 Redis。
能做什么:
- 主动失效:把 Redis 里的记录删掉,该 Token 立即作废
- 踢人下线:删 Redis + 把旧 Token 加入黑名单
- 改密码后旧 Token 失效:删 Redis 记录即可
代价 :每次请求都要查一次 Redis,不再是无状态的了。
但是我们单体服务也不需要集群共享啊,这还造成了对一次IO
于是有了无状态的token ,不过如果
接受最长 7 天延迟生效 → 纯 JWT 够;要求下一秒就生效 → 必须 Redis 管状态。
Token
状态不存服务端了,直接存在客户端。
服务端把用户 ID、角色、过期时间等信息,加上签名生成一段字符串(Token),发给客户端。客户端每次请求带在 Header 里,服务端只需要验签名,就能确认身份,不用查库 / 查 Redis
Token = 一段编码了身份信息的字符串 ,服务端能验证它没被篡改 且由自己签发。
Token = 用户ID + 角色 + 过期时间 + 签名
核心要求:
- 不可篡改:签名保证(JWT 的 HMAC/RSA、Opaque Token 存 Redis)
- 有时效性:过期自动失效
- 可传递:放在 HTTP Header 中跨服务传递
无状态 --- 服务器只校验签名,不查数据库、不查 Redis,就知道这个 Token 是谁发的、有没有过期
请求带 Token → 服务器用密钥解析 JWT → 取出 userId、role
↓
校验签名通过 = 没被篡改
校验时间未过期 = 有效
↓
认定:你是 alice,角色 user
所以 Token 本质就是:一张一次签发、自带防伪、服务器不存底单的通行证
JWT(JSON Web Token)
JWT(jjwt)
- jjwt GitHub(你们用的库):https://github.com/jwtk/jjwt
- JWT 官方标准:https://jwt.io/introduction
JWT 由三部分组成,用 . 连接:Header.Payload.Signature
JWT(JSON Web Token)就是业界统一的 Token 格式,结构是 Header.Payload.Signature 三部分用点拼接。
eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOjF9.abc123...
← Header → ← Payload → ← Signature →
-
Header:声明签名算法(比如 HS256),Base64Url 编码。
-
Payload:放用户信息(userId、role)、过期时间等,也是 Base64Url 编码。
⚠️ 重点:Base64 只是编码,不是加密!任何人拿到 JWT 都能解码看到 Payload 内容,所以绝对不能放密码、手机号等敏感信息。
-
Signature:把前两部分拼起来,用密钥做签名。只要内容被改了,签名就对不上,服务端就能识别出来。
| 部分 | 内容 | 说明 |
|---|---|---|
| Header | {"alg":"HS256","typ":"JWT"} |
Base64Url 编码:签名算法 |
| Payload | {"userId":1,"role":"admin","exp":1699999999} |
Base64Url 编码:声明(claims),不加密,只是编码! |
| Signature | HMACSHA256(Header.Payload, secret) |
签名:防篡改 |
常用签名算法:
- HS256(对称加密):同一个密钥既用来签发,又用来验签。简单,适合单体服务,但多服务共享密钥有泄露风险。
- RS256(非对称加密):私钥签发,公钥验签。认证中心握私钥,各个微服务只拿公钥,安全很多,是微服务架构的首选
封装JWT
工具类把不变的部分固定,变的部分通过参数暴露。以后想改有效期,改 JwtUtil.USER_EXPIRE 一个地方就行
理由
jjwt 官方提供的是 链式 API builder:
// 1. 构建器
Jwts.builder()
.claim("userId", 1) // 往里塞数据
.signWith(密钥) // 签名
.compact(); // 输出字符串
// 2. 解析器
Jwts.parser()
.verifyWith(密钥) // 用密钥验证
.build()
.parseSignedClaims(token) // 解析
.getPayload(); // 取出数据
它不管你的业务:
- 密钥是什么?不知道
- Token 里放哪些字段?不知道
- 有效期多长?不知道
- 过期了怎么办?不知道
这些都是每个项目自己决定的业务细节,官方不可能替你定。
于是我们 封装JwtUtil 把这四个决策固定下来了
决策
决策 1 --- 密钥
private static final SecretKey KEY = Keys.hmacShaKeyFor(
"PetNest2024SecretKeyForJWTTokenGeneration!@#$".getBytes());
所有代码统一用同一个密钥,不会出现各处各写各的
决策 2 --- Token 里放什么
.claim("userId", userId)
.claim("username", username)
.claim("role", role)
每次生成 Token 都放这三个字段,解析时保证能取到。
决策 3 --- 两种过期时间
public static final long ADMIN_EXPIRE = 2 * 3600 * 1000L; // 2小时
public static final long USER_EXPIRE = 7 * 24 * 3600 * 1000L; // 7天
管理员短、用户长,这是业务策略
决策 4 --- 空安全校验
public static boolean validateToken(String token) {
try { parseToken(token); return true; }
catch (Exception e) { return false; } // 过期、篡改都返回false
}
调用方不用写 try-catch,传个字符串就行。
JwtUtil
java
public class JwtUtil {
private static final String SECRET = "PetNest2024SecretKeyForJWTTokenGeneration!@#$";
private static final SecretKey KEY = Keys.hmacShaKeyFor(SECRET.getBytes(StandardCharsets.UTF_8));
public static final long ADMIN_EXPIRE = 2 * 3600 * 1000L; // 管理员 2 小时
public static final long USER_EXPIRE = 7 * 24 * 3600 * 1000L; // 普通用户 7 天
public static String generateToken(Long userId, String username, String role, long expireMs) {
return Jwts.builder()
.claim("userId", userId)
.claim("username", username)
.claim("role", role)
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + expireMs))
.signWith(KEY)
.compact();
}
public static Claims parseToken(String token) {
return Jwts.parser()
.verifyWith(KEY)
.build()
.parseSignedClaims(token)
.getPayload();
}
public static boolean validateToken(String token) {
try { parseToken(token); return true; }
catch (Exception e) { return false; }
}
}
PasswordEncode
用来替代 Spring Security 的 BCryptPasswordEncoder,不用引入 Spring Security 整坨依赖。功能一样:加密存储 + 安全比对
java
public class PasswordEncoder {
private static final int SALT_LENGTH = 16;
public static String encode(String rawPassword) {
byte[] salt = new byte[SALT_LENGTH];
new SecureRandom().nextBytes(salt);
byte[] hash = hashWithSalt(rawPassword, salt);
return Base64.getEncoder().encodeToString(salt) + ":" + Base64.getEncoder().encodeToString(hash);
}
public static boolean matches(String rawPassword, String encodedPassword) {
String[] parts = encodedPassword.split(":");
if (parts.length != 2) return false;
byte[] salt = Base64.getDecoder().decode(parts[0]);
byte[] expectedHash = Base64.getDecoder().decode(parts[1]);
byte[] actualHash = hashWithSalt(rawPassword, salt);
return MessageDigest.isEqual(expectedHash, actualHash);
}
private static byte[] hashWithSalt(String password, byte[] salt) {
try {
MessageDigest md = MessageDigest.getInstance("SHA-256");
md.update(salt);
return md.digest(password.getBytes());
} catch (Exception e) {
throw new RuntimeException("SHA-256 not available", e);
}
}
}
使用
java
String token = JwtUtil.generateToken(user.getId(), user.getUsername(), user.getRole(), JwtUtil.USER_EXPIRE);
Access Token + Refresh Token 双令牌
JWT 最大的问题:签发之后就没法主动撤销。因为服务端不存,只要没过期就一直有效
不透明令牌
就是一个随机字符串,服务端存在 Redis 里,每次请求都要查 Redis。优点是可以随时删除注销,控制能力强;缺点是每次都有 IO 开销
| 维度 | JWT | Opaque Token |
|---|---|---|
| 用户信息 | 自包含在 Token 中 | Token 只是 key,需查 Redis/DB |
| 验证方式 | 本地验签,无外部依赖 | 每次查 Redis |
| 主动撤销 | 困难(需黑名单) | 容易(删 Redis key) |
| 性能 | 高(无 I/O) | 低(每次有 I/O) |
| 典型场景 | 微服务内部传递 | 需要强控制的登录态 |
上下文传递
上下文传递:代码怎么知道"当前用户是谁"
看懂这一节,你需要先知道:ThreadLocal 是什么(每个线程独占的小抽屉)、Spring 拦截器的 preHandle / afterCompletion 机制、Tomcat 线程池复用模型(线程处理完请求 A 后不销毁,继续处理请求 B)。
认证通过了,但 Controller 和 Service 在干活时需要知道"当前请求是谁发的"------比如下单时要知道 userId 挂谁名下。
最笨的办法是层层传参 :每层方法签名都加一个 userId 参数,又丑又容易漏。
更好的办法:在入口处(拦截器)一次性解析,存到一个当前线程独享的小抽屉里,后续整个请求链路随便取,不用传参。
网关解析 JWT → 写入请求头 user-info
→ 微服务拦截器 preHandle 取出 → 存入 ThreadLocal(UserHolder)
→ Controller/Service 随处 UserHolder.getUser()
→ afterCompletion 清理
详细原理 → \[认证上下文注入]
使用规范:
| 场景 | 推荐 | 说明 |
|---|---|---|
| 存储当前用户身份(UserHolder) | ✅ 推荐 | 拦截器入口存入、出口清理 |
| 传递 TraceID / RequestID | ✅ 推荐 | 全链路日志追踪 |
| 多租户 tenantId | ✅ 推荐 | 自动拼接 SQL |
| 大对象(几百 KB 以上) | ⚠️ 谨慎 | 占用时间 = 请求周期,太长 |
核心规则:
java
@Override
public boolean preHandle(...) {
String userId = request.getHeader("user-info");
UserHolder.saveUser(userId); // 存入
return true;
}
@Override
public void afterCompletion(...) {
UserHolder.removeUser(); // 必须清理!
}
⚠️ 不清理的后果 :Tomcat 线程池复用。请求 A 的 User 没清理 → 请求 B 复用同一线程 → 拿到 A 的身份 → 用户串号,生产事故。
OAuth2 -令牌的流转
OAuth2 是一套令牌的流转协议:只规定了「怎么发、怎么用、怎么刷新 Token」的流程,完全不限制 Token 本身长什么样
它只规定一套统一的「授权交互规则」:
- 角色怎么划分(资源所有者、客户端、授权服务器、资源服务器)
- 授权流程分几步走(授权码模式、客户端凭证模式等)
- 接口参数叫什么名字(
client_id、code、access_token等) - 令牌怎么申请、怎么刷新、权限范围怎么定义
只要大家都遵守这套约定,不管你用 Java、Python 还是前端写,不管是微信、GitHub 还是自家系统,都能无缝对接,不用各自定制一套对接逻辑
解决的是"第三方应用如何获取你的数据访问权限"的问题。
四个角色
| 角色 | 英文 | 例子 | |
|---|---|---|---|
| 资源所有者 | Resource Owner | 用户本人 | 使用你的系统、点「微信登录」的普通用户 |
| 客户端 | Client | 第三方应用(某个网站/App) | 就是你写的这个系统 ------ 负责发起授权申请、处理回调、兑换令牌 |
| 授权服务器 | Authorization Server | 微信/QQ/GitHub 的认证中心 | 微信 / GitHub 的授权服务 ------ 负责展示授权页、发 code、发 token |
| 资源服务器 | Resource Server | 存放用户数据的服务(微信头像API) | 微信 / GitHub 的用户信息接口 ------ 负责凭 token 返回用户数据 |
核心令牌体系
OAuth2 协议定义了两类核心令牌,职责严格分离:
-
Access Token(访问令牌)
- 作用:访问资源服务器的唯一凭证,每次调用业务接口都必须携带。
- 特点:有效期短(通常 1~2 小时),降低泄露后的风险。
- 格式:协议不做强制规定,可以是随机字符串(不透明令牌),也可以是 JWT。
-
Refresh Token(刷新令牌)
- 作用:仅用于在 Access Token 过期后,向授权服务器换取新的 Access Token,不能用来访问业务资源。
- 特点:有效期长(7~30 天),仅在客户端和授权服务器之间交互,不传递给资源服务器。
- 价值:避免用户频繁重新登录,同时兼顾安全。
-
Scope(权限范围)
- 作用:给令牌划定权限边界,比如
user:read只能读用户信息,order:write可以创建订单。 - 意义:遵循「最小权限原则」,第三方应用只能拿到它需要的权限,避免过度授权。
- 作用:给令牌划定权限边界,比如
授权流程
OAuth2 针对不同业务场景设计了 4 种授权流程,安全性和适用场景差异很大。
| 模式 | 英文 | 核心特征 | 适用场景 | 安全性 |
|---|---|---|---|---|
| 授权码模式 | Authorization Code | 先拿授权码,后端兑换令牌 | 有后端的 Web 应用、第三方登录 | 最高(官方推荐) |
| 隐式模式 | Implicit | 前端直接拿令牌,跳过授权码 | 纯前端 SPA(已废弃) | 低(不推荐) |
| 密码模式 | Password Credentials | 用户直接把账号密码交给客户端 | 高度信任的自家内部应用 | 中 |
| 客户端凭证模式 | Client Credentials | 无用户参与,应用自身认证 | 服务间后台调用、定时任务 | 中 |
为什么不直接返回 token,非要多一步 code?
浏览器跳转的 URL 是可见、可被拦截的。如果直接返回 token,极易泄露。
而授权码 code 是一次性、短时效的,就算被截获也无法复用;真正的令牌通过后端服务间的请求传递,不会暴露在浏览器中,安全性大幅提升。
授权码模式
微信、QQ、GitHub 等第三方登录均基于此模式,是 OAuth2 安全性最高的授权方式
OAuth2 约定了必须严格按 6 步走,所有合规平台都是这个顺序
-
发起授权请求
用户点击第三方登录,客户端构造授权跳转链接,携带参数:
response_type=code(固定标识)、client_id(客户端唯一标识)、redirect_uri(授权回调地址)、scope(申请的权限范围)、state(随机字符串,防 CSRF 攻击) -
用户确认授权
授权服务器展示授权页面,用户点击同意授权
-
返回授权码
授权服务器重定向回
redirect_uri,携带参数:code(一次性授权码,有效期仅数分钟)、state(原样返回,客户端校验一致性防攻击) -
后端兑换令牌
客户端后端拿到
code后,后台调用授权服务器令牌接口,携带参数:grant_type=authorization_code(固定标识)、code、client_id + client_secret(客户端身份凭证,client_secret严禁暴露在前端)、redirect_uri(与第一步保持一致) -
颁发令牌
校验通过后,授权服务器返回令牌信息:
access_token(访问令牌)、refresh_token(刷新令牌)、expires_in(有效期)、scope -
访问资源
客户端携带
access_token调用资源服务器接口,获取用户数据
接口参数约定
参数名是统一的,不能自己瞎起名
这是「约定」最直观的体现:OAuth2 规定了关键参数就叫这些名字,所有平台都认,你传错名字就会失败
| 约定参数名 | 出现环节 | 作用 |
|---|---|---|
client_id |
授权请求、换令牌 | 标识你是哪个应用 |
client_secret |
后端换令牌 | 你的应用密钥,证明身份 |
response_type=code |
授权请求 | 固定值,表示我要走授权码模式 |
redirect_uri |
授权请求、换令牌 | 授权成功后跳回哪里 |
code |
回调、换令牌 | 一次性授权码 |
grant_type=authorization_code |
换令牌 | 固定值,表示用授权码换令牌 |
scope |
授权请求 | 申请的权限范围 |
state |
授权请求、回调 | 防 CSRF 攻击 |
access_token |
令牌返回、调用资源 | 访问凭证 |
refresh_token |
令牌返回 | 刷新凭证 |
这条约定的价值:你不用跟每个平台商量「这个参数叫什么」。
比如你对接 GitHub 传
client_id,对接微信也传client_id,大家都遵守同一个命名,对接成本极低。
令牌规则约定
怎么申请、怎么刷新、怎么限权限
OAuth2 还统一约定了令牌的使用规则:
- 申请方式 :换令牌必须用 POST 请求,参数放请求体里;
grant_type不同值对应不同授权模式 - 刷新方式 :令牌过期后,用
grant_type=refresh_token+refresh_token去换新的 access_token,不用用户重新登录 - 权限范围 :用
scope字段控制令牌能访问哪些接口,遵循「申请多少给多少」的最小权限原则
这条约定的价值:令牌刷新、权限控制的逻辑都是通用的,你不用每个平台单独写一套刷新逻辑
隐式模式(已不推荐)
密码模式
用户直接把自己的账号密码交给客户端,客户端拿着账号密码去申请令牌。
- 前提:客户端必须被用户高度信任(比如公司自家的官方 App)。
- 局限:本质上还是把密码交给了第三方,违背了 OAuth2 的设计初衷,仅用于完全可信的内部场景。
客户端凭证模式
全程没有用户参与,是「应用对应用」的纯后台认证模式。
- 场景:微服务之间的后台调用、定时任务访问接口、第三方应用拉取平台公开数据等。
- 流程:客户端直接用
client_id+client_secret向授权服务器申请令牌,校验通过后颁发令牌,客户端即可访问资源
PKCE 增强授权码
PKCE(Proof Key for Code Exchange)是针对纯前端应用、移动端 App这类「无法安全保存 client_secret 的公开客户端」设计的增强方案。
对接过程
申请接入资质
这是所有对接的起点,目的是拿到你的「身份凭证」,让第三方平台认识你的应用。
-
注册开发者账号并创建应用
去对应平台的开放平台 / 开发者后台,注册开发者账号,新建一个「OAuth 应用 / 网站应用」
比如 GitHub 在 Developer settings 里创建 OAuth Apps,微信在微信开放平台创建网站应用
-
配置回调地址(
redirect_uri)在平台后台填写授权成功后的回调地址,比如
http://localhost:8080/oauth/callback/github关键点:这个地址必须和你代码里写的完全一致,多一个斜杠、大小写不同都会导致授权失败,这是新手最常踩的坑
-
获取核心凭证
创建完成后,你会得到两个关键值,存入你项目的配置文件里:
client_id:客户端公开标识,可以放在前端跳转链接里client_secret:客户端密钥,只能存在后端,绝对不能泄露到前端 / App 包
-
记下 3 个核心接口地址
从平台文档里复制好,后续代码里会用到:
- 授权页面地址(用户跳转过去点同意的页面)
- 令牌兑换接口地址(用 code 换 access_token 的接口)
- 用户信息接口地址(用 access_token 拉取用户资料的接口)
后端开发 1:生成授权链接,引导用户跳转
写一个简单的接口,作用是拼接好合规的授权跳转链接,把用户重定向到第三方的授权页面。
你要做的事:
- 生成随机字符串
state,存入 Redis/Session(设置 5 分钟过期),用于后续回调校验防攻击。 - 按 OAuth2 约定的参数名,拼接授权 URL:
- 固定参数:
response_type=code - 身份参数:
client_id - 回调参数:
redirect_uri - 权限参数:
scope(比如只获取用户基础信息) - 安全参数:
state
- 固定参数:
- 让用户重定向到这个拼接好的地址。
这一步对应 OAuth2 约定的「发起授权请求」,所有平台参数名统一,只需要改域名和路径
后端开发 2:接收回调,完成信息兑换
写一个回调接口(就是你配置的 redirect_uri),这是整个流程的核心,用户点完同意后,第三方会带着 code 跳回这里。
你要做的事(按顺序):
-
校验 state:先比对返回的 state 和你存的是否一致,不一致直接拒绝,防范 CSRF 攻击;校验通过后删除已使用的 state。
-
用 code 兑换 access_token:
- 在后端发起 POST 请求,调用第三方的令牌接口
- 按约定传参:
grant_type=authorization_code、code、client_id、client_secret、redirect_uri - 从返回结果里解析出
access_token
-
用 access_token 拉取用户信息:
- 把 access_token 放在请求头里,调用第三方的用户信息接口
- 拿到用户唯一标识(比如 GitHub 的
id、微信的openid)、昵称、头像等基础信息
这两步都是后端纯后台调用,完全不经过用户浏览器,这也是授权码模式安全的核心原因
绑定自有账号,签发你自己的令牌
第三方授权只是「证明了这个用户是谁」,最终要落地到你自己的系统里。这一步做完,第三方登录就和你原有登录体系打通了
你要做的事:
- 匹配本地用户 :用第三方的唯一标识(比如
github_id、wx_openid)去你的用户表里查询,看这个第三方账号有没有绑定过本地用户。 - 处理新用户 :
- 如果没查到,有两种做法:
- 自动创建一个新用户,把第三方唯一 ID、昵称、头像存进去
- 跳转到绑定手机号 / 用户名的页面,让用户绑定已有账号或完善信息
- 如果没查到,有两种做法:
- 签发自有令牌 :
- 找到 / 创建用户后,和「用户名密码登录」走完全一样的逻辑,生成你自己系统的 Token(JWT 或 Redis 令牌都可以)
- 返回前端 :把 Token 传给前端(可以重定向到首页并带 token,也可以返回 JSON)
做完这一步,后续就和第三方没关系了:前端携带你签发的自有 Token 请求接口,你的拦截器正常校验 Token 即可,完全不用区分是密码登录还是第三方登录。
用户认证
认证方式多样化
- 不同的认证提交方式的数据不一样,例如
- 手机加验证码方式:会提交手机号和验证码
- 账号密码方式:会提交账号、密码、验证码
- 我们可以在loadUserByUsername()方法上做文章,将用户原来提交的账号数据改为提交一个JSON数据,JSON数据可以扩展不同的认证方式所提交的各种参数
将用户原来提交的账号数据改为提交一个JSON数据,JSON数据可以扩展不同的认证方式所提交的各种参数
首先创建一个DTO类用于接收各种认证参数
一个接口的多种实现,我们依靠beanName来做区分
认证入口
统一的登录接口 POST /api/user/login,接收 LoginDTO,通过 authType 字段路由到不同的认证实现
// LoginDTO 中的关键字段
authType → password / sms / email_code / wechat / face
username → 用户名
password → 密码
phone → 手机号
email → 邮箱
code → 短信/邮箱验证码
wxCode → 微信临时登录 code
faceToken → 人脸识别 token
策略模式
@Autowired Map 注入:https://docs.spring.io/spring-framework/reference/core/beans/annotation-config/autowired.html#beans-autowired-annotation-qualifiers
教科书策略模式(GoF)
// 客户端自己决定用哪个策略
AuthService strategy = new PasswordAuthService(); // ❌ 硬编码
Context ctx = new Context(strategy);
ctx.execute(dto);
- 客户端负责:创建具体策略对象、传递给上下文
- 新增策略 → 客户端代码也需要改(
new的地方要加) - 组件间通过手动装配耦合
Spring Boot 策略模式
java
// Spring 自动把全部策略注入到 Map
@Autowired
private Map<String, AuthService> serviceMap;
public AuthService getService(String authType) {
return serviceMap.get(authType + "AuthService");
}
需要Bean name 与 lookup key 之间有可推导的约定
java
// 路由时: authType + "AuthService"
serviceMap.get(authType + "AuthService");
// 所以 authType = "password" → 必须 bean name = "passwordAuthService"
- 命名不一致 → 匹配不上 → 抛
"不支持的认证方式"
这个命名是否要显示取决于你前端的字段了,如果 @Service 不命名,Spring 默认 bean name = 类名首字母小写
好了接下来就可以为每个认证策略写一个service了
五种认证策略
| 策略 | 流程 |
|---|---|
密码登录 (password) |
支持用户名或手机号 + 密码 → 查 User 表 → SHA-256 + 随机盐校验密码 → 返回 User |
邮箱验证码 (email_code) |
先发验证码到邮箱(存 Redis,5分钟过期)→ 提交验证码校验 → 用户不存在则自动注册 |
短信验证码 (sms) |
验证码存 Redis → 校验通过后按手机号查/建用户 → 自动注册 |
微信登录 (wechat) |
小程序调用 wx.login() 拿到 code → 后端调微信 code2session 获取 openid → 按 openid/unionid 查/建用户 → 自动注册 |
人脸识别 (face) |
目前是存根(Mock),模拟接收 faceToken 后创建用户 |
用户名密码登录
- 写一个
/login接口,接收用户名和密码 - 查数据库对应用户,用
BCrypt等加密算法比对密码(禁止明文存密码) - 校验通过后,生成 Token(可以是 JWT,也可以是随机字符串存 Redis),返回给前端
- 后续请求通过全局拦截器,从请求头取 Token 校验有效性,把用户信息存入 ThreadLocal
短信验证码、指纹、人脸登录
这几类本质都是「借第三方能力做身份校验,通过后发自己的 Token」,和安全框架没有绑定关系:
- 短信登录:后端生成 6 位验证码存 Redis(设置 5 分钟过期),调用阿里云 / 腾讯云短信接口下发;登录接口校验手机号和验证码,匹配到用户就发 Token。
- 指纹 / 人脸登录:生物特征采集和比对都在客户端 / 第三方 SDK 完成,后端只接收第三方返回的「校验成功凭证」,验证凭证有效后,对应用户签发 Token 即可。后端本身不处理生物数据,全程都是业务逻辑
第三方账号登录
用 OAuth2 第三方登录 ≠ 必须用 Spring Security OAuth2
你只需要做两件事:
- 调微信 SDK (小程序
wx.login()或 web 的window.location跳转)→ 拿到code - 把 code 发给自己的后端 → 拿到 Token
OAuth2 里服务端和微信服务器之间的 code 换 access_token、换 openid、校验签名------跟你没关系
OAuth2 的复杂握手是服务端和微信之间的事,你只管:① 拿 code ② 发 code ③ 收 Token
后端把微信 API 调用、openid 查/建用户全封装在一个接口里了,你调这个接口就相当于在说:
"给你 code,帮我搞定一切,我只要 Token
PC微信登入
微信登录
- PC 扫码登录(OAuth2):https://developers.weixin.qq.com/doc/oplatform/Website_App/WeChat_Login/Wechat_Login.html(https://developers.weixin.qq.com/doc/oplatform/Website_App/WeChat_Login/Wechat_Login.html
- 接口文档:https://developers.weixin.qq.com/doc/oplatform/Website_App/WeChat_Login/Wechat_Login.html
!\[Pasted image 20260624101929.png]
第 1 步:用户点击"微信登录"
你的页面跳转到微信的二维码页面。微信展示一个二维码,上面写着"扫一扫登录"。
------ 到这里,用户已经从你的网站跑到微信的地盘了。
第 2 步:用户掏出手机扫码
手机微信扫了码,手机上会弹出一个确认页:"是否授权登录 XX 网站?" 用户点"允许"。
第 3 步:微信通知你的后端
用户点允许后,微信拿着一个临时凭证(code),直接回调到你后端配置好的地址:你的后端/wxLogin?code=xxx
第 4 步:后端拿 code 去找微信换用户信息
你的后端收到 code 后,做了两件事:
- 先换令牌 :把
code发给微信 → 微信返回access_token和openid - 再查资料 :拿
access_token去问微信"这人叫什么?头像是什么?" → 微信返回昵称、头像、unionid
第 5 步:后端存用户 / 发 Token
拿到微信的用户信息后:
- 查数据库:这个
unionid有没有注册过? - 有 → 直接返回已有用户
- 没有 → 自动建一个新用户(用户名=unionid,昵称=微信昵称,头像=微信头像)
最后生成 Token,重定向回前端页面,URL 上带好 Token
- 参数名标准化 :
client_id、client_secret、grant_type、response_type、redirect_uri、code、access_token------所有 OAuth2 服务都用这套命名,微信、GitHub、Google 全一样 - code 一次性,secret 不外传 :code 允许在 URL 上传递,
client_secret必须只留在后端 - Bearer Token 放请求头 :拿到的 access_token 用
Authorization: Bearer xxx传
学一个 OAuth2,GitHub 登录、Google 登录、微信登录全通了。 接口 URL 不同,协议结构一模一样
接入微信/GitHub/Google 等第三方登录
基本不用自己写 ------ 有现成的库:
| 技术栈 | 轮子 |
|---|---|
| Spring Boot | spring-security-oauth2-client 或 justify/justify-oauth2-client |
你只需要配置 appid + secret + 回调地址,库帮你把:
-
生成跳转微信的 URL
-
接收 code
-
调微信换 access_token
-
拿用户信息
全包了
要自己写的部分:// 你只需要写这个
用户登录成功 → UUID / JWT 生成 Token → 存 Redis → 返回前端
前端请求 → 拦截器取 Token → 查 Redis → 通过/拒绝
这个逻辑跟 OAuth2 没关系,是你自己系统的登录态管理
登录二维码
二维码是微信生成的,你只要拼个 URL 跳过去
直接跳转
微信的二维码不需要你生成,你只需要把用户引导到微信的授权页面,微信自己会展示二维码。
js
<!-- 你的页面上放一个按钮 -->
<a href="https://open.weixin.qq.com/connect/qrconnect?
appid=YOUR_APPID
&redirect_uri=YOUR_CALLBACK_URL
&response_type=code
&scope=snsapi_login
&state=STATE">
微信登录
</a>
用户点这个链接:
- 浏览器跳转到微信的页面
- 微信页面上自动显示二维码
- 用户掏出手机扫码
二维码是微信页面自带的,你不需要生成它的图片,只要让用户跳过去就行。
内嵌二维码(PC 端更友好)
如果你非要自己展示二维码图片(更常见)
微信提供了 扫码登录的 JS 工具 ,可以在你的页面上直接嵌入二维码
这里采用的是将微信登录二维码内嵌到自己页面,然后用户扫码登录,查看官方文档的使用说明
微信提供了 扫码登录的 JS 工具,可以在你的页面上直接嵌入二维码:
js
<script src="https://res.wx.qq.com/connect/zh_CN/htmledition/js/wxLogin.js"></script>
<script>
new WxLogin({
id: 'qrcode_container', // 你页面上的 div id
appid: '你的APPID',
scope: 'snsapi_login',
redirect_uri: '你的回调地址',
state: '随机值'
})
</script>
这个 JS 会在你的页面上渲染一个二维码,用户扫了之后流程和前面一样
二维码不需要你生成,微信提供。你要做的只是在页面上引导用户跳到微信的二维码页面(或用微信的 JS 在页面上嵌入二维码),剩下的微信自己搞定
公网域名
内网穿透 + 微信测试号
改用本地 hosts + 自签证书(不推荐)
前端 Mock 回调,只测前端流程(不验微信服务端)
接入微信登录 (回调到后端)
- 微信回调地址 指向后端:
/wxLogin是后端 Controller 的接口,微信带着code直接请求它 - code 不进前端 :
code在后端被wxAuth(code)消费,换取access_token、获取用户信息、入库,整个过程对浏览器透明 - 后端主动重定向到前端 :处理完成后,用
return "redirect:http://localhost/sign.html?username=..."把用户"踢回"前端页面,前端再从 URL 参数里拿username和authType
接入分析
根据OAuth2协议授权码流程,结合本项目自身特点,分析接入微信扫码登录的流程
!\[Pasted image 20260624102147.png]
Controller 收 code → Service 拿 code 找微信换 access_token → 拿 access_token 换用户资料 → 存到自己数据库(没来过就自动注册) → 重定向回前端带 Token
定义接口
定义WxLoginController类
被动等微信来调 → 处理业务 → 主动把用户踢回前端页面
既是收微信重定向,也是发重定向回前端
java
@Slf4j
@Controller
public class WxLoginController {
@Autowired
WxAuthServiceImpl wxAuthService;
@RequestMapping("/wxLogin")
public String wxLogin(String code, String state) throws IOException {
log.debug("微信扫码回调,code:{},state:{}",code,state);
XcUser xcUser = wxAuthService.wxAuth(code);
if(xcUser==null){
return "redirect:http://localhost/error.html";
}
String username = xcUser.getUsername();
return "redirect:http://localhost/sign.html?username="+username+"&authType=wx";
}
}
定义微信认证的service
java
@Service("wx_authservice")
public class WxAuthServiceImpl implements AuthService {
@Autowired
XcUserMapper xcUserMapper;
public XcUser wxAuth(String code) {
//TODO: 获取access_token
//TODO: 获取用户信息
// 这里先用个假数据
XcUser xcUser = xcUserMapper.selectOne(new LambdaQueryWrapper<XcUser>().eq(XcUser::getUsername, "Kyle"));
//TODO: 添加用户信息到数据库
return xcUser;
}
/**
* 微信扫码认证,不需要校验密码和验证码
*
* @param authParamsDto 认证参数
* @return
*/
@Override
public XcUserExt execute(AuthParamsDto authParamsDto) {
// 账号
String username = authParamsDto.getUsername();
XcUser user = xcUserMapper.selectOne(new LambdaQueryWrapper<XcUser>().eq(XcUser::getUsername, username));
if (user == null) {
throw new RuntimeException("账号不存在");
}
XcUserExt xcUserExt = new XcUserExt();
BeanUtils.copyProperties(user, xcUserExt);
return xcUserExt;
}
}
申请令牌 (code 去换 access_token)
使用RestTemplate请求微信,在AuthApplication里配置RestTemplate的bean
java
@Bean
RestTemplate restTemplate(){
RestTemplate restTemplate = new RestTemplate(new OkHttp3ClientHttpRequestFactory());
return restTemplate;
}
定义一个WxAuthService,将刚刚写的wxAuth()方法提取到该接口中
java
public interface WxAuthService {
XcUser wxAuth(String code);
}
在WxAuthServiceImpl类中定义申请令牌的私有方法,刚刚我们是用的一个死数据登录的,现在我们要从微信获取真实的用户信息
java
private Map<String, String> getAccess_token(String code) {
// 1. 请求路径模板,参数用%s占位符
String url_template = "https://api.weixin.qq.com/sns/oauth2/access_token?appid=%s&secret=%s&code=%s&grant_type=authorization_code";
// 2. 填充占位符:appid,secret,code
String url = String.format(url_template, appid, secret, code);
// 3. 远程调用URL,POST方式(详情参阅官方文档)
ResponseEntity<String> exchange = restTemplate.exchange(url, HttpMethod.POST, null, String.class);
// 4. 获取相应结果,响应结果为json格式
String result = exchange.getBody();
// 5. 转为map
Map<String, String> map = JSON.parseObject(result, Map.class);
return map;
}
完善
java
@Override
public XcUser wxAuth(String code) {
//TODO: 获取access_token
Map<String, String> access_token_map = getAccess_token(code);
//TODO: 获取用户信息
// 这里先用个假数据
XcUser xcUser = xcUserMapper.selectOne(new LambdaQueryWrapper<XcUser>().eq(XcUser::getUsername, "Kyle"));
//TODO: 添加用户信息到数据库
return xcUser;
}
获取用户信息
上面我们已经成功获取到了access_token,下面我们拿着access_token来查询用户信息
在WxAuthServiceImpl中定义获取用户信息的方法
java
private Map<String, String> getAccess_token(String code) {
// 1. 请求路径模板,参数用%s占位符
String url_template = "https://api.weixin.qq.com/sns/oauth2/access_token?appid=%s&secret=%s&code=%s&grant_type=authorization_code";
// 2. 填充占位符:appid,secret,code
String url = String.format(url_template, appid, secret, code);
// 3. 远程调用URL,POST方式(详情参阅官方文档)
ResponseEntity<String> exchange = restTemplate.exchange(url, HttpMethod.POST, null, String.class);
// 4. 获取响应结果,响应结果为json格式
String result = exchange.getBody();
// 4.1 需要转码
result = new String(result.getBytes(StandardCharsets.ISO_8859_1), StandardCharsets.UTF_8);
// 5. 转为map
Map<String, String> map = JSON.parseObject(result, Map.class);
return map;
}
ok完善
@Override
public XcUser wxAuth(String code) {
//TODO: 获取access_token
Map<String, String> access_token_map = getAccess_token(code);
String accessToken = access_token_map.get("access_token");
//TODO: 获取用户信息
String openid = access_token_map.get("openid");
Map<String, String> userInfo = getUserInfo(accessToken, openid);
// 这里先用个假数据
XcUser xcUser = xcUserMapper.selectOne(new LambdaQueryWrapper<XcUser>().eq(XcUser::getUsername, "Kyle"));
//TODO: 添加用户信息到数据库
return xcUser;
}
保存用户信息
- 向数据库保存用户信息,如果用户不存在,则将其保存在数据库
- 在WxAuthServiceImpl中定义方法addWxUser()
java
public XcUser addWxUser(Map<String, String> user_info_map){
// 1. 获取用户唯一标识:unionid作为用户的唯一表示
String unionid = user_info_map.get("unionid");
// 2. 根据唯一标识,判断数据库是否存在该用户
XcUser xcUser = xcUserMapper.selectOne(new LambdaQueryWrapper<XcUser>().eq(XcUser::getWxUnionid, unionid));
// 2.1 存在,则直接返回
if (xcUser != null){
return xcUser;
}
// 2.2 不存在,新增
xcUser = new XcUser();
// 2.3 设置主键
String uuid = UUID.randomUUID().toString();
xcUser.setId(uuid);
// 2.4 设置其他数据库非空约束的属性
xcUser.setUsername(unionid);
xcUser.setPassword(unionid);
xcUser.setWxUnionid(unionid);
xcUser.setNickname(user_info_map.get("nickname"));
xcUser.setUserpic(user_info_map.get("headimgurl"));
xcUser.setName(user_info_map.get("nickname"));
xcUser.setUtype("101001"); // 学生类型
xcUser.setStatus("1");
xcUser.setCreateTime(LocalDateTime.now());
// 2.5 添加到数据库
xcUserMapper.insert(xcUser);
// 3. 添加用户信息到用户角色表
XcUserRole xcUserRole = new XcUserRole();
xcUserRole.setId(uuid);
xcUserRole.setUserId(uuid);
xcUserRole.setRoleId("17");
xcUserRole.setCreateTime(LocalDateTime.now());
xcUserRoleMapper.insert(xcUserRole);
return xcUser;
}
完善
java
// 注入自身
@Autowired
WxAuthServiceImpl wxAuthService;
@Override
public XcUser wxAuth(String code) {
//TODO: 获取access_token
Map<String, String> access_token_map = getAccess_token(code);
String accessToken = access_token_map.get("access_token");
//TODO: 获取用户信息
String openid = access_token_map.get("openid");
Map<String, String> user_info_map = getUserInfo(accessToken, openid);
//TODO: 添加用户信息到数据库,这里注意使用代理对象调用
XcUser xcUser = wxAuthService.addWxUser(user_info_map);
return xcUser;
}
这里的addWxUser()方法涉及到了多表操作,所以需要进行事务控制,而wxAuth()是非事务方法,所以这里我们需要注入自身,然后调用addWxUser()
Controller层接口
java
@RequestMapping("/wxLogin")
public String wxLogin(String code, String state) throws IOException {
log.debug("微信扫码回调,code:{},state:{}",code,state);
XcUser user = wxAuthService.wxAuth(code);
if(user==null){
return "redirect:http://localhost/error.html";
}
String username = user.getUsername();
return "redirect:http://localhost/sign.html?username="+username+"&authType=wx";
}
接入微信登录 (回调到前端)
| 步骤 | 方案 B(回调后端) | 方案 A(回调前端) |
|---|---|---|
| 微信回调地址 | 后端 /wxLogin |
前端 /callback |
| 拿 code 的人 | 后端 Controller | 前端从 URL 中提取 |
| 调用登录接口 | 后端内部直接调用 service | 前端调用 POST /api/user/login |
| token 传递 | 后端 302 重定向,拼在 URL 上 | 接口响应 body 中返回,前端存入 storage |
| 前端角色 | 被动接收 URL 参数解析 | 主动发起登录请求,完全掌控流程 |
用户授权后,微信会重定向到 https://你的前端域名/callback?code=XXX&state=YYY。
前端这个页面(如 WxCallback.vue 或对应路由组件)在 mounted 时做几件事:
看下演示
javascript
// 从 URL 获取 code 和 state
const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get('code');
const state = urlParams.get('state');
// 校验 state 是否与之前生成的一致(防 CSRF)
if (state !== 之前存的state) { 报错 }
// 立刻用 code 调用后端 API,换取 token
const res = await axios.post('/api/user/login', {
loginType: 'wechat',
code: code
});
// 拿到 token,存入 localStorage/cookie
localStorage.setItem('token', res.data.token);
// 清除 URL 中的 code,避免刷新重复使用或暴露
window.history.replaceState({}, '', window.location.pathname);
// 跳转到首页或目标页
router.push('/');
实际我们用下面的那种
接收 code 并调用后端
微信授权成功后,会重定向到 https://你的前端域名/callback?code=xxx&state=yyy
你需要一个专门的前端路由来处理这个回调(例如 /callback),这个页面没有任何 UI,纯逻辑
javascript
// 例如在 Vue Router 的 /callback 页面 onMounted 中执行
const route = useRoute();
const router = useRouter();
// 1. 取参数
const code = route.query.code;
const state = route.query.state;
// 2. 校验 state(防 CSRF)
const savedState = sessionStorage.getItem('wx_state');
if (!state || state !== savedState) {
alert('非法请求');
return;
}
sessionStorage.removeItem('wx_state'); // 清除,保证一次性
// 3. 调后端统一登录接口
try {
const res = await axios.post('/api/user/login', {
loginType: 'wechat',
code: code
});
// 4. 存储 token(按你们的方式:localStorage / cookie / vuex)
localStorage.setItem('token', res.data.token);
// 5. 清除 URL 上的 code 和 state,避免刷新重复使用
window.history.replaceState({}, '', window.location.pathname);
// 6. 跳转到首页或目标页
router.push('/');
} catch (err) {
// 登录失败,跳转到错误页或重新弹出登录
router.push('/login?error=wechat_fail');
}
小程序端认证
- 小程序登录(code2session):https://developers.weixin.qq.com/miniprogram/dev/OpenApiDoc/user-login/code2Session.html
id
微信公众平台测试号
公众号 或 小程序
前往 微信公众平台 :
🔗 https://mp.weixin.qq.com/
登录后操作路径:
-
左侧菜单找到 开发 → 开发管理(旧版叫"开发"→"基本配置")
-
在页面中就能看到 AppID(应用ID) 和 AppSecret(应用密钥)
-
如果 AppSecret 被隐藏了,点「重置」会生成一个新的,重置后旧的立即失效,且新密钥只显示一次,务必当场保存
小程序和公众号的获取路径基本一致,都是在这个平台。
2. 移动应用(App)、网站应用
前往 微信开放平台 :
🔗 https://open.weixin.qq.com/
登录后操作路径:
-
进入 管理中心,选择你已创建的应用
-
点击应用进入详情,在应用详情页即可看到 AppID
-
查看 AppSecret 需要扫码验证,同样只显示一次
框架依赖使用
| 自己写的问题 | 库帮你解决 |
|---|---|
| Token 过期没处理 | 库自动刷新 |
| 网络异常没重试 | 库有重试机制 |
| 微信接口变了要改代码 | 库升级版本就行 |
| 多一个第三方登录(GitHub、Google)又要写一套 | 库配个配置就行 |
| 安全性容易漏(state 参数防 CSRF) | 库默认带着 |
Spring Boot 用这个:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
配置文件里写:
spring:
security:
oauth2:
client:
registration:
wechat:
client-id: ${WECHAT_APPID}
client-secret: ${WECHAT_SECRET}
redirect-uri: "{baseUrl}/login/oauth2/code/wechat"
就这些,接入完成
区别
| 小程序登录 | Web 扫码登录 | |
|---|---|---|
| 运行环境 | 微信小程序内部 | 浏览器 |
| 拿 code 方式 | 你调 wx.login() 直接拿到 |
微信回调你后端的 URL,URL 上带着 |
| 有没有二维码 | 没有,直接弹授权窗口 | 有,微信展示二维码让用户扫 |
| 用户体验 | 点一下"允许"就行 | 掏出手机 → 扫 → 点确认 |
| 你干啥 | 主动调 API 拿 code → 发后端 | 后端开个接口等着被调 |
| OAuth2 变种 | 隐式授权(简化版) | 标准授权码模式 |
小程序端(最典型):
// 第一步:拿 code
const { code } = await wx.login()
// 第二步:发给自己的后端
const res = await axios.post('/api/user/login', {
authType: 'wechat',
wxCode: code
})
// 拿到 token,存起来
localStorage.setItem('token', res.data.token)
Web 端(扫码登录):
// 第一步:跳转微信 OAuth 页面,用户扫码后微信回调你的后端
window.location.href = 'https://open.weixin.qq.com/connect/qrconnect?...'
// 第二步:微信会把 code 拼在回调 URL 上,你的后端自己处理完了
// 直接重定向回前端页面,URL 上带着 token
// 你从 URL 取 token 就完事
const token = new URLSearchParams(window.location.search).get('token')
前端处理
存储 token 和后续请求
- 存在
localStorage:token和profile - Axios 拦截器自动在请求头加
Authorization: Bearer {token} - 响应 401 时自动清除并跳转
/login
登录成功后,token 已存入 localStorage。之后的请求需要在拦截器中自动带上:
javascript
// axios 拦截器
axios.interceptors.request.use(config => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
这一步和你项目中已有的密码登录、手机号登录完全一致,完全复用
处理未登录或 token 过期
在 axios 响应拦截器中统一处理,如果后端返回 401,就跳回登录页:
javascript
axios.interceptors.response.use(
response => response,
error => {
if (error.response?.status === 401) {
localStorage.removeItem('token');
router.push('/login');
}
return Promise.reject(error);
}
);
统一登录态校验(后端拦截)
所有登录方式最终都生成同一种 Token,用一个全局拦截器统一校验:
- 从请求头
Authorization中取出 Token - 解析 / 查 Redis 验证有效性,无效则返回 401
- 有效则把用户 ID、角色等信息存入 ThreadLocal
- 请求结束后清理 ThreadLocal,防止线程复用串号
-
请求到达后端,先被你这个
AdminAuthInterceptor拦截- 如果 token 有效且角色是 admin → 返回 200,正常数据
- 如果 token 有问题 → 返回 401
-
响应回到前端,被
axios响应拦截器拦截- 看到 200 → 该干嘛干嘛,绝不跳登录
- 看到 401 → 清空 token,跳转登录页
有 token 就不跳登录
权限校验
功能权限校验
pring Security = 权限注解 + 用户认证 + OAuth2 + CSRF保护 + Session管理
+ 密码加密 + 记住我功能 + 安全头 + ...
| 方式 | 解决什么问题 |
|---|---|
| 不同 Service 方法 | 不同角色看的数据范围不一样(用户看上架商品,管理员看全部) |
| 角色字段过滤 | 同角色内谁能点某个按钮(管理员A能退款,B不能) |
| 注解方案 | 权限校验的代码写法(用注解代替手写 if) |
| RBAC 表 | 权限数据存在哪(5张表 vs 1个字段) |
可以组合用的。比如一个大项目可能:
不同 Service 方法 → 控制数据范围(用户/管理员看不同数据)
+
注解方案 → 控制功能权限(方法上 @RequirePermission)
+
RBAC 表 → 存权限数据(后台可配)
拦截器方案 - 路径隔离
角色字段过滤
user 表加一个权限字段:admin_permissions
值存: "order:view,order:ship" 或 "order:view,order:ship,order:refund"
一个角色(管理员)内部再分细粒度权限
Controller 或 Service 里手写判断:
java
public void refundApprove(...) {
String perms = UserHolder.get("adminPermissions");
if (!perms.contains("order:refund")) {
throw new BusinessException("无退款权限");
}
// ... 退款逻辑
}
特点 :不建新表、不改架构,一个字段解决。
适用:角色少,但同一个角色内部要分细粒度权限。
注解方案
等你的权限系统长到这样的时候:
// 需求变成这样
@PreAuthorize("hasRole('admin') and hasPermission(#orderId, 'order:ship')")
// 或者要集成第三方 OAuth 登录(微信、GitHub)
// 或者要 LDAP、SSO 单点登录
但你的项目只是一个外卖系统,只有"用户"和"管理员"两种角色,用拦截器 + UserHolder 就已经完全够用了
RBAC 表(企业级)
当系统角色超过 3 个、权限点超过 20 个,或需要支持后台动态配置时,RBAC 表结构 + 方法级注解方案是唯一可持续的选择
数据表设计
标准 RBAC(五张表)
sql
-- 用户表(已有,不需要额外建)
CREATE TABLE sys_user (id BIGINT PRIMARY KEY, username VARCHAR(50), ...);
-- 角色表
CREATE TABLE sys_role (id BIGINT PRIMARY KEY, name VARCHAR(50), data_scope TINYINT DEFAULT 1);
-- 菜单/权限表
CREATE TABLE sys_menu (id BIGINT PRIMARY KEY, perms VARCHAR(50), ...);
-- 用户-角色关联(联合主键:同一个用户不能重复关联同一个角色)
CREATE TABLE sys_user_role (user_id BIGINT, role_id BIGINT, PRIMARY KEY(user_id, role_id));
-- 角色-权限关联
CREATE TABLE sys_role_menu (role_id BIGINT, menu_id BIGINT, PRIMARY KEY(role_id, menu_id));
-- 部门表(企业版需要)
CREATE TABLE sys_dept (id BIGINT PRIMARY KEY, parent_id BIGINT, name VARCHAR(50));
精简版
用户少、角色少的小项目,直接在 user 表加一个字段:
ALTER TABLE sys_user ADD COLUMN role TINYINT DEFAULT 0;
-- 0 = 普通用户, 1 = 管理员
数据权限
不同 Service 方法 - API 层隔离
符合 REST 原则 :端点路径明确表达语义(/my 表示归属关系,/admin/ 表示高权限域)
没有在 Service 里写 if(role == admin) 这种脏代码
⚠️ 关键原则 :
数据范围逻辑应封装在 Service 方法内 (如 findOrdersByUserId),而非通过全局拦截器隐式注入。
若强行用 DAO 拦截器实现,会导致:
- 普通用户误调用管理员接口时仍能返回数据(仅过滤部分字段)
- 无法支持"管理员代查某用户数据"等复杂场景
DAO 层拦截
通过 MyBatis 拦截器自动拼接 WHERE user_id=?(次选,仅当 API 层无法隔离时使用)
业务校验
权限校验 (前置):判断「能否操作」(身份/角色/数据范围),失败即拒绝请求(403)
业务校验(过程中):判断「操作是否合法」(业务规则),失败返回业务错误(如库存不足)
权限校验 → Controller层或注解 (如@PreAuthorize),拦截非法请求,避免执行业务逻辑
业务校验 → Service层,基于业务上下文(状态机、库存等)验证操作合法性
Spring Security认证
- Spring Security是一个功能强大且可高度定制的身份验证和访问控制框架,它是一个专注于为Java应用程序提供身份验证和授权的框架
集成Spring Security
依赖
-
向pom.xml中加入Spring Security所需的依赖
org.springframework.cloud spring-cloud-starter-security org.springframework.cloud spring-cloud-starter-oauth2
配置
创建WebSecurityConfig配置类,继承WebSecurityConfigurerAdapter
业务流程
- 通过
authorizeRequests()方法来配置请求授权规则。 - 使用
antMatchers()方法指定需要进行访问控制的URL路径模式。在这里,/r/**表示所有以/r/开头的 URL 都需要进行授权访问。 - 使用
authenticated()方法指定需要进行身份验证的请求。 - 使用
anyRequest()方法配置除了/r/**以外的所有请求都不需要进行身份验证。 - 使用
permitAll()方法表示任何用户都可以访问不需要进行身份验证的URL。 - 使用
formLogin()方法配置登录页表单认证,其中successForwardUrl()方法指定登录成功后的跳转页面。 - 使用
logout()方法配置退出登录,其中logoutUrl()方法指定退出登录的URL。