Skip to main content
编辑本页

The OAuth2 auth provider

Vert.X的这个组件包含了标准的OAuth2的实现。若要在自己的项目中使用它,则需要在构建描述信息的_dependencies_节点中添加如下信息:

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

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

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

如果用户以第三方应用的方式访问想要的资源,OAuth2可以对这些用户进行授权,任何时候可以根据用户想要都有可能启用或禁用它的访问权限。

Vert.X中的OAuth2支持下边三种流程:

  • 授权码流程(对服务器和App可持久化存储信息)

  • 密码证书流程(之前的流程无法使用或开发阶段使用)

  • 客户端证书流程(客户端可仅仅可凭借客户端整数申请访问令牌【Access Token】)

The same code will work with OpenID Connect https://openid.net/connect/ servers and supports the Discovery protocol as specified in http://openid.net/specs/openid-connect-discovery-1_0.html .

授权码——Authorization Code Flow

授权码授权类型可以用来获取访问令牌(Access Token)和刷新令牌(Refresh Token), 对安全性要求高的客户端(Confidential Client)是很不错的(Optimized)一种方式。 作为一个基于重定向的流程,客户端必须能和资源拥有者的用户代理交互(通常是浏览器), 同时要能接受从授权服务器(Authorization Server)通过重定向发送过来的请求。

更多信息请参考 Oauth2 specification, section 4.1

密码证书——Password Credentials Flow

资源拥有者密码证书授权类型比较适合于这样一种情况:当资源拥有者和客户端之间有可信任的关系时使用, 如设备操作系统、或高特权应用。授权服务器应该在很特殊的情况时启用这种授权类型,并且仅仅当其他应用都不可见时允许使用这种类型。

这种类型对于客户端要获取资源拥有者证书(用户名和密码,通常使用交互式表单)是合适的。 它用于从已经存在的直接使用认证模式的如Basic或Digest的客户端向OAuth2认证方式迁移的时候,它会将证书直接转换成OAuth2中的访问令牌。

更多信息请参考 Oauth2 specification, section 4.3

客户端证书——Client Credentials Flow

当客户端想要申请访问控制之下受保护的资源时,或者其他资源拥有者先前被授权服务器托管时(这种方式超出了本文范畴),客户端仅仅可以使用客户端证书(或者其他表示认证含义的信息)申请访问令牌。

客户端证书类型必须用于且仅用于机密客户端。

更多信息请参考 Oauth2 specification, section 4.4

Extensions

The provider supports RFC7523 an extension to allow server to server authorization based on JWT.

初探

下边是基于GitHub中使用Vert.X的OAuth2 Provider的认证示例实现代码:

OAuth2Auth oauth2 = OAuth2Auth.create(vertx, OAuth2FlowType.AUTH_CODE, new OAuth2ClientOptions()
  .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:


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

oauth2.authenticate(new JsonObject().put("code", code).put("redirect_uri", "http://localhost:8080/callback"), res -> {
  if (res.failed()) {
    // error, the code provided is not valid
  } else {
    // save the token and continue...
  }
});

授权码流程

授权码流程主要包含两部分内容: 第一步,你的应用客户端向用户申请允许访问它们的数据,如果用户审批后,OAuth2服务器发送给客户端一个授权码; 第二步,客户端将这个授权码和客户端密钥放到POST请求中发送给授权服务器(Authority Server)得到访问令牌;

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


// Initialize the OAuth2 Library
OAuth2Auth oauth2 = OAuth2Auth.create(vertx, OAuth2FlowType.AUTH_CODE, 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, res -> {
  if (res.failed()) {
    System.err.println("Access Token Error: " + res.cause().getMessage());
  } else {
    // Get the access token object (the authorization code is given from the previous step).
    User token = res.result();
  }
});

密码证书流程

资源拥有者密码证书授权类型比较适合于这样一种情况:当资源拥有者和客户端之间有可信任的关系时使用, 如设备操作系统、或高特权应用。授权服务器应该在很特殊的情况时启用这种授权类型,并且仅仅当其他应用都不可见时允许使用这种类型。

OAuth2Auth oauth2 = OAuth2Auth.create(vertx, OAuth2FlowType.PASSWORD);

JsonObject tokenConfig = new JsonObject()
  .put("username", "username")
  .put("password", "password");

// Callbacks
// Save the access token
oauth2.authenticate(tokenConfig, res -> {
  if (res.failed()) {
    System.err.println("Access Token Error: " + res.cause().getMessage());
  } else {
    // Get the access token object (the authorization code is given from the previous step).
    AccessToken token = (AccessToken) res.result();

    token.fetch("/users", res2 -> {
      // the user object should be returned here...
    });
  }
});

客户端证书流程

这种类型对于客户端要获取资源拥有者证书是合适的。

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


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

JsonObject tokenConfig = new JsonObject();

// Callbacks
// Save the access token
oauth2.authenticate(tokenConfig, res -> {
  if (res.failed()) {
    System.err.println("Access Token Error: " + res.cause().getMessage());
  } else {
    // Get the access token object (the authorization code is given from the previous step).
    User token = res.result();
  }
});

OpenID Connect Discovery

There is limited support for OpenID Discovery servers. Using OIDC Discovery will simplify the configuration of your auth module into a single line of code, for example, consider setting up your auth using Google:

OpenIDConnectAuth.discover(
  vertx,
  new OAuth2ClientOptions()
    .setSite("https://accounts.google.com")
    .setClientID("clientId"),
  res -> {
    if (res.succeeded()) {
      // 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.
    } else {
      // the setup failed.
    }
  });

Behind the scenes a couple of actions are performed:

  1. HTTP get request to the .well-known/openid-configuration resource

  2. Validation of the response issuer field as mandated by the spec (the issuer value must match the request one)

  3. If the JWK uri is present, keys are loaded from the server and added to the auth keychain

  4. the auth module is configure and returned to the user.

A couple of well known OpenID Connect Discovery providers are:

This and the given client id is enough to configure your auth provider object.

访问令牌对象

当一个令牌过期后你需要刷新令牌,OAuth2提供了访问令牌类AccessToken,它包含了很多实用的方法可以在令牌过期过后对令牌进行刷新。

if (token.expired()) {
  // Callbacks
  token.refresh(res -> {
    if (res.succeeded()) {
      // success
    } else {
      // error handling...
    }
  });
}

当你使用令牌访问完成过后想要注销,你可以撤销(Revoke)访问令牌和刷新令牌。

token.revoke("access_token", res -> {
  // Session ended. But the refresh_token is still valid.

  // Revoke the refresh_token
  token.revoke("refresh_token", res1 -> System.out.println("token revoked."));
});

其他通用OAuth2的Provider配置示例

For convenience there are several helpers to assist your with your configuration. Currently we provide:

JBoss Keycloak

When using this Keycloak the provider has knowledge on how to parse access tokens and extract grants from inside. This information is quite valuable since it allows to do authorization at the API level, for example:

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"), res -> {
  if (res.failed()) {
    // error handling...
  } else {
    AccessToken token = (AccessToken) res.result();

    // now check for permissions
    token.isAuthorized("account:manage-account", r -> {
      if (r.result()) {
        // this user is authorized to manage its account
      }
    });
  }
});

We also provide a helper class for Keycloak so that we can we can easily retrieve decoded token and some necessary data (e.g. preferred_username) from the Keycloak principal. For example:

JsonObject idToken = KeycloakHelper.idToken(principal);

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

Please remember that Keycloak does implement OpenID Connect, so you can configure it just by using it’s discovery url:

OpenIDConnectAuth.discover(
  vertx,
  new OAuth2ClientOptions()
    .setSite("http://server:port/auth/realms/your_realm")
    .setClientID("clientId"),
  res -> {
    if (res.succeeded()) {
      // 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.
    } else {
      // the setup failed.
    }
  });

Since you can deploy your Keycloak server anywhere, just replace server:port with the correct value and the your_realm value with your application realm.

Google Server to Server

The provider also supports Server to Server or the RFC7523 extension. This is a feature present on Google with their service account.

Token Introspection

Tokens can be introspected in order to assert that they are still valid. Although there is RFC7662 for this purpose not many providers implement it. Instead there are variations also known as TokenInfo end points. The OAuth2 provider will accept both end points as a configuration. Currently we are known to work with Google and Keycloak.

Token introspection assumes that tokens are opaque, so they need to be validated on the provider server. Every time a token is validated it requires a round trip to the provider. Introspection can be performed at the OAuth2 level or at the User level:

oauth2.introspectToken("opaque string", res -> {
  if (res.succeeded()) {
    // token is valid!
    AccessToken accessToken = res.result();
  }
});

// User level
token.introspect(res -> {
  if (res.succeeded()) {
    // Token is valid!
  }
});

Verifying JWT tokens

We’ve just covered how to introspect a token however when dealing with JWT tokens one can reduce the amount of trips to the provider server thus enhancing your overall response times. In this case tokens will be verified using the JWT protocol at your application side only. Verifying JWT tokens is cheaper and offers better performance, however due to the stateless nature of JWTs it is not possible to know if a user is logged out and a token is invalid. For this specific case one needs to use the token introspection if the provider supports it.

oauth2.decodeToken("jwt-token", res -> {
  if (res.succeeded()) {
    // token is valid!
    AccessToken accessToken = res.result();
  }
});

Until now we covered mostly authentication, although the implementation is relying party (that means that the real authentication happens somewhere else), there is more you can do with the handler. For example you can also do authorization if the provider is known to support JSON web tokens. This is a common feature if your provider is a OpenId Connect provider or if the provider does support `access_token`s as JWTs.

Such provider is Keycloak that is a OpenId Connect implementation. In that case you will be able to perform authorization in a very easy way.

Role Based Access Control

OAuth2 is an AuthN protocol, however OpenId Connect adds JWTs to the token format which means that AuthZ can be encoded at the token level. Currently there are 2 known JWT AuthZ known formats:

  • Keycloak

  • MicroProfile JWT 1.1 spec

Keycloak JWT

Given that Keycloak does provide JWT `access_token`s one can authorize at two distinct levels:

  • role

  • authority

To distinct the two, the auth provider follows the same recommendations from the base user class, i.e.: use the`:` as a separator for the two. It should be noted that both role and authorities do not need to be together, in the most simple case an authority is enough.

In order to map to keycloak’s token format the following checks are performed:

  1. If no role is provided, it is assumed to the the provider realm name

  2. If the role is realm then the lookup happens in realm_access list

  3. If a role is provided then the lookup happends in the resource_access list under the role name

Check for a specific authorities

Here is one example how you can perform authorization after the user has been loaded from the oauth2 handshake, for example you want to see if the user can print in the current application:

user.isAuthorized("print", res -> {
  // in this case it is assumed that the role is the current application
  if (res.succeeded() && res.result()) {
    // Yes the user can print
  }
});

However this is quite specific, you might want to verify if the user can add-user to the whole system (the realm):

user.isAuthorized("realm:add-user", res -> {
  // the role is "realm"
  // the authority is "add-user"
  if (res.succeeded() && res.result()) {
    // Yes the user can add users to the application
  }
});

Or if the user can access the year-report in the finance department:

user.isAuthorized("finance:year-report", res -> {
  // the role is "finance"
  // the authority is "year-report"
  if (res.succeeded() && res.result()) {
    // Yes the user can access the year report from the finance department
  }
});

MicroProfile JWT 1.1 spec

Another format in the form of a spec is the MP-JWT 1.1. This spec defines a JSON array of strings under the property name groups that define the "groups" the token has an authority over.

In order to use this spec to assert AuthZ the right handler must be set:

oauth2Auth.rbacHandler(MicroProfileRBAC.create());

Token Management

Check if it is expired

Tokens are usually fetched from the server and cached, in this case when used later they might have already expired and be invalid, you can verify if the token is still valid like this:

boolean isExpired = user.expired();

This call is totally offline, it could still happen that the Oauth2 server invalidated your token but you get a non expired token result. The reason behind this is that the expiration is checked against the token expiration dates, not before date and such values.

Refresh token

There are times you know the token is about to expire and would like to avoid to redirect the user again to the login screen. In this case you can refresh the token. To refresh a token you need to have already a user and call:

user.refresh(res -> {
  if (res.succeeded()) {
    // the refresh call succeeded
  } else {
    // 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.
  }
});

Revoke token

Since tokens can be shared across various applications you might want to disallow the usage of the current token by any application. In order to do this one needs to revoke the token against the Oauth2 server:

user.revoke("access_token", res -> {
  if (res.succeeded()) {
    // the refresh call succeeded
  } else {
    // 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.
  }
});

It is important to note that this call requires a token type. The reason is because some providers will return more than one token e.g.:

  • id_token

  • refresh_token

  • access_token

So one needs to know what token to invalidate. It should be obvious that if you invalidate the refresh_token you’re still logged in but you won’t be able to refresh anymore, which means that once the token expires you need to redirect the user again to the login page.

Introspect

Introspect a token is similar to a expiration check, however one needs to note that this check is fully online. This means that the check happens on the OAuth2 server.

user.introspect(res -> {
  if (res.succeeded()) {
    // the introspection call succeeded
  } else {
    // the token failed the introspection. You should proceed
    // to logout the user since this means that this token is
    // not valid anymore.
  }
});

Important note is that even if the expired() call is true the return from the introspect call can still be an error. This is because the OAuth2 might have received a request to invalidate the token or a loggout in between.

Logging out

Logging out is not a Oauth2 feature but it is present on OpenID Connect and most providers do support some sort of logging out. This provider also covers this area if the configuration is enough to let it make the call. For the user this is as simple as:

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