权限系统设计

用户授权

三层防御总览

看懂这一节,你需要先知道: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 的工作方式:

  1. 用户登录成功,服务端在内存 / Redis 里存一份登录信息,叫 Session。
  2. 给客户端返回一个sessionId,存在 Cookie 里。
  3. 下次请求带 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)

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 协议定义了两类核心令牌,职责严格分离:

  1. Access Token(访问令牌)

    • 作用:访问资源服务器的唯一凭证,每次调用业务接口都必须携带。
    • 特点:有效期短(通常 1~2 小时),降低泄露后的风险。
    • 格式:协议不做强制规定,可以是随机字符串(不透明令牌),也可以是 JWT。
  2. Refresh Token(刷新令牌)

    • 作用:仅用于在 Access Token 过期后,向授权服务器换取新的 Access Token,不能用来访问业务资源。
    • 特点:有效期长(7~30 天),仅在客户端和授权服务器之间交互,不传递给资源服务器。
    • 价值:避免用户频繁重新登录,同时兼顾安全。
  3. 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 的公开客户端」设计的增强方案。

对接过程

申请接入资质

这是所有对接的起点,目的是拿到你的「身份凭证」,让第三方平台认识你的应用。

  1. 注册开发者账号并创建应用

    去对应平台的开放平台 / 开发者后台,注册开发者账号,新建一个「OAuth 应用 / 网站应用」

    比如 GitHub 在 Developer settings 里创建 OAuth Apps,微信在微信开放平台创建网站应用

  2. 配置回调地址(redirect_uri)

    在平台后台填写授权成功后的回调地址,比如 http://localhost:8080/oauth/callback/github

    关键点:这个地址必须和你代码里写的完全一致,多一个斜杠、大小写不同都会导致授权失败,这是新手最常踩的坑

  3. 获取核心凭证

    创建完成后,你会得到两个关键值,存入你项目的配置文件里:

    • client_id:客户端公开标识,可以放在前端跳转链接里
    • client_secret:客户端密钥,只能存在后端,绝对不能泄露到前端 / App 包
  4. 记下 3 个核心接口地址

    从平台文档里复制好,后续代码里会用到:

    • 授权页面地址(用户跳转过去点同意的页面)
    • 令牌兑换接口地址(用 code 换 access_token 的接口)
    • 用户信息接口地址(用 access_token 拉取用户资料的接口)
后端开发 1:生成授权链接,引导用户跳转

写一个简单的接口,作用是拼接好合规的授权跳转链接,把用户重定向到第三方的授权页面。

你要做的事:

  1. 生成随机字符串 state,存入 Redis/Session(设置 5 分钟过期),用于后续回调校验防攻击。
  2. 按 OAuth2 约定的参数名,拼接授权 URL:
    • 固定参数:response_type=code
    • 身份参数:client_id
    • 回调参数:redirect_uri
    • 权限参数:scope(比如只获取用户基础信息)
    • 安全参数:state
  3. 让用户重定向到这个拼接好的地址。

这一步对应 OAuth2 约定的「发起授权请求」,所有平台参数名统一,只需要改域名和路径

后端开发 2:接收回调,完成信息兑换

写一个回调接口(就是你配置的 redirect_uri),这是整个流程的核心,用户点完同意后,第三方会带着 code 跳回这里。

你要做的事(按顺序):

  1. 校验 state:先比对返回的 state 和你存的是否一致,不一致直接拒绝,防范 CSRF 攻击;校验通过后删除已使用的 state。

  2. 用 code 兑换 access_token:

    • 在后端发起 POST 请求,调用第三方的令牌接口
    • 按约定传参:grant_type=authorization_code、code、client_id、client_secret、redirect_uri
    • 从返回结果里解析出 access_token
  3. 用 access_token 拉取用户信息:

    • 把 access_token 放在请求头里,调用第三方的用户信息接口
    • 拿到用户唯一标识(比如 GitHub 的 id、微信的 openid)、昵称、头像等基础信息

这两步都是后端纯后台调用,完全不经过用户浏览器,这也是授权码模式安全的核心原因

绑定自有账号,签发你自己的令牌

第三方授权只是「证明了这个用户是谁」,最终要落地到你自己的系统里。这一步做完,第三方登录就和你原有登录体系打通了

你要做的事:

  1. 匹配本地用户 :用第三方的唯一标识(比如 github_id、wx_openid)去你的用户表里查询,看这个第三方账号有没有绑定过本地用户。
  2. 处理新用户 :
    • 如果没查到,有两种做法:
      • 自动创建一个新用户,把第三方唯一 ID、昵称、头像存进去
      • 跳转到绑定手机号 / 用户名的页面,让用户绑定已有账号或完善信息
  3. 签发自有令牌 :
    • 找到 / 创建用户后,和「用户名密码登录」走完全一样的逻辑,生成你自己系统的 Token(JWT 或 Redis 令牌都可以)
  4. 返回前端 :把 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

你只需要做两件事:

  1. 调微信 SDK (小程序 wx.login() 或 web 的 window.location 跳转)→ 拿到 code
  2. 把 code 发给自己的后端 → 拿到 Token

OAuth2 里服务端和微信服务器之间的 code 换 access_token、换 openid、校验签名------跟你没关系


OAuth2 的复杂握手是服务端和微信之间的事,你只管:① 拿 code ② 发 code ③ 收 Token

后端把微信 API 调用、openid 查/建用户全封装在一个接口里了,你调这个接口就相当于在说:

"给你 code,帮我搞定一切,我只要 Token

PC微信登入

微信登录

第 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

  1. 参数名标准化 :client_id、client_secret、grant_type、response_type、redirect_uri、code、access_token------所有 OAuth2 服务都用这套命名,微信、GitHub、Google 全一样
  2. code 一次性,secret 不外传 :code 允许在 URL 上传递,client_secret 必须只留在后端
  3. 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>

用户点这个链接:

  1. 浏览器跳转到微信的页面
  2. 微信页面上自动显示二维码
  3. 用户掏出手机扫码

二维码是微信页面自带的,你不需要生成它的图片,只要让用户跳过去就行。


内嵌二维码(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');
}

小程序端认证

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,防止线程复用串号
  1. 请求到达后端,先被你这个 AdminAuthInterceptor 拦截

    • 如果 token 有效且角色是 admin → 返回 200,正常数据
    • 如果 token 有问题 → 返回 401
  2. 响应回到前端,被 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

业务流程

  1. 通过 authorizeRequests() 方法来配置请求授权规则。
  2. 使用 antMatchers() 方法指定需要进行访问控制的 URL 路径模式。在这里,/r/** 表示所有以 /r/ 开头的 URL 都需要进行授权访问。
  3. 使用 authenticated() 方法指定需要进行身份验证的请求。
  4. 使用 anyRequest() 方法配置除了 /r/** 以外的所有请求都不需要进行身份验证。
  5. 使用 permitAll() 方法表示任何用户都可以访问不需要进行身份验证的 URL。
  6. 使用 formLogin() 方法配置登录页表单认证,其中 successForwardUrl() 方法指定登录成功后的跳转页面。
  7. 使用 logout() 方法配置退出登录,其中 logoutUrl() 方法指定退出登录的 URL。
相关推荐
小羊没烦恼!4 天前
微服务化的基石——持续集成
java·大数据·word·powerpoint·.net
俊昭喜喜里4 天前
java中的继承和多态的区别
java
小羊没烦恼!4 天前
初探性能优化——2个月到4小时的性能提升
java·开发语言·windows·算法·c#
譕痕4 天前
JSONObject与JSONArray封装数据格式区别
java·json
胡写代码4 天前
别再前后端各写一套表单校验了
java·后端
小鱼能吃糖4 天前
缺陷修复总览 · mall电商项目:5类缺陷,1个病根,4个业务域
java·电商
此时不提桶,更待何时4 天前
01-06-A-JVM排查实战详解
java·jvm
vipxieliang4 天前
ValidX 在 DDD 领域驱动设计中的实践
java·spring boot
ba_pi4 天前
mysql 查询所有表名并授权
java
ProcessOn官方账号4 天前
java函数式编程--入门基础
java·编程·函数式编程