OAuth2 鉴权与授权提供程序

Vert.x OAuth2 组件包括一个开箱即用的 OAuth2 (以及一定程度的 OpenID Connect) 依赖实现。 如果需要使用本项目,请在您使用的依赖管理工具中添加相关配置:

  • Maven(在您的 pom.xml 文件中):

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

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

OAuth2 允许用户 (user) 向第三方应用程序授予访问所需资源的权限, 同时让用户可以在任何时候开启或者禁用这些访问。

Vert.x OAuth2 支持以下模式:

  • 授权码模式 (Authorization Code Flow) :用于那些可以存储持久化信息的服务端应用。

  • 密码凭证模式 (Password Credentials Flow) :当之前的模式不可行或者项目已在开发过程中。

  • 客户端凭证模式 (Client Credentials Flow) :客户端可以仅通过客户端凭证请求一个访问令牌(access token)。

同样的代码可以用于 OpenID Connect https://openid.net/connect/ 服务器, 并且支持 http://openid.net/specs/openid-connect-discovery-1_0.html 中的发现协议规范。

授权码模式 (Authorization Code Flow)

此授权类型可以用于获取访问令牌和刷新访问令牌,并针对可信任的客户端做了优化。 作为一个基于重定向(redirection)功能的授权模式, 客户端必须能够与资源所有者(resource owner)的用户代理(user-agent,通常是web浏览器)进行交互, 并且能够接受来自授权服务器的传入请求(通过重定向)。

更多细节请参照 OAuth2 specification, section 4.1.

密码凭证模式 (Password Credentials Flow)

此授权类型适用于资源所有者与客户端存在信任关系的场景, 例如设备操作系统或者具备高权限的应用程序。 授权服务器在启用这种授权类型必须十分谨慎, 应当只有在其他模式不可行的情况下才允许使用。

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

更多细节请参照 Oauth2 specification, section 4.3

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

当客户端请求受控制的受保护资源或者授权服务器所认同的其他资源所有者时 (这种方法不在该规范的范围内), 可以仅使用其客户端凭证 (或其它被支持的认证方式)请求访问令牌,

此授权类型必须只用于那些可以充分信任的客户端。

更多细节请参照 Oauth2 specification, section 4.4

JWT (代表/on behalf of) 模式

客户端可以使用 JWTs 请求访问令牌。

拓展

Vert.x OAuth2 支持 RFC7523 扩展,允许服务器之间基于 JWT 进行授权。

由此开始

下面是一个怎么使用 Vert.x OAuth2 和 GitHub 认证的示例实现:

OAuth2Auth oauth2 = OAuth2Auth.create(vertx, new OAuth2Options()
  .setFlow(OAuth2FlowType.AUTH_CODE)
  .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 JsonObject()
  .put("redirect_uri", "http://localhost:8080/callback")
  .put("scope", "notifications")
  .put("state", "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 JsonObject()
    .put("code", code)
    .put("redirect_uri", "http://localhost:8080/callback"))
  .onSuccess(user -> {
    // save the token and continue...
  })
  .onFailure(err -> {
    // error, the code provided is not valid
  });

授权码模式

授权码模式分为两部分。首先您的应用程序向用户请求访问其数据的权限, 如果用户批准 OAuth2 服务器则向客户端发送一个授权码。 第二部分中,客户端将授权码和客户端机密(client secret)通过 POST 方式发送到授权服务器以获取访问令牌。

OAuth2Options credentials = new OAuth2Options()
  .setFlow(OAuth2FlowType.AUTH_CODE)
  .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 JsonObject()
  .put("redirect_uri", "http://localhost:8080/callback")
  .put("scope", "<scope>")
  .put("state", "<state>"));

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

JsonObject tokenConfig = new JsonObject()
  .put("code", "<code>")
  .put("redirect_uri", "http://localhost: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,
  new OAuth2Options()
    .setFlow(OAuth2FlowType.PASSWORD));

JsonObject tokenConfig = new JsonObject()
  .put("username", "username")
  .put("password", "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()
  .setFlow(OAuth2FlowType.CLIENT)
  .setClientId("<client-id>")
  .setClientSecret("<client-secret>")
  .setSite("https://api.oauth.com");


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

JsonObject tokenConfig = new JsonObject();

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

OpenID Connect 发现(Discovery)

Vert.x OAuth2 对 OpenID 发现服务器的支持有限。使用 OIDC Discovery 可以把您的 auth 模块的配置简化为一行代码, 例如,可以考虑使用 Google 设置您的 auth :

OpenIDConnectAuth.discover(
  vertx,
  new OAuth2Options()
    .setClientId("clientId")
    .setClientSecret("clientSecret")
    .setSite("https://accounts.google.com"))
  .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. HTTP 获取对 well-known/openid-configuration 资源的请求。

  2. 按照规范对响应中 issuer 字段进行校验(issuer值必须与请求相匹配)。

  3. 如果存在 JWK URL ,则从服务器加载密钥并添加到 auth 密钥链中。

  4. 对 auth 模块进行配置并返回给用户。

以下是几个知名的 OpenID Connect Discovery服务提供方:

这些再加上给定的 client id/client secret 足够配置您的auth程序对象。

对于以上这些知名提供方,我们还提供了一些快捷方法:

KeycloakAuth.discover(
  vertx,
  new OAuth2Options()
    .setClientId("clientId")
    .setClientSecret("clientSecret")
    .setSite("http://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 -> {
    // ...
  });

用户对象(User object)

当一个令牌(token)过期时,我们需要对其进行更新。对于这种需求, 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 时,Vert.x OAuth2 应该知道如何解析访问令牌并从中提取授权。 这些信息很有价值,因为它允许在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"));

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

// first get a token (authenticate)
oauth2.authenticate(
  new JsonObject()
    .put("username", "user")
    .put("password", "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 主体中获取解码的令牌和一些必要的数据 (例如 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 ,所以您可以使用它的发现地址(discovery url)来配置:

OpenIDConnectAuth.discover(
  vertx,
  new OAuth2Options()
    .setClientId("clientId")
    .setTenant("your_realm")
    .setSite("http://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 值替换为您的应用程序路径。

Google Server to Server

Vert.x OAuth2 还支持 Server to Server 或 RFC7523 扩展。 这是伴随 Google 账户的一个特性。

令牌自检(Token Introspection)

令牌可以进行自检,以便断言自身依然有效。虽然这是RFC7662出现的目的, 但实现它的项目并不多。取而代之的是一些被称为 TokenInfo 端点的变体。 Vert.x OAuth2 同时接受这两种形式作为配置。 目前已知可以与 GoogleKeycloak 一起协作。

令牌自检假定自身是不透明的,因此需要在部署程序的服务器上对它们进行验证。 每次验证都需要一次到服务器上的完整往返。 自检可以在 OAuth2 服务级别或用户级别执行:

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

// User level
oauth2.authenticate(user.principal())
  .onSuccess(authenticatedUser -> {
    // Token is valid!
  });

验证 JWT 令牌

我们刚刚介绍了如何自检一个令牌,不过在处理JWT令牌时可以减少到部署服务器的访问次数以提高总体的响应时间。 这种情况下,可以仅在应用端使用JWT协议验证令牌。 验证JWT令牌成本更低,性能也更好, 但是由于 JWTs 的无状态性,导致我们不可能知道用户是否注销和令牌是否无效。 对于这种特殊情况,如果服务提供方支持自检,则需要使用自检。

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

截止到目前为止,我们已经讨论了很多认证模式,尽管实现它们是依赖方的事情(这也意味着真正的认证过程发生在应用端之外), 但您可以通过这些实现处理很多事情。 例如在服务提供方支持JSON WEB令牌的时候,您就可以进行授权。 如果您的服务提供方是 OpenID Connect 服务提供者或者他们确实支持 access_token 作为JWTs,那么这将是个很常见的功能。

类似的服务提供方是 Keycloak ,它实现了一个 OpenID Connect。在这种情况下, 您可以用非常简单的方式进行授权。

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

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

  • Keycloak

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

Keycloak JWT

考虑到 Keycloak 确实提供了JWT访问令牌,所以我们可以在两个不同的层次进行授权:

  • 角色(role)

  • 授权权限(authority)

为了区分两者,认证服务提供者遵循基本用户类的共同定义,即使用 : 作为两者的分隔符。 需要指出的一点是,角色和授权权限并不需要同时存在, 在最简单的情况下仅有授权权限就足够了。

为了映射到 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 操作:

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

令牌管理(Token Management)

检查是否过期

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

boolean isExpired = user.expired();

这个校验是在本地完成的,所以仍然可能出现 OAuth2 服务器使令牌无效,但您得到了一个未过期的令牌的结果。 原因是该方法检查是否过期是根据令牌的过期日期进行的, 而不是日期之前的值(not before date and such values)。

刷新令牌

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

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 无效,但此时仍然在登录状态并已经不能再刷新了, 这就意味着一旦令牌过期, 之后必须需要让用户重定向到登录页面。

自检(Introspect)

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

oauth2.authenticate(user.principal())
  .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 可能已经收到了一个撤销令牌或者注销的请求。

注销(Logging out)

注销不是 OAuth2 的特性,但它存在于 OpenID Connect 中, 而且大多数服务提供方都支持某种形式的注销。如果配置足够进行调用,那么 Vert-OAuth2 的操作会覆盖整个区域。 对于用户来说这很简单:

user.logout(res -> {
  if (res.succeeded()) {
    // the logout call succeeded
  } else {
    // the user might not have been logged out
    // to know why:
    System.err.println(res.cause());
  }
});

密匙管理(Key Management)

当服务提供者配置 jwks 路径的时候,无论是手动还是使用发现机制, 都存在必须旋转(be rotated)密钥的事件。因此, 服务提供者实现了 OpenID Connect 核心规范推荐的两种方式。

当调用刷新方法时,如果服务器返回了推荐的缓存头(cache header), 正如 https://openid.net/specs/openid-connect-core-1_0.html#RotateEncKeys 所描述的, 那么服务器将在推荐的时间执行一个周期性任务用于重新加载密钥。

boolean isExpired = user.expired();

但是,有时候服务器更改密钥,而这个服务提供者不知情。 例如用于缓解泄漏或过期证书。 在这种情况下, 服务器将开始发出与 https://openid.net/specs/openid-connect-core-1_0.html#rotatesigkeys 描述中不同的令牌。 为了避免DDoS攻击,Vert.x 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.
  });

需要特别注意的一点是,如果一个用户发送了许多缺少密钥的请求, 程序应该对其的调用操作进行限制,否则可能会造成IdP服务器的DDoS。