Multiple JWT Auth Handlers in Vert.x
Vert.x 的官方 Web 开发包 Vert.x-Web 中 提供了内置的 Authentication&Authorisation 支持;通过扩展的 Auth Common 模块和 JDBC auth MongoDB auth Shiro auth JWT auth OAuth 2 等模块,可以覆盖大部分的用户认证与验权的支持。
在实际的项目中,遇到了一个支持多用户提供方的需求:项目的用户是从多个其他项目导入的;项目的逻辑比较简 单,不想维护自己的用户信息数据和外部映射。
比如,项目需要支持主站用户、视频网站等入口登录访问。正常情况下,只需要项目维护一套内部用户表、一个外 部项目表以及内部用户和外部用户的映射表;在用户导入或用户绑定请求时,建立外部项目 ID 和外部项目用户 ID 与内部用户 ID 的对应关系;在登录请求时,根据外部项目 ID 和外部项目用户 ID 调用用户认证回调,通过 后再寻找到内部用户的 ID 即可。
实际在项目的设计和实现过程中,采用了一个比较好玩的方法:在内部用户表的基础上,对用户使用 JWT 的认证模型;用户登录时,根据外部项目 ID 和外部项目用户 ID 调用用户认证回调,发 放 JWT token,在 token 中限定用户的访问权限。
🔗Vert.x 对 JWT 的支持
相关文档参见 Vert.x Auth Common Vert.x JWT Vert.x Web AuthN & AuthZ 。
- 确定 JWT 的算法和密钥
WTAuthOptions authConfig = new JWTAuthOptions() .setKeyStore(new KeyStoreOptions() .setType("jceks") .setPath("keystore.jceks") .setPassword("secret")); JWTAuth authProvider = JWTAuth.create(vertx, authConfig);
JWTAuth 是 Vert.x 的 AuthProvider 的一个实现;AuthProvider 主要提供根据一个 JSON 的用户信息对象进行用户认证的能力;JWTAuth 则通过校验一个 JWT 型的 JSON token 来验权,同时扩展加上了生成 JWT token 的方法。
JWT 算法和结构相关参见 JWT 。
- 生成并发放 Token
这一步一般在登录时进行。
router.route("/login").handler(this::login);
private void login(final RoutingContext ctx) {
if ("paulo".equals(ctx.request().getParam("username")) && "secret".equals(ctx.request().getParam("password"))) {
ctx.response().end(authProvider.generateToken(new JsonObject().put("sub", "paulo"), new JWTOptions()));
} else {
ctx.fail(401);
}
}
- 验证 Token
这一步一般以拦截器的形式实现,用于在访问需要权限控制的接口时,提前通过 JWT 验权来确定是否放行。
router.route().handler(JWTAuthHandler.create(authProvider));
router.get().handler(this::findById);
router.post().handler(this::add);
private void findById(final RoutingContext ctx) {
final User user = ctx.getUser();
if (null != user) {
// Authenticated
// TODO
}
}
private void add(final RoutingContext ctx) {
final User user = ctx.getUser();
if (null != user) {
// Authenticated
// TODO
}
}
🔗基于通用流程的改造
因为需要增加对多用户源的支持,所以需要扩充实现 JWT 验证的流程,使得能够:1. 不同用户源的用户需要使用不同的密钥和有效期等基本配置;2. 不同数据源的用户的登录接口参数可以不一样(如用户源 A 通过 username/password,用户源 B 通过 uid/token)
最主要的思路是把各个用户源不同的逻辑抽象出来,包括用户管理、JWT 密钥管理、用户认证、用户授权等;扩展 官方的 JWT Auth Provider,提供多源的分发验证。
🔗用户源的抽象 UserRealm
public interface RxUserRealm { Set<String> supportedRealms(); Single<Boolean> isUserAvailable(String uid); Single<String> getJwtSecret(); Single<JwtTokenDto> authenticate(final LoginDto login); Single<Boolean> authorize(final PrincipalDto pricipal, final PermissionDto permission); }
其中,supportedRealms 说明自身的用户源集合;isUserAvailable 提供根据用户ID或用户名查询用户是否存在的功能;getJwtSecret 提供查询用户源相关的 JWT 密钥的功能;authenticate & authorize 提供各用户源的用户认证和授权管理的功能。
接口的返回使用 RxJava 2 的类型,主要原因是这些接口可以是远程调用的,可以利用RxJava 的异步响应机制来封装差异。
作为示例,服务提供了一个 DemoUserRealm,用以提供无数据源用户体验服务的能力。
@AutoService(RxUserRealm.class) public class DemoUserRealm implements RxUserRealm { private static final String UID_PREFIX = "demo"; private static final String JWT_SECRET = "demo"; @Override public Set<String> supportedRealms() { return ImmutableSet.of("demo"); } @Override public Single<Boolean> isUserAvailable(final String uid) { return Single.just(uid.startsWith(UID_PREFIX)); } @Override public Single<String> getJwtSecret() { return Single.just(JWT_SECRET); } @Override public Single<JwtTokenDto> authenticate(final LoginDto login) { final String pw = login.claims().get("password"); // Omitted return Single.just(JwtTokenDto.create()); // Omitted } @Override public Single<Boolean> authorize(final PrincipalDto principal, final PermissionDto permission) { switch (permission.category()) { case READ: return Single.just(Boolean.TRUE); case WRITE: return Single.just(Boolean.FALSE); default: return Single.just(Boolean.FALSE); } } }
@AutoService
注解是 Google Auto-Service 包的一部分,用来辅助实现 Java 基于 java.util.ServiceLoader
的 SPI 机制。
🔗SpiBasedUserRealmService
为了提高扩展性,可以使用 Java ServiceLoader (java.util.ServiceLoader
) 来进行 UserRealm 的管理。
class SpiBasedUserRealmService { private Map<String, RxUserRealm> mappedRealms; SpiBasedUserRealmService() { final Map<String, RxUserRealm> map = new HashMap(); final ServiceLoader<RxUserRealm> realms = ServiceLoader.load(RxUserRealm.class); for (final RxUserRealm r: realms) { final Set<String> types = r.supportedRealms(); if (types.isEmpty()) continue; for (final String type: types) { if (map.containsKey(type)) continue; map.put(type, r); } } // Guava utils mappedRealms = ImmutableMap.copyOf(map); } public Optional<RxUserRealm> findRealm(final String realm) { return Optional.ofNullable(mappedRealms.get(type)); } }
🔗JWT Auth Provider
主要的逻辑在 CustomJwtAuthProvider
中。该类实现 Vert.x 内置的 JWTAuth 接口,以能够和 vert.x-web 模块无缝结合。
在 authenticate 的实现中,首先对 JWT 的 token 串进行只解码不验证,从解码出的 JSON 中可以获得对应的用户源类型,解码 JWT token 可以使用这个;可以通过用户源类型找到可用的 RxUserRealm 实例,查询对应的 JWT 配置;之后再使用配置创建原生的 JWTAuth 实例进行 authenticate。
在 generateToken 的实现中,首先根据用户源类型查询到可用的 RxUserRealm 实例,然后使用该实例的 JWT 配置创建原生的 JWTAuth 实例进行 generateToken。
class CustomJwtAuthProvider implements JWTAuth { private final Scheduler workingScheduler; private final Vertx vertx; private final SpiBasedUserRealmService realmService; // ... // init code omited // ... @Override public void authenticate(final JsonObject authInfo, final Handler<AsyncResult<User>> resultHandler) { final JWT decode; try { final String jwtStr = authInfo.getString("jwt"); decode = JWT.decode(jwtStr); } catch (RuntimeException ex) { resultHandler.handle(Future.failedFuture(ex)); return; } final String realm = firstAudience(decode); authProviderByRealm(Strings.nullToEmpty(realm)) .subscribeOn(workingScheduler) .subscribe( jwt -> jwt.authenticate(authInfo, re -> { if (re.failed()) { LOG.warn("JWT auth failed for realm \"{}\"", realm, re.cause()); } else { LOG.debug("JWT auth succeed for realm \"{}\"", realm); } resultHandler.handle(re); }), ex -> { LOG.warn("JWT auth exception for realm \"{}\"", realm, ex); resultHandler.handle(Future.failedFuture(ex)); } ); } private String firstAudience(final Payload payload) { final List<String> audience = payload.getAudience(); return (null != audience && ! audience.isEmpty()) ? audience.get(0) : ""; } @Override public String generateToken(final JsonObject claims, final JWTOptions options) { final String realm = claims.getString("aud"); return authProviderByRealm(Strings.nullToEmpty(realm)) .map(jwt -> jwt.generateToken(claims, options)) .blockingGet(); } private Single<JWTAuth> authProviderByRealm(final String realm) { final Optional<RxUserRealm> opt = realmService.findRealm(realm); if (! opt.isPresent()) return Single.error(new IllegalStateException("Realm not supported: " + realm)); return opt.get().findJwtSecret(realm).map(this::jwtAuth); } private JWTAuth jwtAuth(final String key) { return JWTAuth.create(vertx, new JWTAuthOptions() .addPubSecKey(new PubSecKeyOptions() .setAlgorithm("HS256") .setPublicKey(key) .setSymmetric(true) ) ); } }
🔗Auth Handler
对于需要用户认证和验权保护的接口,正常使用 vert.x-web 模块提供的 JWTAuthHandler 机制。
// ... // omited final JWTAuth jwtAuth = new CustomJwtAuthProvider(); router.route().handler(JWTAuthHandler.create(jwtAuth); // omited // ...
🔗Login Controller
最后一步是在 HTTP 的 handler 里面使用 CustomJwtAuthProvider 生成 JWT token 并返回给调用方使用。
// ... // omited router.post("/login").handler(this::login); // omited // ... JWTAuth jwtAuth; // formerly inited private void login(final RoutingContext ctx) { // Generate JWT token // final String token = jwtAuth.generateToken(...); final String token = "generated token"; ctx.response().end(token); }
🔗总结
整个设计的基本思路就是基于内置的 JWTAuth 实现类 (io.vertx.ext.auth.jwt.impl.JWTAuthProviderImpl),在 authenticate & generateToken 的实现中从参数中取 出用户源类型,再根据用户源类型执行各自的逻辑,之后再调用 JWTAuthProviderImpl 的实现。这样在 Vert.x 的框架范围之内做最小的改动实现了所需要的功能。不同的用户源的实现和管理可以使用不同的方式实现,我在项 目中使用的是 Java 的 SPI 服务发现机制;如果有必要,可以在项目中引入依赖注入框架(如 Guice Spring 等)管理用户源逻辑的实现。