用 Micronaut Security JWT 实现登录与刷新令牌持久化
原作者:Sergio del Amo。本文依据 Micronaut 官方 Java/Gradle 指南翻译整理,并补充静态代码审查说明。原文代码包可从官方 ZIP下载。官方指南声明代码采用 Apache License 2.0(下载完整许可文本),正文与媒体采用 Creative Commons Attribution 4.0。本文为独立中文编译,不代表 Micronaut 官方发布或背书。本文的命令和代码均未运行,测试部分描述的是代码所写的预期行为。
Micronaut Security 可以在登录后签发短期访问 JWT。客户端把访问 JWT 放在后续请求的 Bearer Authorization 头中。刷新凭证可用于换取新的访问凭证;把它与服务端的持久化记录关联后,服务端便能拒绝没有记录或已标记撤销的凭证。这个演示没有实现注册、密码哈希与重置、多因素认证、账号停用、登录限速或完整会话管理。
版本范围与安全提示
完成本指南需要编辑器或 IDE、正确设置的 JAVA_HOME,以及与项目目标相符的 JDK。指南页面的准备条件写着 JDK 21 或更高;下载到的项目源码则将 micronautVersion 固定为 5.2.0,并在 build.gradle 中把 Java sourceCompatibility 和 targetCompatibility 设为 25。因此,若要编译这个下载包,应以源码包的 Java 25 构建目标为准。示例使用 Micronaut Data JDBC、Hikari 与 H2。
先看安全边界:官方源码包中的认证 provider 把一个固定用户名和密码写在代码里,只是教学占位;配置中的 JWT 签名密钥和刷新令牌密钥共用同一环境变量,并各自带有公开的演示回退值。原配置两行末尾还各有一个多余的单引号,本文修订示例去除了它们。源码还把完整刷新 bearer token 明文写进数据库。不要把未修改的演示配置部署到联网或生产环境。下文的密钥配置已改为两个独立环境变量且不提供默认值;此处只说明配置意图,部署前仍须按所用 Micronaut 配置加载方式确认缺少变量时会启动失败。
创建 Java/Gradle 应用
原指南使用 Micronaut CLI 创建项目。Micronaut Launch 也可以创建同样的 Java 应用;选择 security-jwt、data-jdbc、reactor、validation 和 graalvm features。
mn create-app example.micronaut.micronautguide \
--features=security-jwt,data-jdbc,reactor,validation,graalvm \
--build=gradle \
--lang=java \
--test=junit
省略 --build 时 CLI 默认使用 Kotlin DSL 的 Gradle;省略语言时使用 Java;Java 与 Kotlin 默认用 JUnit,Groovy 默认用 Spock。命令创建的默认包名是 example.micronaut,项目目录为 micronautguide。
若在现有 Gradle 项目中手动添加数据持久化功能,Micronaut Data JDBC 会用编译期注解处理器生成仓库实现,Hikari 提供连接池,H2 是本地驱动:
annotationProcessor("io.micronaut.data:micronaut-data-processor")
implementation("io.micronaut.data:micronaut-data-jdbc")
implementation("io.micronaut.sql:micronaut-jdbc-hikari")
runtimeOnly("com.h2database:h2")
Security JWT、Reactor 与 Validation 依赖由前面的 CLI features 加入;下载项目的完整 build.gradle 还包含 Micronaut 处理器、序列化、测试与 GraalVM 配置。
配置 Bearer 登录、签名密钥和本地数据源
把下面属性放入 src/main/resources/application.properties。第一项让登录端点返回 JSON。密钥行是本文的安全修订:分别使用访问 JWT 与刷新令牌的环境变量,且没有公开回退密钥。官方源码包里的配置不同,仍使用一个共享环境变量和演示回退值。
micronaut.security.authentication=bearer
micronaut.security.token.jwt.signatures.secret.generator.secret=${JWT_ACCESS_SIGNING_SECRET}
micronaut.security.token.jwt.generator.refresh-token.secret=${JWT_REFRESH_SIGNING_SECRET}
datasources.default.url=jdbc:h2:mem:default;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
datasources.default.username=sa
datasources.default.password=
datasources.default.driver-class-name=org.h2.Driver
datasources.default.schema-generate=CREATE_DROP
datasources.default.dialect=H2
这里的 H2 是内存演示数据源,空密码与 CREATE_DROP 只适用于一次性本地练习。源码包没有生产数据库迁移脚本;换成真实数据库时,应单独设计 schema migration、备份与回滚策略,不要依赖演示用的自动建表配置。
访问 JWT 的有效期可以由 micronaut.security.token.jwt.generator.access-token.expiration 控制。原指南指出该配置项,但没有在这一教程中给出访问令牌或持久化刷新记录的独立过期策略。
教学用认证 provider 与受保护端点
指南用一个简单的 AuthenticationProvider 模拟用户认证。下面的固定值仅用于本地测试,不是密码存储方案:它没有哈希、盐、账号数据库或登录保护。真实应用应把身份校验接入现有受信任的认证系统。
package example.micronaut;
import io.micronaut.core.annotation.NonNull;
import io.micronaut.core.annotation.Nullable;
import io.micronaut.http.HttpRequest;
import io.micronaut.security.authentication.AuthenticationFailureReason;
import io.micronaut.security.authentication.AuthenticationRequest;
import io.micronaut.security.authentication.AuthenticationResponse;
import io.micronaut.security.authentication.provider.HttpRequestAuthenticationProvider;
import jakarta.inject.Singleton;
@Singleton
class AuthenticationProviderUserPassword<B> implements HttpRequestAuthenticationProvider<B> {
@Override
public AuthenticationResponse authenticate(
@Nullable HttpRequest<B> request,
@NonNull AuthenticationRequest<String, String> credentials) {
return credentials.getIdentity().equals("sherlock")
&& credentials.getSecret().equals("password")
? AuthenticationResponse.success(credentials.getIdentity())
: AuthenticationResponse.failure(AuthenticationFailureReason.CREDENTIALS_DO_NOT_MATCH);
}
}
登录请求为 POST /login,JSON 正文携带用户名和密码。Micronaut 会把认证结果交给 JWT 机制。下面的控制器要求用户已认证,并返回 principal 名称:
package example.micronaut;
import io.micronaut.http.MediaType;
import io.micronaut.http.annotation.Controller;
import io.micronaut.http.annotation.Get;
import io.micronaut.http.annotation.Produces;
import io.micronaut.security.annotation.Secured;
import io.micronaut.security.rules.SecurityRule;
import java.security.Principal;
@Secured(SecurityRule.IS_AUTHENTICATED)
@Controller
public class HomeController {
@Produces(MediaType.TEXT_PLAIN)
@Get
public String index(Principal principal) {
return principal.getName();
}
}
先验证访问 JWT 登录流程
项目源包含 JwtAuthenticationTest 和声明式 HTTP 客户端测试。它们检查未认证访问 / 会得到 401;登录后返回带有签名 JWT 的 access token;把 token 放入 Authorization: Bearer … 后,受保护端点返回演示用户名。AppClient 把 POST /login 和带 Authorization 头的 GET / 封装为客户端方法。
@Client("/")
public interface AppClient {
@Post("/login")
BearerAccessRefreshToken login(@Body UsernamePasswordCredentials credentials);
@Consumes(TEXT_PLAIN)
@Get
String home(@Header(AUTHORIZATION) String authorization);
}
测试通过 Micronaut 注入客户端并在嵌入式服务器上调用它:先以演示凭据登录,再传入 Bearer 加 access token,最后断言端点返回演示用户名。源码也检查登录响应的 access token 可解析为已签名 JWT。
这些是源码中断言的目标,不是本次运行所得结果。特别是教学 provider 会无条件接受写死的演示凭据;这一测试不能证明生产身份校验、安全配置或密码处理合格。
签发刷新令牌
Micronaut Security 要生成刷新令牌,需要 RefreshTokenGenerator、RefreshTokenValidator 和 RefreshTokenPersistence bean。默认的 SignedRefreshTokenGenerator 同时提供生成和验证能力,使用 HMAC 签名的 JWS 载荷。登录响应可以同时带 access token 与 refresh token。源码包的 LoginIncludesRefreshTokenTest 检查了这两个响应字段。
源项目还用 micronaut.security.token.jwt.generator.access-token.expiration 标出 access JWT 有效期的可配置属性,并通过 micronaut.security.token.jwt.generator.refresh-token.secret 提供刷新令牌签名密钥。上面的安全修订配置将两个密钥拆成独立环境变量。
持久化刷新令牌记录
Micronaut Data JDBC 的示例依赖包含编译期注解处理器、JDBC、Hikari 连接池与 H2 驱动。项目 ZIP 内完整保留 Gradle 配置和源文件;以下实体展示了实际存储字段。字段只有用户名、完整刷新令牌、撤销标记和创建时间,没有到期时间。
@MappedEntity
public class RefreshTokenEntity {
@Id @GeneratedValue @NonNull
private Long id;
@NonNull @NotBlank
private String username;
@NonNull @NotBlank
private String refreshToken;
@NonNull @NotNull
private Boolean revoked;
@DateCreated @NonNull @NotNull
private Instant dateCreated;
public RefreshTokenEntity() { }
// 完整 getter/setter 见官方源码 ZIP。
}
仓库声明 H2 方言,保存新令牌、按令牌查找记录,以及按用户名更新撤销状态:
@JdbcRepository(dialect = H2)
public interface RefreshTokenRepository extends CrudRepository<RefreshTokenEntity, Long> {
@Transactional
RefreshTokenEntity save(@NonNull @NotBlank String username,
@NonNull @NotBlank String refreshToken,
@NonNull @NotNull Boolean revoked);
Optional<RefreshTokenEntity> findByRefreshToken(@NonNull @NotBlank String refreshToken);
long updateByUsername(@NonNull @NotBlank String username, boolean revoked);
}
样例存储明文。下文的持久化类从 RefreshTokenGeneratedEvent 取出完整 token 并直接传给 repository.save。数据库或备份泄露会暴露可重放凭证。这个风险是源码现状,不会因为令牌有签名而消失;签名只帮助验证令牌内容,没有保护数据库里保存的副本。
RefreshTokenPersistence 如何校验记录
下面概括项目中的实现:签发事件发生时保存用户名、完整 token 与未撤销标记;刷新时按完整 token 查找记录,记录不存在或标为已撤销就返回 invalid_grant,否则构建用户名认证信息交给刷新端点继续签发访问 token。
@Singleton
public class CustomRefreshTokenPersistence implements RefreshTokenPersistence {
private final RefreshTokenRepository repository;
public CustomRefreshTokenPersistence(RefreshTokenRepository repository) {
this.repository = repository;
}
@Override
public void persistToken(RefreshTokenGeneratedEvent event) {
if (event != null && event.getRefreshToken() != null
&& event.getAuthentication() != null
&& event.getAuthentication().getName() != null) {
repository.save(event.getAuthentication().getName(), event.getRefreshToken(), false);
}
}
@Override
public Publisher<Authentication> getAuthentication(String refreshToken) {
return Flux.create(emitter -> {
Optional<RefreshTokenEntity> found = repository.findByRefreshToken(refreshToken);
if (found.isEmpty()) {
emitter.error(new OauthErrorResponseException(INVALID_GRANT,
"refresh token not found", null));
return;
}
RefreshTokenEntity token = found.get();
if (token.getRevoked()) {
emitter.error(new OauthErrorResponseException(INVALID_GRANT,
"refresh token revoked", null));
} else {
emitter.next(Authentication.build(token.getUsername()));
emitter.complete();
}
}, FluxSink.OverflowStrategy.ERROR);
}
}
为便于阅读,片段省略了 imports;官方源码 ZIP 包含原始 imports 与完整文件。该实现只查询记录是否存在以及撤销位,不检查数据库到期时间,也没有把成功使用的刷新令牌设为撤销、生成父子轮换关系、锁定行或防止并发重放。不要把这些未实现的能力归到样例名下。
刷新端点与测试范围
指南的刷新端点使用 POST /oauth/access_token,请求体中的 grant type 是 refresh_token,并携带 refresh token:
{
"grant_type": "refresh_token",
"refresh_token": "<client-held-refresh-token>"
}
源项目含以下测试文件,描述预期行为;它们未经本次运行:
UnsignedRefreshTokenTest:无效签名值应返回 HTTP 400,错误码invalid_grant。RefreshTokenNotFoundTest:生成签名刷新令牌但不保存记录,请求应返回 400 和refresh token not found。RefreshTokenRevokedTest:意图测试已撤销记录应返回 400 和refresh token revoked。OauthAccessTokenTest:登录取得 access/refresh token,调用刷新端点并断言新 access token 与登录返回值不同。DefaultTest、CompleteTest、JwtAuthenticationTest、DeclarativeHttpClientWithJwtTest、LoginIncludesRefreshTokenTest:覆盖应用启动、受保护端点、声明式客户端、登录返回刷新令牌。
源教程的撤销测试有一个静态不一致。它先由 createKey(user) 得到 key,再由 generate(user, refreshToken) 生成签名后的 signedRefreshToken;但测试把未签名 key 保存进数据库,随后却提交签名令牌。持久化实现按完整提交值查找,所以该 fixture 与请求不匹配,预期会落到 “not found” 分支,而不是 “revoked” 分支。要验证撤销路径,测试应保存实际请求使用的签名值,例如:
refreshTokenRepository.save(user.getName(), signedRefreshToken, true);
这是基于静态读码提出的修正,未执行测试确认。即使这样修正,也只验证拒绝后续刷新;已经签发的自包含 access JWT 通常仍可使用到其过期时间,除非应用另外实现在线撤销检查或密钥失效策略。
测试和运行演示
官方指南运行单元测试的命令为:
./gradlew test
查看报告:build/reports/tests/test/index.html。启动本地应用:
./gradlew run
应用默认监听 8080 端口。下面用教学占位值示例登录请求;响应中的 access token 和 refresh token 应作为敏感 bearer 凭证处理,不要贴入日志、工单或聊天:
curl -X POST "http://localhost:8080/login" \
-H 'Content-Type: application/json' \
-d '{"username":"sherlock","password":"password"}'
登录成功时,JSON 包含 username、access_token、refresh_token、token_type 和 expires_in。为避免传播可复制的 token,本译稿不展示原页里的签名 JWT 样例。访问受保护资源时用 Authorization: Bearer <access_token>。访问 JWT 到期后,客户端可向 /oauth/access_token 发送刷新请求,服务端检查签名与持久化记录后发出新的访问 token。
可选:生成 GraalVM 原生可执行文件
原指南使用 Java 25 GraalVM,并列出 SDKMAN 安装 Oracle GraalVM 或 GraalVM Community Edition 的命令。其他版本号应以当前 GraalVM 下载页为准;Windows 和手动安装方式见 GraalVM 官方说明。
sdk install java 25.0.2-graal
# 或
sdk install java 25.0.2-graalce
生成 native executable:
./gradlew nativeCompile
生成文件位于 build/native/nativeCompile/,默认名称为 micronautguide。Gradle 配置可通过 graalvmNative.binaries.main.imageName 自定义名称,并把额外参数传入 native-image,例如教程的快速构建选项 -Ob。原文指出该 native-image 流程支持 Java 和 Kotlin;Groovy 对反射的依赖较多,支持有限。本次没有安装 GraalVM 或构建原生文件。
将示例提升到生产前需要补齐的边界
- 用受控秘密管理器提供高熵密钥;访问 JWT 与刷新令牌密钥分离,并规划轮换。删除公开回退值,避免把密钥放进代码库、镜像层或日志。
- 接入真实密码哈希与账号生命周期;对登录和刷新接口限速,增加异常尝试监控,并根据客户端存储方式处理 CSRF、XSS 与令牌泄露风险。
- 不要明文保存可重放刷新凭证。采用经过审查的摘要/标识存储方案,并配置数据库访问控制和备份保护。
- 增加刷新令牌过期时间、清理策略、一次性轮换和原子并发控制;明确已检测到重放时如何撤销相关会话。当前代码没有这些控制。
- 为生产数据库制定迁移、索引、唯一约束、事务隔离、备份与恢复流程,并针对目标数据库实测。
- 访问 JWT 的有效期应尽可能短;若业务要求即时注销,还需额外的在线状态或撤销机制。单纯撤销刷新记录不会抹掉已签发 JWT。
以上是上线前的补充设计检查项,不是教程已经实现或验证的功能。本教程没有形成完整账号系统,也未经过生产安全审计。
后续阅读与许可
可继续阅读 Micronaut Security 的 JWT 官方文档。原作者 Sergio del Amo;文章与媒体采用 CC BY 4.0,示例代码采用 Apache License 2.0。本译稿保留来源及作者,并做了中文翻译、结构整理和明确标记的安全性修订。










暂无评论内容