Dropwizard 认证与授权完全指南:Authenticator、Authorizer 与资源保护实战

原创2026-09-24 13:36:211,140 阅读
文章标签:后端Web框架

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):

  1. 从 Authorization 请求头取出值;
  2. 校验 scheme 前缀(默认 Basic,与 prefix 不区分大小写比对);
  3. Base64 解码出 username:password 形式并拆分为 BasicCredentials;
  4. 调用 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 模块及其测试代码中找到对应的实现范式,可直接照搬到自己的项目中。

登录后查看全文
dropwizard