Dropwizard 认证与授权完全指南:Authenticator、Authorizer 与资源保护实战
Dropwizard 认证与授权完全指南:Authenticator、Authorizer 与资源保护实战
本指南以 docs/source/manual/auth.rst 为核心骨架,系统讲解 Dropwizard 的 dropwizard-auth 模块:如何通过 Authenticator/Authorizer 两个核心抽象实现认证与授权,如何启用 HTTP Basic 与 OAuth2 Bearer Token 两种内置认证方案,如何用注解与 @Auth 注入保护 REST 资源,以及如何测试受保护资源。读完本文,你将能够在自己的 Dropwizard 应用中直接搭建一套可缓存、可链式组合、可多方案并存的生产级认证授权体系。
认证与授权的核心抽象
Authenticator:凭据到 Principal 的映射
在 Dropwizard 中,认证器(Authenticator) 是一个策略类:给定客户端提供的凭据,它"可能"返回一个 Principal(委托人,即你的服务将要代表其执行操作的那个人或实体)。
Authenticator<C, P extends Principal> 接口只声明了一个方法(见 Authenticator.java):
public class ExampleAuthenticator implements Authenticator<BasicCredentials, User> {
@Override
public Optional<User> authenticate(BasicCredentials credentials) throws AuthenticationException {
if ("secret".equals(credentials.getPassword())) {
return Optional.of(new User(credentials.getUsername()));
}
return Optional.empty();
}
}
该接口的契约非常明确:
- 凭据有效并能映射到某个 Principal 时,返回
Optional.of(...); - 凭据无效时,返回
Optional.empty(); - 认证器无法完成凭据校验时(例如数据库宕机),抛出
AuthenticationException。
上面这个认证器接收 basic auth 凭据,当客户端提供的密码等于 secret 时,将其认证为携带该用户名的 User;否则返回空 Optional,表示凭据无效。
安全警告:认证服务切忌在错误信息中透露过多细节。用户名/邮箱是否存在本身对攻击者就有价值,因此
Authenticator接口刻意不允许你区分"用户名错误"和"密码错误"。只有当你无法检查凭据(例如后端数据库不可用)时才应抛出AuthenticationException。
Authorizer:Principal 与角色的授权判定
授权器(Authorizer) 是一个策略类:给定一个 Principal 和一个角色,决定是否授予该 Principal 访问权限。
Authorizer<P extends Principal> 接口的核心方法(见 Authorizer.java):
public class ExampleAuthorizer implements Authorizer<User> {
@Override
public boolean authorize(User user, String role) {
return user.getName().equals("good-guy") && role.equals("ADMIN");
}
}
从源码可以看到,自 2.0 起 authorize 方法还接收一个 ContainerRequestContext,便于授权时感知当前请求上下文;自 2.1 起新增了默认方法 getAuthorizationContext(principal, role, requestContext),返回一个 DefaultAuthorizationContext 作为 CachingAuthorizer 的缓存键。
认证缓存:CachingAuthenticator 与 CaffeineSpec
认证器背后的数据源(如 RDBMS、LDAP 服务器)往往无法承受高吞吐量。Dropwizard 为此提供了一个装饰器类 CachingAuthenticator,用于缓存认证结果:
SimpleAuthenticator simpleAuthenticator = new SimpleAuthenticator();
CachingAuthenticator<BasicCredentials, User> cachingAuthenticator = new CachingAuthenticator<>(
metricRegistry, simpleAuthenticator,
config.getAuthenticationCachePolicy());
CachingAuthenticator 基于 Caffeine 缓存实现(见 CachingAuthenticator.java),其构造函数接受:
- metricRegistry:应用的指标注册表,缓存自动记录
cache-misses(Meter)与gets(Timer),并将统计接入MetricsStatsCounter; - authenticator:被装饰的底层认证器;
- cacheSpec:一个
CaffeineSpec,或直接传Caffeine<Object, Object>builder; - cacheNegativeResult(可选):是否缓存"否定结果"(即空 Optional)。默认
false,此时认证器返回空 Optional 时,内部会抛出InvalidCredentialsException阻止缓存无效凭据;置为true则连失败结果也缓存,可防御针对不存在账号的暴力探测。
Dropwizard 能从配置文件直接解析 Caffeine 的 CaffeineSpec,因此你的配置文件中可以这样写:
authenticationCachePolicy: maximumSize=10000, expireAfterAccess=10m
这条策略最多缓存 10,000 个 Principal,条目在 10 分钟无访问后过期。此外 CachingAuthenticator 还提供了 invalidate(credentials)、invalidateAll(...)、size()、stats() 等方法,便于在账号变更、权限撤销等场景主动失效缓存(见 CachingAuthenticator.java)。
启用 HTTP Basic 认证
AuthDynamicFeature 搭配 BasicCredentialAuthFilter 与 RolesAllowedDynamicFeature 即可开启 HTTP Basic 认证与授权;这要求认证器接收 BasicCredentials 类型的凭据。如果不需要授权,可以省略 RolesAllowedDynamicFeature。
在应用的 run 方法中注册:
@Override
public void run(ExampleConfiguration configuration,
Environment environment) {
environment.jersey().register(new AuthDynamicFeature(
new BasicCredentialAuthFilter.Builder<User>()
.setAuthenticator(new ExampleAuthenticator())
.setAuthorizer(new ExampleAuthorizer())
.setRealm("SUPER SECRET STUFF")
.buildAuthFilter()));
environment.jersey().register(RolesAllowedDynamicFeature.class);
// 如果想用 @Auth 将自定义 Principal 类型注入资源方法
environment.jersey().register(new AuthValueFactoryProvider.Binder<>(User.class));
}
底层工作原理(见 BasicCredentialAuthFilter.java):
- 从
Authorization请求头取出值; - 校验 scheme 前缀(默认
Basic,与prefix不区分大小写比对); - Base64 解码出
username:password形式并拆分为 BasicCredentials; - 调用
authenticate(...):认证成功则构造携带 Principal 的SecurityContext并写入请求上下文(见 AuthFilter.java),认证失败则抛出由UnauthorizedHandler构建的401响应。
AuthFilter 标注了 @Priority(Priorities.AUTHENTICATION),确保认证过滤器先于其他业务过滤器执行;其 Builder 还支持 setUnauthorizedHandler(...) 自定义 401 响应(默认 DefaultUnauthorizedHandler,另有 JSONUnauthorizedHandler 可返回 JSON)。
启用 OAuth2 Bearer Token 认证
AuthDynamicFeature 搭配 OAuthCredentialAuthFilter 与 RolesAllowedDynamicFeature 可开启 OAuth2 Bearer Token 认证与授权;这要求认证器接收 String 类型的凭据(即 token 本身)。同样,不需要授权时省略 RolesAllowedDynamicFeature。
@Override
public void run(ExampleConfiguration configuration,
Environment environment) {
environment.jersey().register(new AuthDynamicFeature(
new OAuthCredentialAuthFilter.Builder<User>()
.setAuthenticator(new ExampleOAuthAuthenticator())
.setAuthorizer(new ExampleAuthorizer())
.setPrefix("Bearer")
.buildAuthFilter()));
environment.jersey().register(RolesAllowedDynamicFeature.class);
// 如果想用 @Auth 将自定义 Principal 类型注入资源方法
environment.jersey().register(new AuthValueFactoryProvider.Binder<>(User.class));
}
从 OAuthCredentialAuthFilter.java 的实现可以看到两个值得注意的细节:
- 支持从
Authorization: Bearer <token>头解析 token(prefix默认Bearer); - 额外支持从查询参数取 token:常量
OAUTH_ACCESS_TOKEN_PARAM = "access_token",当Authorization头缺失时,会回退到 URL 查询参数?access_token=...中获取(对应 RFC 6750 第 2.3 节对"URI 查询参数传递 Bearer Token"的说明)。
链式认证工厂:ChainedAuthFilter
ChainedAuthFilter 允许你同时启用多种认证方案:按顺序尝试每一个过滤器,一旦某个过滤器成功认证(即安全上下文发生了变化)就短路返回(见 ChainedAuthFilter.java):
@Override
public void run(ExampleConfiguration configuration,
Environment environment) {
AuthFilter basicCredentialAuthFilter = new BasicCredentialAuthFilter.Builder<>()
.setAuthenticator(new ExampleBasicAuthenticator())
.setAuthorizer(new ExampleAuthorizer())
.setPrefix("Basic")
.buildAuthFilter();
AuthFilter oauthCredentialAuthFilter = new OAuthCredentialAuthFilter.Builder<>()
.setAuthenticator(new ExampleOAuthAuthenticator())
.setAuthorizer(new ExampleAuthorizer())
.setPrefix("Bearer")
.buildAuthFilter();
List<AuthFilter> filters = Lists.newArrayList(basicCredentialAuthFilter, oauthCredentialAuthFilter);
environment.jersey().register(new AuthDynamicFeature(new ChainedAuthFilter(filters)));
environment.jersey().register(RolesAllowedDynamicFeature.class);
// 如果想用 @Auth 将自定义 Principal 类型注入资源方法
environment.jersey().register(new AuthValueFactoryProvider.Binder<>(User.class));
}
关键约束:所有被链式组合的过滤器必须产出同一种 Principal 类型(此处均为 User)。该约束在编译期无法由类型系统强制,混用不一致的 Principal 会导致运行时错误——源码注释中也明确指出了这一点。各过滤器可以使用不同的凭据类型,因为 ChainedAuthFilter 只是把请求委托给内部封装了认证器与凭据类型的具体过滤器。
保护资源:注解、@Auth 注入与 SecurityContext
方法级与类级注解
保护资源方法有两种方式,第一种是使用以下注解之一标记资源方法:
@PermitAll:所有已认证用户均可访问该方法;@RolesAllowed:仅授予指定角色的用户访问;@DenyAll:任何人都不允许访问。
注意:
@RolesAllowed、@PermitAll可以放在类级别,方法级注解优先于类级注解。
AuthDynamicFeature 的实现逻辑(见 AuthDynamicFeature.java)会先扫描方法参数中是否存在 @Auth 注解,再检查类或方法上是否存在 @RolesAllowed/@PermitAll/@DenyAll 注解,据此将认证过滤器动态注册到对应的资源方法上。
@Auth 注入 Principal
第二种方式是给代表 Principal 的方法参数加上 @Auth 注解。注意必须先注册对应的 Jersey provider:
environment.jersey().register(new AuthValueFactoryProvider.Binder<>(User.class));
@RolesAllowed("ADMIN")
@GET
public SecretPlan getSecretPlan(@Auth User user) {
return dao.findPlanForUser(user);
}
AuthValueFactoryProvider(见 AuthValueFactoryProvider.java)负责把 @Auth 注解的参数解析为当前请求的 Principal:当参数类型与注册的 principalClass 一致时直接注入;当参数是 Optional<Principal> 时注入 OptionalPrincipalContainerRequestValueFactory 的结果。Binder 将 PrincipalClassProvider 与 provider 绑定到 Jersey 注入容器。
通过 SecurityContext 访问 Principal
还可以给方法增加 @Context SecurityContext context 参数来访问 Principal:
@RolesAllowed("ADMIN")
@GET
public SecretPlan getSecretPlan(@Context SecurityContext context) {
User userPrincipal = (User) context.getUserPrincipal();
return dao.findPlanForUser(user);
}
注意:@Context SecurityContext 不会自动注册执行认证的 servlet 过滤器——你仍需添加 @PermitAll、@RolesAllowed 或 @DenyAll 之一。而 @Auth 不同:只要方法上存在 @Auth 参数,认证过滤器就会自动注册,这是为了便于从旧版 Dropwizard 升级的用户平滑迁移。
无论采用哪种方式,如果请求没有提供凭据或凭据无效,provider 都会返回与认证方案对应的 401 Unauthorized 响应,而不会调用你的资源方法。
可选保护:Optional Principal
资源方法还可以做"可选"保护:将 Principal 参数表示为 Optional。此时若请求携带了有效的 Principal,参数会被填充为 Optional.of(...);否则为 Optional.empty。
典型场景:某个端点应该展示已登录用户的姓名,但未认证的请求则返回匿名回复。你需要实现一个自定义过滤器,在存在 Principal 时注入包含它的安全上下文,但不执行认证:
@GET
public String getGreeting(@Auth Optional<User> userOpt) {
if (userOpt.isPresent()) {
return "Hello, " + userOpt.get().getName() + "!";
} else {
return "Greetings, anonymous visitor!"
}
}
对可选保护的资源而言,认证失败与未提供凭据的处理方式一致:凡是未通过认证器或授权器要求的请求,都会以空 Principal 传给资源方法,而不会返回 401。AuthDynamicFeature 对 Optional 参数会额外包装一层 WebApplicationExceptionCatchingFilter,把认证过程抛出的异常吞掉、转为空 Principal 传递(见 AuthDynamicFeature.java)。
测试受保护资源
为受保护资源编写测试,需要在 pom.xml 中加入以下依赖:
<dependencies>
<dependency>
<groupId>io.dropwizard</groupId>
<artifactId>dropwizard-testing</artifactId>
<version>${dropwizard.version}</version>
</dependency>
<dependency>
<groupId>org.glassfish.jersey.test-framework.providers</groupId>
<artifactId>jersey-test-framework-provider-grizzly2</artifactId>
<version>${jersey.version}</version>
<exclusions>
<exclusion>
<groupId>jakarta.servlet</groupId>
<artifactId>jakarta.servlet-api</artifactId>
</exclusion>
<exclusion>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
</exclusion>
</exclusions>
</dependency>
</dependencies>
OAuth 示例
构建 ResourceExtension 时,加入 GrizzlyWebTestContainerFactory 这一行:
@ExtendWith(DropwizardExtensionsSupport.class)
public class OAuthResourceTest {
public ResourceExtension resourceExtension = ResourceExtension
.builder()
.setTestContainerFactory(new GrizzlyWebTestContainerFactory())
.addProvider(new AuthDynamicFeature(new OAuthCredentialAuthFilter.Builder<User>()
.setAuthenticator(new MyOAuthAuthenticator())
.setAuthorizer(new MyAuthorizer())
.setRealm("SUPER SECRET STUFF")
.setPrefix("Bearer")
.buildAuthFilter()))
.addProvider(RolesAllowedDynamicFeature.class)
.addProvider(new AuthValueFactoryProvider.Binder<>(User.class))
.addResource(new ProtectedResource())
.build();
}
测试时需手动设置 token 请求头:
@Test
public void testProtected() throws Exception {
final Response response = resourceExtension.target("/protected")
.request(MediaType.APPLICATION_JSON_TYPE)
.header("Authorization", "Bearer TOKEN")
.get();
assertThat(response.getStatus()).isEqualTo(200);
}
Basic Auth 示例
同样需要 GrizzlyWebTestContainerFactory,并手动构造 Basic 凭据:
@ExtendWith(DropwizardExtensionsSupport.class)
public class OAuthResourceTest {
public ResourceExtension resourceExtension = ResourceExtension
.builder()
.setTestContainerFactory(new GrizzlyWebTestContainerFactory())
.addProvider(new AuthDynamicFeature(new BasicCredentialAuthFilter.Builder<User>()
.setAuthenticator(new MyBasicAuthenticator())
.setAuthorizer(new MyBasicAuthorizer())
.buildAuthFilter()))
.addProvider(RolesAllowedDynamicFeature.class)
.addProvider(new AuthValueFactoryProvider.Binder<>(User.class))
.addResource(new ProtectedResource())
.build()
}
@Test
public void testProtectedResource(){
String credential = "Basic " + Base64.getEncoder().encodeToString("test@gmail.com:secret".getBytes())
Response response = resourceExtension
.target("/protected")
.request()
.header(HttpHeaders.AUTHORIZATION, credential)
.get();
Assert.assertEquals(200, response.getStatus());
}
仓库中的测试代码可作为进一步参考,例如 BasicAuthProviderTest.java、OAuthProviderTest.java 与 AuthBaseTest.java 展示了完整的资源配置与断言方式。
多 Principal 与多认证方案并存:PolymorphicAuthDynamicFeature
某些场景下,你可能希望对不同资源使用不同的认证器/认证方案,例如一个资源用 Basic 认证、另一个用 OAuth,并且每种方案使用不同的 Principal 类型。
为此 Dropwizard 提供了 PolymorphicAuthDynamicFeature 与 PolymorphicAuthValueFactoryProvider 两个组件。使用该特性需要三步:
- 用"Principal 类型 → 认证过滤器"的映射注册
PolymorphicAuthDynamicFeature; - 用将使用的 Principal 类集合注册
PolymorphicAuthValueFactoryProvider; - 在资源方法的 Principal 参数上标注
@Auth。
例如同时配置 OAuth 与 Basic 认证,且各自使用不同的 Principal:
final AuthFilter<BasicCredentials, BasicPrincipal> basicFilter
= new BasicCredentialAuthFilter.Builder<BasicPrincipal>()
.setAuthenticator(new ExampleAuthenticator())
.setRealm("SUPER SECRET STUFF")
.buildAuthFilter());
final AuthFilter<String, OAuthPrincipal> oauthFilter
= new OAuthCredentialAuthFilter.Builder<OAuthPrincipal>()
.setAuthenticator(new ExampleOAuthAuthenticator())
.setPrefix("Bearer")
.buildAuthFilter());
final PolymorphicAuthDynamicFeature feature = new PolymorphicAuthDynamicFeature<>(
ImmutableMap.of(
BasicPrincipal.class, basicFilter,
OAuthPrincipal.class, oauthFilter));
final AbstractBinder binder = new PolymorphicAuthValueFactoryProvider.Binder<>(
ImmutableSet.of(BasicPrincipal.class, OAuthPrincipal.class));
environment.jersey().register(feature);
environment.jersey().register(binder);
之后资源方法即可按参数类型自动选择认证方案:
@GET
public Response basicAuthResource(@Auth BasicPrincipal principal) {}
@GET
public Response oauthResource(@Auth OAuthPrincipal principal) {}
第一个资源方法走 Basic 认证,第二个走 OAuth。PolymorphicAuthDynamicFeature 在 configure 中按方法参数的实际类型(含 Optional 泛型参数提取)在映射中查找对应过滤器并动态注册(见 PolymorphicAuthDynamicFeature.java)。
注意:上面的示例只配置了认证。如果还需要授权,请额外完成以下步骤:
- 向应用注册
RolesAllowedDynamicFeature;- 构建
AuthFilter时设置Authorizer;- 确保任何自定义
AuthFilter带有@Priority(Priorities.AUTHENTICATION)注解(否则授权会在请求安全上下文正确建立之前执行,导致失败);- 授权注解必须标注在资源方法上——与前面提到的"允许类级注解"不同,使用多态特性时目前不支持类级注解。
延续前面的示例,授权配置如下:
... = new BasicCredentialAuthFilter.Builder<BasicPrincipal>()
.setAuthorizer(new ExampleAuthorizer()).. // set authorizer
... = new OAuthCredentialAuthFilter.Builder<OAuthPrincipal>()
.setAuthorizer(new ExampleAuthorizer()).. // set authorizer
environment.jersey().register(RolesAllowedDynamicFeature.class);
然后可以这样使用:
@GET
@RolesAllowed({ "ADMIN" })
public Response baseAuthResource(@Auth BasicPrincipal principal) {}
@GET
@RolesAllowed({ "ADMIN" })
public Response oauthResource(@Auth OAuthPrincipal principal) {}
最后务必记住:多态认证特性不应与任何其他 AuthDynamicFeature 混用,否则可能产生意想不到的副作用。仓库中的 PolymorphicPrincipalEntityTest.java 等测试展示了该特性的完整用法。
小结
Dropwizard 的认证授权体系以 Authenticator(认证)与 Authorizer(授权)两个极简接口为基石,通过 CachingAuthenticator 解决高吞吐下的认证性能问题,通过 AuthDynamicFeature + 具体认证过滤器(Basic/OAuth2)把安全逻辑织入 Jersey 资源,再配合 @Auth 注入与 @RolesAllowed/@PermitAll/@DenyAll 注解实现声明式的资源保护。无论是单一方案、链式多方案,还是按 Principal 类型区分的多态方案,都能在 dropwizard-auth 模块及其测试代码中找到对应的实现范式,可直接照搬到自己的项目中。