认证与权限安全
你当前正在浏览历史版本 Vert.x 4.3.8 的文档。 访问本页面的 最新版本。

OAuth2 身份认证与授权提供者 (vertx-auth-oauth2)

该组件提供了开箱即用的 OAuth2(以及在一定程度上支持 OpenID Connect)依赖方(Relying Party / Client)安全实现。 要在项目中使用该组件,请在构建描述文件的 dependencies 区域添加以下依赖:

  • Maven(在 pom.xml 中):

<dependency>
  <groupId>io.vertx</groupId>
  <artifactId>vertx-auth-oauth2</artifactId>
  <version>5.0.12</version>
</dependency>
  • Gradle(在 build.gradle 文件中):

compile 'io.vertx:vertx-auth-oauth2:5.0.12'

OAuth2 允许用户将指定受保护资源的访问权限授权给第三方应用,并赋予用户随时启用或吊销这些访问权限的能力。

Vert.x OAuth2 全面支持以下标准授权模式:

  • 授权码模式(Authorization Code Flow):适用于拥有能够安全持久化存储凭据的服务端应用。

  • 密码凭据模式(Password Credentials Flow):用于无法采用授权码模式的高信任场景或本地开发测试阶段。

  • 客户端凭据模式(Client Credentials Flow):客户端仅使用其自身的客户端凭据向授权服务器请求访问令牌(用于系统间服务调用)。

同一套代码亦能无缝协同 OpenID Connect 服务器运作,并完整支持 http://openid.net/specs/openid-connect-discovery-1_0.html 中定义的 OIDC 自动发现协议。

授权码模式 (Authorization Code Flow)

授权码授权类型用于同时获取访问令牌(Access Token)与刷新令牌(Refresh Token),并专门针对机密客户端(Confidential Clients)进行了安全优化。 作为一种基于 HTTP 重定向的授权交互,客户端必须具备与资源拥有者的用户代理(通常为 Web 浏览器)进行交互的能力,并且能够接收来自授权服务器的重定向入站回调请求。

更多规范细节请参阅 OAuth2 规范第 4.1 节。

密码凭据模式 (Password Credentials Flow)

资源拥有者密码凭据授权类型适用于资源拥有者与客户端之间存在高度信任关系的场景(例如设备操作系统原生组件或企业第一方高特权应用)。 授权服务器在启用此授权类型时应保持高度审慎,通常仅在其他授权模式均不可行时方予以开放。

该授权类型适用于能够直接获取资源拥有者原始凭据(用户名与密码,通常通过交互式表单录入)的受信任客户端。 它也常用于将采用直接认证方案(如 HTTP Basic 或 Digest 摘要认证)的传统遗留客户端通过将存储凭据兑换为 OAuth 访问令牌的方式平滑迁移。

更多规范细节请参阅 OAuth2 规范第 4.3 节。

客户端凭据模式 (Client Credentials Flow)

当客户端请求访问其自身控制下的受保护资源,或访问由授权服务器预先协商授权的其他资源拥有者的资源时,客户端仅凭其客户端凭据(Client Credentials)即可直接向服务端申请访问令牌。

客户端凭据模式 必须且仅能 由机密客户端(Confidential Clients)使用。

更多规范细节请参阅 OAuth2 规范第 4.4 节。

JWT 代式授权模式 (On-Behalf-Of Flow)

客户端可以使用 JWT 令牌发起访问令牌申请,这在微服务架构中也被称为“代表(On-Behalf-Of)”身份传递模式。

扩展规范支持

该提供者原生支持 RFC 7523 规范扩展,允许基于 JWT 声明实现安全的服务端到服务端(Server-to-Server)身份授权。

快速上手

以下是以集成 GitHub 登录授权为例的完整实现范式:

OAuth2Auth oauth2 = OAuth2Auth.create(vertx, new OAuth2Options()
  .setClientId("YOUR_CLIENT_ID")
  .setClientSecret("YOUR_CLIENT_SECRET")
  .setSite("https://github.com/login")
  .setTokenPath("/oauth/access_token")
  .setAuthorizationPath("/oauth/authorize")
);

// 当需要访问受保护资源或调用授权接口时,生成授权挑战 URL:
String authorization_uri = oauth2.authorizeURL(new OAuth2AuthorizationURL()
  .setRedirectUri("http://localhost:8080/callback")
  .addScope("notifications")
  .setState("3(#0/!~"));

// 在 Web 应用中将用户重定向至上述生成的授权地址

// 用户授权后,GitHub 将重定向回调至你配置的 callback URI,随后按如下方式完成握手:

// code 由 GitHub 作为 URL Query 参数传递至回调端点
String code = "xxxxxxxxxxxxxxxxxxxxxxxx";

oauth2.authenticate(
    new Oauth2Credentials()
      .setCode(code)
      .setRedirectUri("http://localhost:8080/callback"))
  .onSuccess(user -> {
    // 成功获取凭据并保存 Token,继续后续业务...
  })
  .onFailure(err -> {
    // 错误处理:授权码无效或已过期
  });

深入授权码交互流程

授权码模式分为两个主要阶段: 第一阶段,应用向用户发起请求以获取访问其数据的权限。若用户同意授权,OAuth2 服务器将向客户端返回一个短期授权码(Authorization Code)。 第二阶段,客户端将该授权码同自身的 Client Secret 通过 HTTP POST 提交至授权服务器,以换取最终的 Access Token。

OAuth2Options credentials = new OAuth2Options()
  .setClientId("<client-id>")
  .setClientSecret("<client-secret>")
  .setSite("https://api.oauth.com");

// 初始化 OAuth2 认证实例
OAuth2Auth oauth2 = OAuth2Auth.create(vertx, credentials);

// 生成 OAuth2 授权引导 URI
String authorization_uri = oauth2.authorizeURL(new OAuth2AuthorizationURL()
  .setRedirectUri("http://localhost:8080/callback")
  .addScope("<scope>")
  .setState("<state>"));

// 使用 Vert.x HTTP 响应执行 302 重定向
response.putHeader("Location", authorization_uri)
  .setStatusCode(302)
  .end();

Credentials tokenConfig = new Oauth2Credentials()
  .setCode("<code>")
  .setRedirectUri("http://localhost:3000/callback");

// 在回调端点中兑换 Access Token
oauth2.authenticate(tokenConfig)
  .onSuccess(user -> {
    // 成功获取用户访问令牌对象
  })
  .onFailure(err -> {
    System.err.println("获取 Access Token 失败: " + err.getMessage());
  });

深入密码凭据交互流程

此模式适用于资源拥有者对客户端拥有极高信任度的场景(如操作系统原生应用或特权管理控制台)。 仅当其他授权模式均不适用或在极速本地测试调试时使用。

OAuth2Auth oauth2 = OAuth2Auth.create(vertx);

Credentials tokenConfig = new UsernamePasswordCredentials(
  "username", "password");

oauth2.authenticate(tokenConfig)
  .onSuccess(user -> {
    // 成功获取令牌对象
    // 随后可以在后续受保护请求的 HTTP Authorization 头中传递该值:
    String httpAuthorizationHeader = user.principal()
      .getString("access_token");

  })
  .onFailure(err -> {
    System.err.println("获取 Access Token 失败: " + err.getMessage());
  });

深入客户端凭据交互流程

此模式专为客户端直接访问自身名下受保护资源的系统间服务调用场景而设计。

OAuth2Options credentials = new OAuth2Options()
  .setClientId("<client-id>")
  .setClientSecret("<client-secret>")
  .setSite("https://api.oauth.com");

// 初始化 OAuth2 认证库
OAuth2Auth oauth2 = OAuth2Auth.create(vertx, credentials);

Credentials tokenConfig = new TokenCredentials("<token>");

oauth2.authenticate(tokenConfig)
  .onSuccess(user -> {
    // 认证成功
  })
  .onFailure(err -> {
    System.err.println("获取 Access Token 失败: " + err.getMessage());
  });

OpenID Connect 自动发现协议 (OIDC Discovery)

模块对 OpenID Discovery 规范提供了原生支持。 利用 OIDC Discovery 协议,你可以将安全模块的配置简化为一行代码。例如快速接入 Google 账号认证:

OpenIDConnectAuth.discover(
    vertx,
    new OAuth2Options()
      .setClientId("clientId")
      .setClientSecret("clientSecret")
      .setSite("https://accounts.google.com"))
  .onSuccess(oauth2 -> {
    // 自动发现与初始化成功!
    // 此时 OAuth2 实例已就绪,Google 公钥签名密钥库已自动加载,可直接进行 Token 解码与验签。
  })
  .onFailure(err -> {
    // 发现配置失败
  });

底层执行的标准序列包括:

  1. 向 .well-known/openid-configuration 端点发起 HTTP GET 请求拉取元数据

  2. 依据 OIDC 规范核验响应中的 issuer 字段(必须与请求的发行端点严格一致)

  3. 若存在 jwks_uri,自动从服务端加载公钥集(JWKS)并注册至密钥链中

  4. 完成认证模块组装并返回已完全初始化的实例。

常见的主流 OpenID Connect 发现端点示例:

配合指定的 clientId 与 clientSecret 即可自动完成安全配置。

针对上述主流身份提供商,模块提供了专属快捷工厂:

KeycloakAuth.discover(
    vertx,
    new OAuth2Options()
      .setClientId("clientId")
      .setClientSecret("clientSecret")
      .setSite("https://keycloakhost:keycloakport/auth/realms/{realm}")
      .setTenant("your-realm"))
  .onSuccess(oauth2 -> {
    // ...
  });

// Google 示例
GoogleAuth.discover(
    vertx,
    new OAuth2Options()
      .setClientId("clientId")
      .setClientSecret("clientSecret"))
  .onSuccess(oauth2 -> {
    // ...
  });

// Salesforce 示例
SalesforceAuth.discover(
    vertx,
    new OAuth2Options()
      .setClientId("clientId")
      .setClientSecret("clientSecret"))
  .onSuccess(oauth2 -> {
    // ...
  });

// Azure AD 示例
AzureADAuth.discover(
    vertx,
    new OAuth2Options()
      .setClientId("clientId")
      .setClientSecret("clientSecret")
      .setTenant("your-app-guid"))
  .onSuccess(oauth2 -> {
    // ...
  });

// IBM Cloud 示例
IBMCloudAuth.discover(
    vertx,
    new OAuth2Options()
      .setClientId("clientId")
      .setClientSecret("clientSecret")
      .setSite("https://<region-id>.appid.cloud.ibm.com/oauth/v4/{tenant}")
      .setTenant("your-tenant-id"))
  .onSuccess(oauth2 -> {
    // ...
  });

User 用户对象与生命周期

当 Access Token 到期时,我们需要对其进行刷新。 OAuth2 模块扩展了用户对象能力,提供了便利的刷新与吊销方法:

if (user.expired()) {
  // 异步执行刷新
  oauth2.refresh(user)
    .onSuccess(refreshedUser -> {
      // 成功获得刷新后的用户对象
    })
    .onFailure(err -> {
      // 错误处理...
    });
}

当用户登出或会话结束时,可以显式吊销访问令牌和刷新令牌:

oauth2.revoke(user, "access_token")
  .onSuccess(v -> {
    // 当前访问令牌已吊销。若刷新令牌未吊销,仍可被用于换取新令牌。

    // 继续吊销 refresh_token
    oauth2.revoke(user, "refresh_token")
      .onSuccess(v2 -> {
        System.out.println("所有令牌已成功吊销。");
      });
  });

常见 OAuth2 服务商配置参考

为简化集成,模块预置了丰富的内置适配器:

JBoss Keycloak 集成

Keycloak 适配器能够深度解析 Access Token 并提取其中内嵌的角色与权限 Grants 数据。该能力对于在 API 层面直接完成权限鉴权极其高效:

JsonObject keycloakJson = new JsonObject()
  .put("realm", "master")
  .put("realm-public-key", "MIIBIjANBgkqhk...wIDAQAB")
  .put("auth-server-url", "http://localhost:9000/auth")
  .put("ssl-required", "external")
  .put("resource", "frontend")
  .put("credentials", new JsonObject()
    .put("secret", "2fbf5e18-b923-4a83-9657-b4ebd5317f60"));

// 初始化 OAuth2 认证实例
OAuth2Auth oauth2 = KeycloakAuth
  .create(vertx, OAuth2FlowType.PASSWORD, keycloakJson);

// 第一步:获取令牌(认证)
oauth2.authenticate(
    new UsernamePasswordCredentials("user", "secret"))
  .onSuccess(user -> {
    // 第二步:执行权限鉴权判定
    AuthorizationProvider authz = KeycloakAuthorization.create();

    authz.getAuthorizations(user)
      .onSuccess(v -> {
        if (
          RoleBasedAuthorization.create("manage-account")
            .setResource("account")
            .match(user)) {
          // 当前用户已获得管理账户的权限!
        }
      });
  });

针对 Keycloak,我们还提供了快捷辅助类以直接提取已解码的令牌及关键 Principal 数据(如 preferred_username):

JsonObject idToken = user.attributes().getJsonObject("idToken");

// 亦可直接从 Keycloak principal 中读取属性:
String username = user.principal().getString("preferred_username");

请记住 Keycloak 完整支持 OpenID Connect,因此你可以直接利用 Discovery URL 极简初始化:

OpenIDConnectAuth.discover(
    vertx,
    new OAuth2Options()
      .setClientId("clientId")
      .setTenant("your_realm")
      .setSite("https://server:port/auth/realms/{tenant}"))
  .onSuccess(oauth2 -> {
    // 发现与加载成功,直接使用即可!
  });

Google 服务端到服务端授权 (RFC 7523)

模块原生支持 RFC 7523 规范,支持与 Google Service Account(服务账号)的服务间免交互授权对接。

令牌自省 (Token Introspection)

可以通过令牌自省(Introspection)机制向授权服务器实时确认令牌当前是否依然有效。 尽管 RFC 7662 明确定义了标准自省协议,但部分服务商会采用定制的 TokenInfo 端点。Vert.x OAuth2 提供者对这两种端点均提供了兼容配置,并已验证支持 Google 与 Keycloak。

令牌自省将令牌视作不透明字符串(Opaque Token),每次校验均需要与授权服务器发起一次网络往返通信。自省可在 OAuth2 级别或 User 级别执行:

oauth2.authenticate(new TokenCredentials("opaque string"))
  .onSuccess(theUser -> {
    // 令牌经自省确认为有效!
  });

// 在 User 级别执行自省:
oauth2.authenticate(new TokenCredentials(user.<String>get("access_token")))
  .onSuccess(authenticatedUser -> {
    // 令牌有效!
  });

校验 JWT 令牌

前文介绍了如何自省令牌。而在处理标准 JWT 令牌时,完全可以在应用本地利用公钥进行签名验签,从而消除与授权服务器的网络往返,大幅提升系统吞吐与响应耗时。 本地校验 JWT 更加轻量且性能极佳;但由于 JWT 的无状态本质,本地验签无法即时感知用户是否已在服务端主动注销或 Token 是否被提前拉黑。若业务对状态一致性有极高要求,建议在必要时结合 Token 自省。

oauth2.authenticate(new TokenCredentials("jwt-token"))
  .onSuccess(theUser -> {
    // JWT 本地验签通过!
  });

基于角色的访问控制 (RBAC)

OAuth2 本身是单纯的认证协议(AuthN),但 OpenID Connect 将 JWT 引入了令牌生态,使得权限授权数据(AuthZ)能够直接编码在 Token 载荷内部。 目前主流的 JWT 授权载荷格式包括:

  • Keycloak JWT 格式

  • MicroProfile JWT 1.1 规范(由 auth-jwt 模块提供支持)

Keycloak JWT 格式鉴权

由于 Keycloak 原生签发包含权限声明的 JWT Access Token,系统可以在两个维度执行鉴权断言:

  • 角色(Role)

  • 权限(Authority)

为区分两者,系统遵循与基类一致的约定,使用 : 作为分隔符。角色与权限不必同时存在,最基础的场景下仅声明权限即可。

映射 Keycloak 令牌格式时执行以下匹配规则:

  1. 若未指定角色,默认使用提供者对应的 Realm 名称

  2. 若角色为 realm,则在 realm_access 列表中进行检索

  3. 若指定了具体角色,则在 resource_access 列表下的对应角色名称内查找

核查特定权限

以下是在用户通过 OAuth2 握手成功加载后执行权限断言的示例:

if (PermissionBasedAuthorization.create("print").match(user)) {
  // 匹配成功,该用户拥有打印权限
}

若需校验用户是否在系统全局(Realm 级别)拥有添加用户的权限:

if (
  PermissionBasedAuthorization.create("add-user")
    .setResource("realm")
    .match(user)) {
  // 匹配成功,该用户拥有在系统全局添加用户的特权
}

或者核实用户是否有权访问财务(finance)部门的年度财务报表:

if (
  PermissionBasedAuthorization.create("year-report")
    .setResource("finance")
    .match(user)) {
  // 匹配成功,该用户有权查阅财务部门年度报表
}

MicroProfile JWT 1.1 规范

另一种标准化格式是 MP-JWT 1.1。该规范在令牌载荷中通过名为 groups 的 JSON 字符串数组声明该令牌所覆盖的用户组权限。

若需基于该规范进行授权断言,请使用 auth-jwt 模块中提供的 AuthorizationProvider 工厂类。

令牌生命周期管理

检查令牌是否已过期

从授权服务器获取并缓存在客户端或服务端的令牌在后续使用时可能已经过期失效。你可以按如下方式快速判断:

boolean isExpired = user.expired();

该方法是完全离线执行的本地比对,主要核对令牌中的过期时间戳(exp)等字段。因此即使本地判定未过期,仍有可能授权服务器已在后台主动将该令牌吊销。

刷新令牌

当检测到令牌即将到期时,为了避免将用户强制重定向至登录页,可以使用 Refresh Token 刷新凭据:

oauth2.refresh(user)
  .onSuccess(refreshedUser -> {
    // 令牌刷新成功,获得更新后的用户对象
  })
  .onFailure(err -> {
    // 刷新失败。作为最佳实践,此时应引导用户重新登录,因为这通常意味着刷新令牌已失效或用户在授权端已被踢下线。
  });

吊销令牌

当一个令牌被多个客户端共享,或者用户需要注销时,可以向 OAuth2 服务器发起吊销请求:

oauth2.revoke(user, "access_token")
  .onSuccess(v -> {
    // Access Token 吊销成功
  })
  .onFailure(err -> {
    // 吊销失败
  });

该调用需要显式指定令牌类型(如 access_token 或 refresh_token)。请注意:若仅吊销了 refresh_token,当前 access_token 在其自然有效期内依然可用,但将无法再次进行续期。

实时在线自省

与本地 expired() 检查不同,自省是完全在线向 OAuth2 授权服务器发起校验的强一致性检查:

oauth2.authenticate(new TokenCredentials(user.<String>get("access_token")))
  .onSuccess(validUser -> {
    // 服务端自省确认令牌当前依然合法有效
  })
  .onFailure(err -> {
    // 自省失败,说明该令牌已被授权端主动注销或失效,应立即终止会话
  });

用户注销 (Logout)

登出注销虽然不是传统 OAuth2 核心规范所定义的动作,但广泛存在于 OpenID Connect 体系中。提供者支持自动构造符合规范的登出重定向地址:

String logoutUrl = oauth2.endSessionURL(user);

密钥轮转与管理 (Key Management)

当通过 jwks 路径或 OIDC Discovery 发现机制配置提供者时,系统支持自动处理密钥轮转(Key Rotation)。 提供者遵循 OpenID Connect 核心规范推荐的两种机制:

  1. 定期轮转:在调用刷新方法时,若服务端返回了规范推荐的 Cache-Control 缓存头,系统将启动定时任务在推荐的时间周期自动重新拉取最新的公钥集。

  2. 应急轮转:当服务端由于密钥泄露等突发事件临时变更了密钥并颁发了携带未知 kid 的新 Token 时,提供者会侦测到缺失的未知密钥并通知上层触发密钥重载。需要注意的是,业务层应对刷新调用实施适当的限流防护,以防止针对 IdP 授权服务器的非预期拒绝服务压力。