搜索 K
Appearance
Appearance
宠物商店的后台同样需要登录与放行:商品维护、订单处理要登录后才能操作,而 /v1/api/auth/login 与文档页必须免登录可达。forge 把这件事拆成了「核心契约 + 可替换实现」两层——核心模块只认一个 SPI,具体令牌怎么签、怎么验,交给 Spring Security 或 Sa-Token 其中之一去做。
本篇讲清楚「为什么这么分层」,以及登录、鉴权、落库三条链路是怎么串起来的。只想快速接入、复制代码的话,请看配套的使用指南。
最直接的做法是让 forge-auth 直接依赖 JJWT,把 JWT 的签发解析写死在核心里。但宠物商店上线后可能换需求:今天用无状态 JWT,明天想要「踢人下线」「单端登录」这类有状态能力,JWT 纯无状态做不到,得换 Sa-Token。如果核心写死了 JWT,这个切换就是伤筋动骨。
forge 的取舍是:核心模块 forge-auth 只持有抽象契约 AuthProvider,它完全不知道令牌是 JWT 还是 Sa-Token 的 token,也不直接依赖任何令牌库。具体实现下沉到两个独立模块,业务工程二选一引入。
这种分层带来三个直接好处:核心可以独立编译、独立演进;两个实现互不感知、互不依赖;业务工程换鉴权方案只须换一行 Maven 依赖,业务代码零改动。
整个体系收敛在一个接口上。AuthProvider 只有三个方法,签发、校验、失效:
@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() 拿到那个唯一的实现:
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() 这类持久化方法。整个流程每步都先查后建,天然幂等——重启不会重复播种。
Spring Security 实现走纯无状态 JWT,它本身极薄,真正的活儿落在 ForgeJwtTokenCodec:
@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)。sign 把 userId 放进 subject,把 username、nickname、superAdmin 作为自定义声明写进 JWT,生成签发时间和过期时间后压缩成串;parse 用密钥解析、把声明回填成 LoginUser,解析异常统一转成 TOKEN_INVALID。
关键的依赖治理细节:JJWT 这个依赖只出现在 forge-auth-spring-security 模块,因为它是 JWT 编解码的唯一消费者。依赖跟着真正用它的人走,不往核心上抬——核心因此保持了对令牌库零依赖。密钥与 TTL 则由 ForgeAuthRuntime 这个静态 holder 集中持有(启动期 configure 注入),并对默认密钥发出告警:
[auth] 正在使用内置默认 JWT 密钥,生产环境务必通过 forge.auth.jwt.secret 覆盖!Sa-Token 实现走「服务端持有会话」的路子,令牌里不塞业务声明:
@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 把 nickname、superAdmin 这些信息塞进令牌随身携带(无状态、但令牌一旦签发就僵化);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 / ApplicationReadyEvent | ApplicationReadyEvent | 确保数据源与 ORM 完全就绪,写库安全 |
| 密码加密依赖 | 整套 Security / 仅 crypto 子模块 | 核心仅引 spring-security-crypto | BCrypt 标准化的同时保持核心轻量 |
当前边界
按钮级授权(@Action 之类)与基于权限码的细粒度校验尚未落地,AuthRole / AuthUserRole 已建模但暂未参与运行期鉴权判断;superAdmin 标记当前仅作标识,绕过逻辑待补充。错误码 40300 FORBIDDEN 为此预留。当前模块聚焦在登录认证这一层。
forge 鉴权的核心思想是一句话:核心只持契约,依赖跟随消费者。AuthProvider SPI 把「令牌怎么签验」隔离在两个可替换实现里,JJWT 这类令牌库依赖也就近下沉到真正用它的模块,核心因此对任何具体方案中立。Spring Security 实现胜在无状态轻量,Sa-Token 实现胜在可注销与信息新鲜——选哪个,由业务工程引入哪个 jar 决定。
接入步骤与日志验证请移步 鉴权使用指南。