认证与授权

OAuth2 认证提供者

该组件包含一个开箱即用的 OAuth2(以及在某种程度上 OpenID Connect)依赖方实现。要使用此项目,请将以下依赖项添加到构建描述符的 dependencies 部分

  • Maven(在您的 pom.xml 中)

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

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

OAuth2 允许用户授权第三方应用程序访问所需的资源,并使其能够随时启用或禁用这些访问。

Vert.x OAuth2 支持以下流程。

  • 授权码流(适用于具有可存储持久信息的服务器的应用程序)。

  • 密码凭证流(当无法使用前一个流或在开发期间)。

  • 客户端凭证流(客户端只能使用其客户端凭证请求访问令牌)

相同的代码适用于 OpenID Connect https://openid.net/connect/ 服务器,并支持 http://openid.net/specs/openid-connect-discovery-1_0.html 中指定的发现协议。

授权码流

授权码授予类型用于获取访问令牌和刷新令牌,并为保密客户端进行了优化。作为一种基于重定向的流,客户端必须能够与资源所有者的用户代理(通常是 Web 浏览器)交互,并且能够从授权服务器接收传入请求(通过重定向)。

更多详情请参阅 Oauth2 规范,第 4.1 节

密码凭证流

资源所有者密码凭证授予类型适用于资源所有者与客户端之间存在信任关系的情况,例如设备操作系统或高权限应用程序。授权服务器在启用此授予类型时应特别注意,并且仅在其他流程不可行时才允许使用。

此授予类型适用于能够获取资源所有者凭证(用户名和密码,通常通过交互式表单)的客户端。它还用于将使用直接身份验证方案(例如 HTTP Basic 或 Digest 身份验证)的现有客户端迁移到 OAuth,方法是将存储的凭证转换为访问令牌。

更多详情请参阅 Oauth2 规范,第 4.3 节

客户端凭证流

当客户端请求访问其控制下的受保护资源,或之前已与授权服务器安排好的其他资源所有者的受保护资源时(此方法的具体内容超出本规范范围),客户端可以仅使用其客户端凭证(或其他受支持的身份验证方式)请求访问令牌。

客户端凭证授予类型只能由保密客户端使用。

更多详情请参阅 Oauth2 规范,第 4.4 节

JWT(代表)流

客户端可以使用 JWT 请求访问令牌,这也被称为“代表”。

扩展

该提供者支持 RFC7523,这是一个允许基于 JWT 进行服务器到服务器授权的扩展。

快速入门

以下是如何使用此提供者并使用 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")
);

// when there is a need to access a protected resource
// or call a protected method, call the authZ url for
// a challenge

String authorization_uri = oauth2.authorizeURL(new OAuth2AuthorizationURL()
  .setRedirectUri("https://:8080/callback")
  .addScope("notifications")
  .setState("3(#0/!~"));

// when working with web application use the above string as a redirect url

// in this case GitHub will call you back in the callback uri one
// should now complete the handshake as:

// the code is provided as a url parameter by github callback call
String code = "xxxxxxxxxxxxxxxxxxxxxxxx";

oauth2.authenticate(
    new Oauth2Credentials()
      .setCode(code)
      .setRedirectUri("https://:8080/callback"))
  .onSuccess(user -> {
    // save the token and continue...
  })
  .onFailure(err -> {
    // error, the code provided is not valid
  });

授权码流

授权码流由两部分组成。首先,您的应用程序请求用户访问其数据的权限。如果用户批准,OAuth2 服务器会向客户端发送一个授权码。在第二部分中,客户端将授权码及其客户端密钥 POST 到授权服务器,以获取访问令牌。

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


// Initialize the OAuth2 Library
OAuth2Auth oauth2 = OAuth2Auth.create(vertx, credentials);

// Authorization oauth2 URI
String authorization_uri = oauth2.authorizeURL(new OAuth2AuthorizationURL()
  .setRedirectUri("https://:8080/callback")
  .addScope("<scope>")
  .setState("<state>"));

// Redirect example using Vert.x
response.putHeader("Location", authorization_uri)
  .setStatusCode(302)
  .end();

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

// Callbacks
// Save the access token
oauth2.authenticate(tokenConfig)
  .onSuccess(user -> {
    // Get the access token object
    // (the authorization code is given from the previous step).
  })
  .onFailure(err -> {
    System.err.println("Access Token Error: " + err.getMessage());
  });

密码凭证流

此流适用于资源所有者与客户端之间存在信任关系的情况,例如其计算机操作系统或高权限应用程序。仅当其他流不可行或您需要快速测试应用程序时才使用此流。

OAuth2Auth oauth2 = OAuth2Auth.create(vertx);

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

oauth2.authenticate(tokenConfig)
  .onSuccess(user -> {
    // Get the access token object
    // (the authorization code is given from the previous step).

    // you can now make requests using the
    // `Authorization` header and the value:
    String httpAuthorizationHeader = user.principal()
      .getString("access_token");

  })
  .onFailure(err -> {
    System.err.println("Access Token Error: " + err.getMessage());
  });

客户端凭证流

当客户端请求访问其控制下的受保护资源时,此流是合适的。

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


// Initialize the OAuth2 Library
OAuth2Auth oauth2 = OAuth2Auth.create(vertx, credentials);

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

oauth2.authenticate(tokenConfig)
  .onSuccess(user -> {
    // Success
  })
  .onFailure(err -> {
    System.err.println("Access Token Error: " + err.getMessage());
  });

OpenID Connect 发现

对 OpenID 发现服务器的支持有限。使用 OIDC Discovery 将简化您的身份验证模块配置,只需一行代码即可完成。例如,考虑使用 Google 设置身份验证:

OpenIDConnectAuth.discover(
    vertx,
    new OAuth2Options()
      .setClientId("clientId")
      .setClientSecret("clientSecret")
      .setSite("https://#"))
  .onSuccess(oauth2 -> {
    // the setup call succeeded.
    // at this moment your auth is ready to use and
    // google signature keys are loaded so tokens can be decoded and verified.
  })
  .onFailure(err -> {
    // the setup failed.
  });

在幕后,会执行以下几个操作:

  1. .well-known/openid-configuration 资源发出 HTTP GET 请求

  2. 根据规范要求验证响应中的 issuer 字段(发行者值必须与请求值匹配)

  3. 如果存在 JWK URI,则从服务器加载密钥并将其添加到身份验证密钥链

  4. 身份验证模块配置完毕并返回给用户。

一些知名的 OpenID Connect Discovery 提供者包括:

这些信息加上提供的 client id/client secret 足以配置您的身份验证提供者对象。

对于这些知名提供者,提供了快捷方式

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

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

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

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

// IBM Cloud example
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 -> {
    // ...
  });

用户对象

当令牌过期时,我们需要刷新它。OAuth2 提供了 AccessToken 类,其中包含几个有用的方法,用于在访问令牌过期时刷新它。

if (user.expired()) {
  // Callbacks
  oauth2.refresh(user)
    .onSuccess(refreshedUser -> {
      // the refreshed user is now available
    })
    .onFailure(err -> {
      // error handling...
    });
}

当您使用完令牌或想要注销时,可以吊销访问令牌和刷新令牌。

oauth2.revoke(user, "access_token")
  .onSuccess(v -> {
    // Session ended. But the refresh_token is still valid.

    // Revoke the refresh_token
    oauth2.revoke(user, "refresh_token")
      .onSuccess(v2 -> {
        System.out.println("token revoked.");
      });
  });

常见 OAuth2 提供者的配置示例

为方便起见,有几个辅助工具可以帮助您进行配置。目前我们提供:

JBoss Keycloak

使用此 Keycloak 时,提供者知道如何解析访问令牌并从中提取授权。此信息非常有价值,因为它允许在 API 级别进行授权,例如:

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

// Initialize the OAuth2 Library
OAuth2Auth oauth2 = KeycloakAuth
  .create(vertx, OAuth2FlowType.PASSWORD, keycloakJson);

// first get a token (authenticate)
oauth2.authenticate(
    new UsernamePasswordCredentials("user", "secret"))
  .onSuccess(user -> {
    // now check for permissions
    AuthorizationProvider authz = KeycloakAuthorization.create();

    authz.getAuthorizations(user)
      .onSuccess(v -> {
        if (
          RoleBasedAuthorization.create("manage-account")
            .setResource("account")
            .match(user)) {
          // this user is authorized to manage its account
        }
      });
  });

我们还为 Keycloak 提供了一个辅助类,以便我们可以轻松地从 Keycloak principal 中检索解码后的令牌和一些必要的数据(例如 preferred_username)。例如:

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

// you can also retrieve some properties directly from the Keycloak principal
// e.g. `preferred_username`
String username = user.principal().getString("preferred_username");

请记住,Keycloak 确实实现了 OpenID Connect,因此您只需使用其发现 URL 即可对其进行配置

OpenIDConnectAuth.discover(
    vertx,
    new OAuth2Options()
      .setClientId("clientId")
      .setTenant("your_realm")
      .setSite("https://server:port/auth/realms/{tenant}"))
  .onSuccess(oauth2 -> {
    // the setup call succeeded.
    // at this moment your auth is ready to use
  });

由于您可以将 Keycloak 服务器部署在任何地方,只需将 server:port 替换为正确的值,并将 your_realm 替换为您的应用程序 realm。

Google 服务器到服务器

该提供者还支持服务器到服务器或 RFC7523 扩展。这是 Google 及其服务账户中存在的一项功能。

令牌内省

可以对令牌进行内省以确认它们仍然有效。尽管有 RFC7662 用于此目的,但没有多少提供者实现它。取而代之的是一些变体,也称为 TokenInfo 端点。OAuth2 提供者将接受这两个端点作为配置。目前已知我们与 GoogleKeycloak 配合良好。

令牌内省假设令牌是不透明的,因此需要在提供者服务器上进行验证。每次验证令牌都需要与提供者进行一次往返。内省可以在 OAuth2 级别或用户级别执行

oauth2.authenticate(new TokenCredentials("opaque string"))
  .onSuccess(theUser -> {
    // token is valid!
  });

// User level
oauth2.authenticate(new TokenCredentials(user.<String>get("access_token")))
  .onSuccess(authenticatedUser -> {
    // Token is valid!
  });

验证 JWT 令牌

我们刚刚介绍了如何内省令牌,但在处理 JWT 令牌时,可以减少与提供者服务器的往返次数,从而提高您的整体响应时间。在这种情况下,令牌将仅在您的应用程序端使用 JWT 协议进行验证。验证 JWT 令牌成本更低且性能更好,但是由于 JWT 的无状态性,无法知道用户是否已注销以及令牌是否无效。对于这种特定情况,如果提供者支持,则需要使用令牌内省。

oauth2.authenticate(new TokenCredentials("jwt-token"))
  .onSuccess(theUser -> {
    // token is valid!
  });

到目前为止,我们主要讨论了身份验证,尽管实现是依赖方(这意味着实际的身份验证发生在其他地方),但您可以使用处理程序做更多事情。例如,如果提供者已知支持 JSON Web 令牌,您也可以进行授权。如果您的提供者是 OpenID Connect 提供者,或者提供者支持将 `access_token` 作为 JWT,这是一个常见功能。

这样的提供者就是 Keycloak,它是一个 OpenID Connect 实现。在这种情况下,您将能够以非常简单的方式执行授权。

基于角色的访问控制

OAuth2 是一个 AuthN 协议,但 OpenID Connect 将 JWT 添加到令牌格式中,这意味着 AuthZ 可以在令牌级别进行编码。目前有 2 种已知的 JWT AuthZ 格式

  • Keycloak

  • MicroProfile JWT 1.1 规范(来自 auth-jwt 模块)

Keycloak JWT

鉴于 Keycloak 确实提供了 JWT 访问令牌,可以从两个不同的级别进行授权:

  • 角色

  • 权限

为了区分两者,身份验证提供者遵循基本用户类的相同建议,即:使用`:`作为两者的分隔符。需要注意的是,角色和权限不需要同时存在,在最简单的情况下,一个权限就足够了。

为了映射到 Keycloak 的令牌格式,会执行以下检查:

  1. 如果未提供角色,则假定为提供者 realm 名称

  2. 如果角色是 realm,则在 realm_access 列表中查找

  3. 如果提供了角色,则在角色名称下的 resource_access 列表中查找

检查特定权限

这是一个示例,说明如何在从 OAuth2 握手加载用户后执行授权。例如,您想查看用户是否可以在当前应用程序中执行 print 操作:

if (PermissionBasedAuthorization.create("print").match(user)) {
  // Yes the user can print
}

然而,这非常具体。您可能想验证用户是否可以将 add-user 添加到整个系统(realm):

if (
  PermissionBasedAuthorization.create("add-user")
    .setResource("realm")
    .match(user)) {
  // Yes the user can add users to the application
}

或者用户是否可以访问 finance 部门中的 year-report

if (
  PermissionBasedAuthorization.create("year-report")
    .setResource("finance")
    .match(user)) {
  // Yes the user can access the year report from the finance department
}

MicroProfile JWT 1.1 规范

另一种规范形式是 MP-JWT 1.1。此规范在属性名 groups 下定义了一个 JSON 字符串数组,用于定义令牌具有权限的“组”。

为了使用此规范声明 AuthZ,请使用 auth-jwt 模块中提供的 AuthorizationProvider 工厂。

令牌管理

检查是否过期

令牌通常从服务器获取并缓存,在这种情况下,当稍后使用时,它们可能已经过期并无效,您可以像这样验证令牌是否仍然有效:

boolean isExpired = user.expired();

此调用完全离线,仍然可能发生 OAuth2 服务器使您的令牌失效但您获得未过期令牌结果的情况。其原因在于,过期是根据令牌的过期日期检查的,而不是根据之前的日期或类似值。

刷新令牌

有时您知道令牌即将过期,并且希望避免将用户再次重定向到登录屏幕。在这种情况下,您可以刷新令牌。要刷新令牌,您需要已经有一个用户并调用:

oauth2.refresh(user)
  .onSuccess(refreshedUser -> {
    // the refresh call succeeded
  })
  .onFailure(err -> {
    // the token was not refreshed, a best practise would be
    // to forcefully logout the user since this could be a
    // symptom that you're logged out by the server and this
    // token is not valid anymore.
  });

吊销令牌

由于令牌可以在各种应用程序之间共享,您可能希望禁止任何应用程序使用当前令牌。为此,需要向 OAuth2 服务器吊销令牌:

oauth2.revoke(user, "access_token")
  .onSuccess(v -> {
    // the revoke call succeeded
  })
  .onFailure(err -> {
    // the token was not revoked.
  });

需要注意的是,此调用需要令牌类型。原因是一些提供者将返回多个令牌,例如:

  • id_token

  • refresh_token

  • access_token

因此需要知道要使哪个令牌失效。显然,如果您使 refresh_token 失效,您仍然处于登录状态,但将无法再刷新令牌,这意味着一旦令牌过期,您需要将用户再次重定向到登录页面。

内省

内省令牌类似于过期检查,但需要注意的是,此检查是完全在线的。这意味着检查发生在 OAuth2 服务器上。

oauth2.authenticate(new TokenCredentials(user.<String>get("access_token")))
  .onSuccess(validUser -> {
    // the introspection call succeeded
  })
  .onFailure(err -> {
    // the token failed the introspection. You should proceed
    // to logout the user since this means that this token is
    // not valid anymore.
  });

重要提示是,即使 expired() 调用返回 trueintrospect 调用的返回值仍然可能是错误。这是因为 OAuth2 可能在期间收到了使令牌失效的请求或注销请求。

注销

注销不是 OAuth2 的功能,但它存在于 OpenID Connect 中,并且大多数提供者都支持某种形式的注销。如果配置足以允许它进行调用,此提供者也涵盖了这一领域。对用户来说,这就像这样简单:

String logoutUrl = oauth2.endSessionURL(user);

密钥管理

当提供者配置了 jwks 路径时,无论是手动还是使用发现机制,都会有密钥必须轮换的情况。因此,此提供者实现了 OpenID Connect 核心规范推荐的两种方式。

当调用刷新方法时,如果服务器返回 https://openid.net/specs/openid-connect-core-1_0.html#RotateEncKeys 中描述的推荐缓存头,则一个定期任务将按照服务器推荐的时间运行以重新加载密钥。

boolean isExpired = user.expired();

然而,有时服务器会更改密钥而此提供者却不知情。例如,为了缓解泄露或过期证书的问题。在这种情况下,服务器将开始发出带有与存储中不同的 kid 的令牌,如 https://openid.net/specs/openid-connect-core-1_0.html#RotateSigKeys 所述。对于这种情况,为了避免 DDoS 攻击,提供者会通知您缺少未知密钥。

oauth2.refresh(user)
  .onSuccess(refreshedUser -> {
    // the refresh call succeeded
  })
  .onFailure(err -> {
    // the token was not refreshed, a best practise would be
    // to forcefully logout the user since this could be a
    // symptom that you're logged out by the server and this
    // token is not valid anymore.
  });

特别需要注意的是,如果用户发送大量带有缺失密钥的请求,您的处理程序应限制刷新新密钥集的调用频率,否则您可能会对您的 IdP 服务器造成 DDoS 攻击。