权限系统设计

用户授权

三层防御总览

看懂这一节,你需要先知道:HTTP 请求的结构(URL、Header、Body)、服务端收到请求后的基本处理流程(请求 → 处理 → 返回)。

一个系统里,"鉴权"其实要回答三个不同的问题:

  • 你是谁?(认证)------ 你是不是本系统的用户
  • 你能做什么?(功能权限)------ 你能不能删订单、能不能看后台
  • 你能看哪些?(数据权限)------ 同为"普通用户",你只能看自己的订单,不能看别人的

很多新手把三层混在一起处理,结果就是:忘记加数据权限的 SQL 条件,普通用户也能查全表。分层的好处是每一层独立校验,漏了一层不会全崩

复制代码
请求(带 JWT)
    │
    ▼
第一层:身份认证 ─── 你是谁?
    解析 JWT → 得到 userId、role
    没过 → 401,连门都进不来
    │
    ▼
第二层:功能权限 ─── 你能做什么?
    RBAC:用户→角色→权限标识
    判断你有没有 "order:delete" 这个权限
    没过 → 403,进来了但没资格
    │
    ▼
第三层:数据权限 ─── 你能看哪些数据?
    SQL 自动拼接 WHERE user_id = 当前用户
    或者 dept_id IN (你的部门及子部门)
    没过 → 返回空数据,看不到不该看的

第二层:功能权限(你能做什么)

看懂这一节,你需要先知道 :数据库多对多关系(一个学生选多门课 → 需要中间表)、Vue 基本指令(v-ifv-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_idcodeaccess_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(固定标识)、codeclient_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_codecodeclient_idclient_secretredirect_uri
    • 从返回结果里解析出 access_token
  3. 用 access_token 拉取用户信息

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

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

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

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

你要做的事:

  1. 匹配本地用户 :用第三方的唯一标识(比如 github_idwx_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 里服务端和微信服务器之间的 codeaccess_token、换 openid、校验签名------跟你没关系


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

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

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

PC微信登入

微信登录

第 1 步:用户点击"微信登录"

你的页面跳转到微信的二维码页面。微信展示一个二维码,上面写着"扫一扫登录"。

------ 到这里,用户已经从你的网站跑到微信的地盘了。

第 2 步:用户掏出手机扫码

手机微信扫了码,手机上会弹出一个确认页:"是否授权登录 XX 网站?" 用户点"允许"。

第 3 步:微信通知你的后端

用户点允许后,微信拿着一个临时凭证(code),直接回调到你后端配置好的地址:你的后端/wxLogin?code=xxx

第 4 步:后端拿 code 去找微信换用户信息

你的后端收到 code 后,做了两件事:

  • 先换令牌 :把 code 发给微信 → 微信返回 access_tokenopenid
  • 再查资料 :拿 access_token 去问微信"这人叫什么?头像是什么?" → 微信返回昵称、头像、unionid

第 5 步:后端存用户 / 发 Token

拿到微信的用户信息后:

  • 查数据库:这个 unionid 有没有注册过?
  • → 直接返回已有用户
  • 没有 → 自动建一个新用户(用户名=unionid,昵称=微信昵称,头像=微信头像)
    最后生成 Token,重定向回前端页面,URL 上带好 Token

  1. 参数名标准化client_idclient_secretgrant_typeresponse_typeredirect_uricodeaccess_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-clientjustify/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 参数里拿 usernameauthType
接入分析

根据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 和后续请求
  • 存在 localStoragetokenprofile
  • 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
相关推荐
盐焗鹌鹑蛋1 小时前
【C++】多态
java·jvm·c++
rebibabo1 小时前
Java高并发底层原理(七)volatile 到底解决了什么问题
java·学习笔记·volatile·可见性·有序性·指令重排
名字还没想好☜1 小时前
Go 并发实战:用 channel 实现 worker pool
java·数据库·后端·golang·go
人道领域2 小时前
【LeetCode刷题日记】贪心算法理论与实战:455.分发饼干最优解
java·开发语言·数据结构·算法·leetcode·贪心算法
rebibabo2 小时前
Java高并发底层原理(六)—— 多核 CPU 如何保持缓存一致
java·缓存·cas·缓存一致性·cache line·longadder·mesi协议
_abab2 小时前
Java面试宝典:从基础到架构2
java·面试·架构
万亿少女的梦1682 小时前
基于Spring Boot与Vue的繁星技术论坛系统设计与实现
java·spring boot·mysql·vue·系统设计
阿pin2 小时前
Android随笔-神经系统Handler
android·java·开发语言·handler