Skip to content

鉴权架构与双实现

宠物商店的后台同样需要登录与放行:商品维护、订单处理要登录后才能操作,而 /v1/api/auth/login 与文档页必须免登录可达。forge 把这件事拆成了「核心契约 + 可替换实现」两层——核心模块只认一个 SPI,具体令牌怎么签、怎么验,交给 Spring Security 或 Sa-Token 其中之一去做。

本篇讲清楚「为什么这么分层」,以及登录、鉴权、落库三条链路是怎么串起来的。只想快速接入、复制代码的话,请看配套的使用指南。

配套阅读

落地步骤、application.yml 配置、控制台日志验证与常见坑,见 鉴权使用指南。涉及包扫描、SPI 装载、启动钩子的底层机制,见 前置知识

为什么核心不绑定具体令牌方案

最直接的做法是让 forge-auth 直接依赖 JJWT,把 JWT 的签发解析写死在核心里。但宠物商店上线后可能换需求:今天用无状态 JWT,明天想要「踢人下线」「单端登录」这类有状态能力,JWT 纯无状态做不到,得换 Sa-Token。如果核心写死了 JWT,这个切换就是伤筋动骨。

forge 的取舍是:核心模块 forge-auth 只持有抽象契约 AuthProvider,它完全不知道令牌是 JWT 还是 Sa-Token 的 token,也不直接依赖任何令牌库。具体实现下沉到两个独立模块,业务工程二选一引入。

这种分层带来三个直接好处:核心可以独立编译、独立演进;两个实现互不感知、互不依赖;业务工程换鉴权方案只须换一行 Maven 依赖,业务代码零改动。

AuthProvider:唯一的契约

整个体系收敛在一个接口上。AuthProvider 只有三个方法,签发、校验、失效:

java
@SPI
public interface AuthProvider {
    String issueToken(LoginUser user);        // 令牌签发
    LoginUser verifyToken(String token);      // 令牌校验与解析
    default void invalidate(String token) {}  // 令牌失效(可选)
}

注意 invalidate 是带默认空实现的——这是个刻意的设计。纯无状态的 JWT 服务端不留状态,根本无法主动让一个未过期的令牌失效,所以 Spring Security 实现就用默认空实现;而 Sa-Token 是有状态的,能真正注销,于是它覆盖了这个方法。契约用「可选默认方法」把两种能力差异优雅地兜住,而不是逼着 JWT 实现抛 UnsupportedOperationException

核心侧通过 ForgeAuthProvider.get() 拿到那个唯一的实现:

java
public static AuthProvider get() {
    List<AuthProvider> all = Spider.getAllExtensions(AuthProvider.class);
    if (all.isEmpty())
        throw new IllegalStateException("未检测到任何鉴权实现");
    if (all.size() > 1)
        throw new IllegalStateException("检测到多个鉴权实现同时存在");
    AuthProvider chosen = all.get(0);
    log.info("[auth] 使用鉴权实现: {}", chosen.name());
    return chosen;
}

这里是 fail-fast 一元约束:必须恰好一个实现。少了启动就报错,多了也报错。这样设计是因为令牌格式不能混——如果工程里同时装了两套实现,签发用 JWT、校验落到 Sa-Token,令牌互不兼容,运行期才暴雷会非常难排查。把它前移到启动期,问题在最容易定位的时刻被拍死。

为什么用唯一实现而不是配置选择

也可以做成 forge.auth.provider=jwt 这种配置项来选。但 forge 用「装了哪个实现就用哪个」的物理隔离方式——依赖即决策。业务工程的 pom.xml 里出现哪个实现 jar 一目了然,不会出现「配置写了 jwt 但实际没引依赖」这类配置与依赖不一致的坑。

三条链路

登录链路

登录由核心的 ForgeDefaultAuthManager 编排,校验落在核心,签发委托给 AuthProvider

关键点在于职责切分:用户名密码这套「鉴别身份」的逻辑属于框架共性,放在核心;「身份转成令牌」这套有方案差异的逻辑才下放给实现。两个实现的 LoginUser 一进一出格式完全一致,这就是 forge-auth 能保持中立的原因。

鉴权链路

每个 /v1/**/v2/** 请求都过 ForgeAuthInterceptor

@Public 的检查放在拦截器最前面、优先于白名单匹配。原因是 @Public 标在 Controller 方法上,跟着代码走、改了一眼能看见,比散落在配置文件里的路径白名单更易维护。宠物商店那个 getUserStats 统计接口就是直接挂 @Public 免登录的。

校验同样只是个转发:拦截器拿到 token 后委托 AuthProvider.verifyToken,至于这个 token 是被 JJWT 解出来的还是被 Sa-Token 回源 DB 查出来的,核心一概不关心。

落库链路(默认管理员初始化)

宠物商店第一次启动得有个能登录的账号。ForgeAuthMetaLoader 在应用就绪后播种默认管理员 admin/admin,并创建 SUPER_ADMIN 角色完成绑定:

这里选 ApplicationReadyEvent 而非 ContextRefreshedEvent 是有讲究的。容器刚 refresh 时,数据源、事务管理、MyBatis-Plus 这些不一定全部就绪,此时去写库有风险。而 ApplicationReadyEvent 发布时整个应用已经 Ready,可以放心调用 Models.origin(AuthUser.class).save() 这类持久化方法。整个流程每步都先查后建,天然幂等——重启不会重复播种。

两个实现各自封装了什么

SpringSecurityAuthProvider:JWT 编解码就近下沉

Spring Security 实现走纯无状态 JWT,它本身极薄,真正的活儿落在 ForgeJwtTokenCodec

java
@SPI.Service("spring-security")
@Order(10)
public class SpringSecurityAuthProvider implements AuthProvider {
    public String issueToken(LoginUser user) {
        return ForgeAuthRuntime.codec().sign(user);
    }
    public LoginUser verifyToken(String token) {
        return ForgeAuthRuntime.codec().parse(token);
    }
}

ForgeJwtTokenCodec 用 HS256,密钥长度强制 ≥ 32 字节(Keys.hmacShaKeyFor 会对短密钥 fail-fast)。signuserId 放进 subject,把 usernamenicknamesuperAdmin 作为自定义声明写进 JWT,生成签发时间和过期时间后压缩成串;parse 用密钥解析、把声明回填成 LoginUser,解析异常统一转成 TOKEN_INVALID

关键的依赖治理细节:JJWT 这个依赖只出现在 forge-auth-spring-security 模块,因为它是 JWT 编解码的唯一消费者。依赖跟着真正用它的人走,不往核心上抬——核心因此保持了对令牌库零依赖。密钥与 TTL 则由 ForgeAuthRuntime 这个静态 holder 集中持有(启动期 configure 注入),并对默认密钥发出告警:

[auth] 正在使用内置默认 JWT 密钥,生产环境务必通过 forge.auth.jwt.secret 覆盖!

SaTokenAuthProvider:回源 DB 重建身份

Sa-Token 实现走「服务端持有会话」的路子,令牌里不塞业务声明:

java
@SPI.Service("sa-token")
@Order(10)
public class SaTokenAuthProvider implements AuthProvider {
    public String issueToken(LoginUser user) {
        StpUtil.login(user.getUserId());
        return StpUtil.getTokenValue();
    }
    public LoginUser verifyToken(String token) {
        Object userId = StpUtil.getLoginIdByToken(token);
        return ForgeLoginUsers.load(userId);   // 回源 DB 重建
    }
    public void invalidate(String token) {
        StpUtil.logoutByTokenValue(token);
    }
}

它的设计哲学和 JWT 方案相反:JWT 把 nicknamesuperAdmin 这些信息塞进令牌随身携带(无状态、但令牌一旦签发就僵化);Sa-Token 只在令牌里留 userId,校验时通过 ForgeLoginUsers.load(userId) 回源数据库重建 LoginUser。代价是每次校验多一次查询,换来的是用户信息永远是库里的最新值,而且天然支持 invalidate 主动注销。

设计取舍

决策点选项选择理由
核心是否绑定令牌方案写死 JWT / 抽象 SPI抽象 AuthProvider SPI核心保持中立,换方案只换依赖,业务零改动
实现数量约束允许多实现并存 / 强制唯一fail-fast 强制唯一避免令牌格式混用,问题前移到启动期
方案选择方式配置项选择 / 依赖即决策引入哪个实现 jar 就用哪个杜绝配置与依赖不一致,pom.xml 一目了然
JJWT 依赖位置抬到核心 / 留在实现模块跟随唯一消费者下沉到 forge-auth-spring-security核心对令牌库零依赖,依赖治理清晰
invalidate 语义强制实现 / 可选默认方法接口提供默认空实现优雅兜住「JWT 无法注销、Sa-Token 可注销」的能力差异
身份信息来源全塞令牌 / 回源 DB由实现自行选择JWT 重无状态、Sa-Token 重信息新鲜度与可注销
免登录优先级仅白名单 / @Public 优先@Public 检查在拦截器最前注解跟代码走,比配置路径更易发现与维护
落库时机ContextRefreshedEvent / ApplicationReadyEventApplicationReadyEvent确保数据源与 ORM 完全就绪,写库安全
密码加密依赖整套 Security / 仅 crypto 子模块核心仅引 spring-security-cryptoBCrypt 标准化的同时保持核心轻量

当前边界

按钮级授权(@Action 之类)与基于权限码的细粒度校验尚未落地,AuthRole / AuthUserRole 已建模但暂未参与运行期鉴权判断;superAdmin 标记当前仅作标识,绕过逻辑待补充。错误码 40300 FORBIDDEN 为此预留。当前模块聚焦在登录认证这一层。

小结

forge 鉴权的核心思想是一句话:核心只持契约,依赖跟随消费者。AuthProvider SPI 把「令牌怎么签验」隔离在两个可替换实现里,JJWT 这类令牌库依赖也就近下沉到真正用它的模块,核心因此对任何具体方案中立。Spring Security 实现胜在无状态轻量,Sa-Token 实现胜在可注销与信息新鲜——选哪个,由业务工程引入哪个 jar 决定。

接入步骤与日志验证请移步 鉴权使用指南