Skip to content

AuthProvider 双实现设计

forge 的鉴权能力被切成两层:核心层 forge-auth 只定义契约与流程骨架,签发与校验令牌的真正逻辑通过 SPI 下沉到两个独立实现包 —— forge-auth-spring-security(无状态 JWT)与 forge-auth-sa-token(Sa-Token)。本文解释这套分层「为什么这么设计」:为何强制唯一实现、登录链路如何在两种实现之间保持一致、以及为什么默认管理员元数据落库要等到 ApplicationReadyEvent 而非更早的容器刷新阶段。

接入步骤与配置项请看使用指南:鉴权快速接入

为什么把令牌读写抽成 SPI

宠物商店上线初期跑在单机、用最简单的无状态 JWT 即可;等到要做多端登出、单点踢人这类有状态能力时,团队希望切到 Sa-Token,而不想动业务代码里的 @Public、登录端点和拦截器。要做到「换实现不换上层」,就必须让上层只依赖一个稳定契约。

这个契约是 AuthProvider

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

上层的 ForgeDefaultAuthManager 只认这三个方法,至于令牌是一段 HS256 签名的 JWT 还是 Sa-Token 的会话票据,它一概不关心。实现包通过 @SPI.Service 声明自己的名字,再由 META-INF/services 注册,启动时被发现并选中。

TIP

关于 @SPI + Spider.getAllExtensions 这套扩展点基建的通用机制,见 SPI 扩展点。这里只讲鉴权场景下的选择策略。

整体分层

SPI 如何被选中:恰好一个

ForgeAuthProvider 在启动时聚合所有实现,并强制「有且仅有一个」:

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:如果项目同时引入了 forge-auth-spring-securityforge-auth-sa-token,两种实现签发的令牌格式互不兼容,运行期若随机命中一个,会出现「能登录但校验失败」的诡异现象。与其让问题在生产环境以偶发形式暴露,不如在启动期直接拒绝。选中后日志会打印:

[auth] 使用鉴权实现: spring-security

[auth] 使用鉴权实现: sa-token

两个实现的取向差异

两个实现都标注 @SPI.Service@Order(10),差别在于令牌的承载方式。

维度SpringSecurityAuthProviderSaTokenAuthProvider
SPI 名spring-securitysa-token
签发ForgeAuthRuntime.codec().sign(user)StpUtil.login(userId) 后取 StpUtil.getTokenValue()
校验ForgeAuthRuntime.codec().parse(token)StpUtil.getLoginIdByToken(token)ForgeLoginUsers.load(userId)
失效空实现(纯无状态,无法主动失效)StpUtil.logoutByTokenValue(token)
用户信息来源JWT 自定义声明回源数据库重建 LoginUser
主要依赖jjwt 0.12.6 + spring-boot-starter-securitysa-token-spring-boot3-starter 1.39.0 + sa-token-jwt

值得注意的是两者对「校验时如何拿到用户」的不同选择:Spring Security 方案把 usernamenicknamesuperAdmin 写进 JWT 声明里,校验即可还原,零数据库往返;Sa-Token 方案只在令牌里放 userId,校验时用 ForgeLoginUsers.load(userId) 回源数据库重建 LoginUser,代价是一次查询,换来的是不把用户属性固化进令牌、改昵称/权限可即时生效。

登录签发与校验流程

无论选中哪个实现,上层 ForgeDefaultAuthManager 的流程是一致的。下面以 Spring Security 实现为例画出登录与一次受保护请求的时序。

登录签发

密码错误会抛 LOGIN_FAILED(40110),账号被禁用抛 USER_DISABLED(40111),二者都在签发令牌之前拦下。

受保护请求的校验

未携带令牌返回 TOKEN_MISSING(40100),令牌非法或过期返回 TOKEN_INVALID(40101)。Sa-Token 实现的链路结构相同,只是 parse 一步换成「解析 userId + 回源 DB」。

JWT codec 为何下沉到独立 holder

Spring Security 实现没有把签名密钥散落在 Provider 里,而是抽出 ForgeJwtTokenCodec 专管编解码,再由 ForgeAuthRuntime 这个 volatile 静态 holder 持有它。原因有三:

  • 密钥与 TTL 集中一处管理。ForgeJwtTokenCodec 在构造时用 Keys.hmacShaKeyFor 校验密钥长度,HS256 要求 ≥ 32 字节,长度不足直接 fail-fast,避免运行期才暴露弱密钥。
  • 配置注入与令牌读写解耦。SecurityJwtConfiguration 在启动期把 ForgeAuthProperties 喂给 ForgeAuthRuntime.configure(...)codec() 取用时做 lazy 初始化检查,未配置即抛异常,而不是悄悄用半成品。
  • 若密钥仍是内置默认值,启动时会打印告警,提醒生产环境务必覆盖:
[auth] 正在使用内置默认 JWT 密钥,生产环境务必通过 forge.auth.jwt.secret 覆盖!

parse 阶段对 JwtExceptionIllegalArgumentException 统一兜底,转换成 TOKEN_INVALID,不让底层异常类型泄漏到上层。

为何元数据落库用 ApplicationReadyEvent

ForgeAuthMetaLoader 负责在启动时播种默认管理员:创建 admin/admin 账号、SUPER_ADMIN 角色,并建立两者的绑定关系。它的监听点是 ApplicationReadyEvent,而不是更早的 ContextRefreshedEvent@PostConstruct

java
@EventListener(ApplicationReadyEvent.class)
public void onReady(ApplicationReadyEvent event) {
    long start = System.currentTimeMillis();
    seedDefaultAdmin();
    log.info("[auth] 默认管理员初始化完成,耗时 {}ms", System.currentTimeMillis() - start);
}

核心理由是时序:播种动作要写 AuthUserAuthRoleAuthUserRole 三张表,而这些表是由元数据驱动在启动期建出来的。若在容器刚刷新(ContextRefreshedEvent)时就执行,建表 DDL 可能尚未跑完,落库会因表不存在而失败(例如向尚未建出的角色/权限相关表写数据时直接报「表不存在」)。ApplicationReadyEvent 在整个应用 Ready 之后才发出,此时 ORM(MyBatis-Plus)已装配、建表已完成、事务管理就绪,可以安全地通过 Models.origin(AuthUser.class).save() 等方式落库。

该流程同时是幂等的:每一步都先查后写,已存在则跳过,因此重启不会重复播种。代码还有一个细节 —— 创建后会重新查询取主键,而不依赖自增主键回填,以兼容分布式主键场景。创建成功时打印:

[auth] 已创建默认管理员账号: admin
[auth] 默认管理员初始化完成,耗时 XXms

设计取舍

决策点选项选择理由
实现数量允许多实现共存 / 强制唯一强制唯一,fail-fast不同实现令牌互不兼容,多实现会导致随机命中、偶发校验失败;启动期拒绝优于生产期暴露
令牌承载用户信息全写进令牌 / 只放 userId 回源两实现各取一种Spring 方案零 DB 往返但属性被固化;Sa-Token 方案多一次查询但改昵称/权限即时生效
主动失效令牌统一要求 / 由实现决定接口提供默认空实现纯无状态 JWT 无法主动失效,强行要求会迫使其引入黑名单;交给有状态实现自行支持
JWT 密钥管理位置散落在 Provider / 抽独立 codec下沉到 ForgeJwtTokenCodec + ForgeAuthRuntime密钥长度校验、TTL、默认值告警集中一处,配置注入与读写解耦
默认管理员落库时机@PostConstruct / ContextRefreshedEvent / ApplicationReadyEventApplicationReadyEvent晚于建表 DDL 与 ORM 装配,避免向尚未建出的表写数据而报表不存在
播种幂等直接插入 / 先查后写先查后写并重查取主键支持重启不重复,且不依赖自增主键回填,兼容分布式主键

WARNING

强制唯一实现意味着不能为了过渡而临时双引。若需从 Spring Security 切换到 Sa-Token,应移除旧实现包后再引入新包,并要求所有用户重新登录 —— 旧令牌在新实现下无法解析。


签发链路的可复制代码、application.yml 配置键与启动日志验证,见使用指南 鉴权快速接入