Using OAuth2 RBAC

本指南介绍了 Quarkus 应用程序如何利用 OAuth2 令牌来提供对 Jakarta REST(以前称为 JAX-RS)端点的安全访问。

This guide explains how your Quarkus application can utilize OAuth2 tokens to provide secured access to the Jakarta REST (formerly known as JAX-RS) endpoints.

OAuth2 是一个授权框架,使应用程序能够代表用户获取对 HTTP 资源的访问权限。可以通过委托外部服务器(身份验证服务器)进行用户身份验证并为身份验证上下文提供令牌,来使用它实施基于令牌的应用程序身份验证机制。

OAuth2 is an authorization framework that enables applications to obtain access to an HTTP resource on behalf of a user. It can be used to implement an application authentication mechanism based on tokens by delegating to an external server (the authentication server) the user authentication and providing a token for the authentication context.

此扩展提供轻量级的支持来使用不透明的 Bearer 令牌并通过调用查询端点来验证它们。

This extension provides a light-weight support for using the opaque Bearer Tokens and validating them by calling an introspection endpoint.

如果 OAuth2 身份验证服务器提供 JWT Bearer 令牌,请考虑改用 OIDC Bearer token authenticationSmallRye JWT扩展。如果 Quarkus 应用程序需要使用 OIDC 授权代码流来验证用户,则必须使用 OpenID Connect 扩展。有关更多信息,请参阅 OIDC code flow mechanism for protecting web applications指南。

If the OAuth2 Authentication server provides JWT Bearer Tokens, consider using either OIDC Bearer token authentication or SmallRye JWT extensions instead. OpenID Connect extension has to be used if the Quarkus application needs to authenticate the users using OIDC Authorization Code Flow. For more information, see the OIDC code flow mechanism for protecting web applications guide.

Solution

我们建议您遵循接下来的部分中的说明,按部就班地创建应用程序。然而,您可以直接跳到完成的示例。

We recommend that you follow the instructions in the next sections and create the application step by step. However, you can go right to the completed example.

克隆 Git 存储库:git clone [role="bare"][role="bare"]https://github.com/quarkusio/quarkus-quickstarts.git,或下载归档文件。

Clone the Git repository: git clone [role="bare"]https://github.com/quarkusio/quarkus-quickstarts.git, or download an archive.

该解决方案位于 security-oauth2-quickstart directory中。它还包含一个非常简单的 UI,也用于使用此处创建的 Jakarta REST 资源。

The solution is located in the security-oauth2-quickstart directory. It contains a very simple UI to use the Jakarta REST resources created here, too.

Creating the Maven project

首先,我们需要一个新项目。使用以下命令创建一个新项目:

First, we need a new project. Create a new project with the following command:

Unresolved directive in security-oauth2.adoc - include::{includes}/devtools/create-app.adoc[]

此命令生成一个项目并导入 `elytron-security-oauth2`扩展,其中包括 OAuth2 不透明令牌支持。

This command generates a project and imports the elytron-security-oauth2 extension, which includes the OAuth2 opaque token support.

如果你不想使用 Maven 插件,你可以在构建文件中包含依赖项:

If you don’t want to use the Maven plugin, you can just include the dependency in your build file:

pom.xml
<dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-elytron-security-oauth2</artifactId>
</dependency>
build.gradle
implementation("io.quarkus:quarkus-elytron-security-oauth2")

Examine the Jakarta REST resource

使用以下内容创建 src/main/java/org/acme/security/oauth2/TokenSecuredResource.java 文件:

Create the src/main/java/org/acme/security/oauth2/TokenSecuredResource.java file with the following content:

package org.acme.security.oauth2;

import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;

@Path("/secured")
public class TokenSecuredResource {

    @GET
    @Produces(MediaType.TEXT_PLAIN)
    public String hello() {
        return "hello";
    }
}

这是一个基本的 REST 端点,它不具有任何 Elytron Security OAuth2 特定功能,所以我们来添加一些。

This is a basic REST endpoint that does not have any of the Elytron Security OAuth2 specific features, so let’s add some.

我们将使用 JSR 250 通用安全注释,它们在 Using Security 指南中进行了描述。

We will use the JSR 250 common security annotations, they are described in the Using Security guide.

package org.acme.security.oauth2;

import java.security.Principal;

import jakarta.annotation.security.PermitAll;
import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.SecurityContext;

@Path("/secured")
@ApplicationScoped
public class TokenSecuredResource {


    @GET()
    @Path("permit-all")
    @PermitAll (1)
    @Produces(MediaType.TEXT_PLAIN)
    public String hello(@Context SecurityContext ctx) { (2)
        Principal caller =  ctx.getUserPrincipal(); 3
        String name = caller == null ? "anonymous" : caller.getName();
        String helloReply = String.format("hello + %s, isSecure: %s, authScheme: %s", name, ctx.isSecure(), ctx.getAuthenticationScheme());
        return helloReply; (4)
    }
}
1 @PermitAll indicates that the given endpoint is accessible by any caller, authenticated or not.
2 Here we inject the Jakarta REST SecurityContext to inspect the security state of the call.
3 Here we obtain the current request user/caller Principal. For an unsecured call this will be null, so we build the username by checking caller against null.
4 The reply we build up makes use of the caller name, the isSecure() and getAuthenticationScheme() states of the request SecurityContext.

Setting up application.properties

您需要使用以下最小属性配置您的应用程序:

You need to configure your application with the following minimal properties:

quarkus.oauth2.client-id=client_id
quarkus.oauth2.client-secret=secret
quarkus.oauth2.introspection-url=http://oauth-server/introspect

您需要指定身份验证服务器的内省 URL 以及应用程序将用于向身份验证服务器进行身份验证的 client-id / client-secret。然后,扩展程序将使用此信息验证令牌并恢复与之关联的信息。

You need to specify the introspection URL of your authentication server and the client-id / client-secret that your application will use to authenticate itself to the authentication server. The extension will then use this information to validate the token and recover the information associated with it.

对于所有配置属性,请参见本指南结尾处的 Configuration Reference 部分。

For all configuration properties, see the Configuration Reference section at the end of this guide.

Run the application

现在,我们可以运行我们的应用程序了。使用:

Now we are ready to run our application. Use:

Unresolved directive in security-oauth2.adoc - include::{includes}/devtools/dev.adoc[]

现在 REST 端点正在运行,我们可以使用像 curl 这样的命令行工具访问它:

Now that the REST endpoint is running, we can access it using a command line tool like curl:

$ curl http://127.0.0.1:8080/secured/permit-all; echo
hello + anonymous, isSecure: false, authScheme: null

我们没有在请求中提供任何令牌,因此我们不期望端点看到任何安全状态,且答复与之相一致:

We have not provided any token in our request, so we would not expect that there is any security state seen by the endpoint, and the response is consistent with that:

  • username is anonymous

  • isSecure is false as https is not used

  • authScheme is null

Securing the endpoint

因此,现在我们实际上需要保护一些东西。在以下内容中查看新的端点方法 helloRolesAllowed

So now let’s actually secure something. Take a look at the new endpoint method helloRolesAllowed in the following:

package org.acme.security.oauth2;

import java.security.Principal;

import jakarta.annotation.security.PermitAll;
import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.SecurityContext;

@Path("/secured")
@ApplicationScoped
public class TokenSecuredResource {

    @GET()
    @Path("permit-all")
    @PermitAll
    @Produces(MediaType.TEXT_PLAIN)
    public String hello(@Context SecurityContext ctx) {
        Principal caller =  ctx.getUserPrincipal();
        String name = caller == null ? "anonymous" : caller.getName();
        String helloReply = String.format("hello + %s, isSecure: %s, authScheme: %s", name, ctx.isSecure(), ctx.getAuthenticationScheme());
        return helloReply;
    }

    @GET()
    @Path("roles-allowed") (1)
    @RolesAllowed({"Echoer", "Subscriber"}) (2)
    @Produces(MediaType.TEXT_PLAIN)
    public String helloRolesAllowed(@Context SecurityContext ctx) {
        Principal caller =  ctx.getUserPrincipal();
        String name = caller == null ? "anonymous" : caller.getName();
        String helloReply = String.format("hello + %s, isSecure: %s, authScheme: %s", name, ctx.isSecure(), ctx.getAuthenticationScheme());
        return helloReply;
    }
}
1 This new endpoint will be located at /secured/roles-allowed
2 @RolesAllowed indicates that the given endpoint is accessible by a caller if they have either an "Echoer" or a "Subscriber" role assigned.

在对 TokenSecuredResource 进行此添加后,尝试 curl -v [role="bare"]http://127.0.0.1:8080/secured/roles-allowed; echo 以尝试访问新端点。您的输出应为:

After you make this addition to your TokenSecuredResource, try curl -v [role="bare"]http://127.0.0.1:8080/secured/roles-allowed; echo to attempt to access the new endpoint. Your output should be:

$ curl -v http://127.0.0.1:8080/secured/roles-allowed; echo
*   Trying 127.0.0.1...
* TCP_NODELAY set
* Connected to 127.0.0.1 (127.0.0.1) port 8080 (#0)
> GET /secured/roles-allowed HTTP/1.1
> Host: 127.0.0.1:8080
> User-Agent: curl/7.54.0
> Accept: */*
>
< HTTP/1.1 401 Unauthorized
< Connection: keep-alive
< Content-Type: text/html;charset=UTF-8
< Content-Length: 14
< Date: Sun, 03 Mar 2019 16:32:34 GMT
<
* Connection #0 to host 127.0.0.1 left intact
Not authorized

非常好,我们没有在请求中提供任何 OAuth2 令牌,因此我们不应该能够访问端点,并且我们也没有能够访问。相反,我们收到了 HTTP 401 未授权错误。我们需要获取并传入有效的 OAuth2 令牌来访问该端点。这有两个步骤,1) 使用关于如何验证令牌的信息来配置我们的 Elytron Security OAuth2 扩展,以及 2) 生成具有适当声明的匹配令牌。

Excellent, we have not provided any OAuth2 token in the request, so we should not be able to access the endpoint, and we were not. Instead, we received an HTTP 401 Unauthorized error. We need to obtain and pass in a valid OAuth2 token to access that endpoint. There are two steps to this, 1) configuring our Elytron Security OAuth2 extension with information on how to validate the token, and 2) generating a matching token with the appropriate claims.

Generating a token

你需要利用令牌端点,从标准的 OAuth2 身份验证服务器(例如 Keycloak)获取令牌。

You need to obtain the token from a standard OAuth2 authentication server (Keycloak for example) using the token endpoint.

下面你可以找到一些关于 client_credential 流程的 curl 调用示例:

You can find below a curl example of such call for a client_credential flow:

curl -X POST "http://oauth-server/token?grant_type=client_credentials" \
-H  "Accept: application/json" -H  "Authorization: Basic Y2xpZW50X2lkOmNsaWVudF9zZWNyZXQ="

它应该会回复像这样的内容…

It should respond something like that…​

{"access_token":"60acf56d-9daf-49ba-b3be-7a423d9c7288","token_type":"bearer","expires_in":1799,"scope":"READER"}

Finally, make a secured request to /secured/roles-allowed

现在让我们用它对 /secured/roles-allowed 端点发出安全请求

Now let’s use this to make a secured request to the /secured/roles-allowed endpoint

$ curl -H "Authorization: Bearer 60acf56d-9daf-49ba-b3be-7a423d9c7288" http://127.0.0.1:8080/secured/roles-allowed; echo
hello + client_id isSecure: false, authScheme: OAuth2

成功!我们现在拥有:

Success! We now have:

  • a non-anonymous caller name of client_id

  • an authentication scheme of OAuth2

Roles mapping

角色从省察端点响应声明之一中映射而来。默认情况下,它是 scope 声明。通过使用空格分隔符,来分割声明以获取角色。如果声明是一个数组,则不进行分割,而是直接从该数组获取角色。

Roles are mapped from one of the claims of the introspection endpoint response. By default, it’s the scope claim. Roles are obtained by splitting the claim with a space separator. If the claim is an array, no splitting is done, the roles are obtained from the array.

你可以使用 quarkus.oauth2.role-claim 属性来自定义用于角色的声明名称。

You can customize the name of the claim to use for the roles with the quarkus.oauth2.role-claim property.

Package and run the application

和往常一样,可以使用以下命令打包应用程序:

As usual, the application can be packaged using:

Unresolved directive in security-oauth2.adoc - include::{includes}/devtools/build.adoc[]

并使用 java -jar target/quarkus-app/quarkus-run.jar 执行:

And executed using java -jar target/quarkus-app/quarkus-run.jar:

[INFO] Scanning for projects...
...
$ java -jar target/quarkus-app/quarkus-run.jar
2019-03-28 14:27:48,839 INFO  [io.quarkus] (main) Quarkus {quarkus-version} started in 0.796s. Listening on: http://[::]:8080
2019-03-28 14:27:48,841 INFO  [io.quarkus] (main) Installed features: [cdi, rest, rest-jackson, security, security-oauth2]

你还可以按如下方式生成本机可执行文件:

You can also generate the native executable with:

Unresolved directive in security-oauth2.adoc - include::{includes}/devtools/build-native.adoc[]

[INFO] Scanning for projects...
...
[security-oauth2-quickstart-runner:25602]     universe:     493.17 ms
[security-oauth2-quickstart-runner:25602]      (parse):     660.41 ms
[security-oauth2-quickstart-runner:25602]     (inline):   1,431.10 ms
[security-oauth2-quickstart-runner:25602]    (compile):   7,301.78 ms
[security-oauth2-quickstart-runner:25602]      compile:  10,542.16 ms
[security-oauth2-quickstart-runner:25602]        image:   2,797.62 ms
[security-oauth2-quickstart-runner:25602]        write:     988.24 ms
[security-oauth2-quickstart-runner:25602]      [total]:  43,778.16 ms
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time:  51.500 s
[INFO] Finished at: 2019-06-28T14:30:56-07:00
[INFO] ------------------------------------------------------------------------

$ ./target/security-oauth2-quickstart-runner
2019-03-28 14:31:37,315 INFO  [io.quarkus] (main) Quarkus 0.20.0 started in 0.006s. Listening on: http://[::]:8080
2019-03-28 14:31:37,316 INFO  [io.quarkus] (main) Installed features: [cdi, rest, rest-jackson, security, security-oauth2]

Integration testing

如果你不想为集成测试使用真实的 OAuth2 授权服务器,可以通过 Properties based security 扩展来自行测试,或使用 Wiremock 来模拟授权服务器。

If you don’t want to use a real OAuth2 authorization server for your integration tests, you can use the Properties based security extension for your test, or mock an authorization server using Wiremock.

首先,需要将 Wiremock 添加为测试依赖。对于 Maven 项目,它会这样发生:

First, Wiremock needs to be added as a test dependency. For a Maven project that would happen like so:

pom.xml
<dependency>
    <groupId>org.wiremock</groupId>
    <artifactId>wiremock</artifactId>
    <scope>test</scope>
    <version>${wiremock.version}</version> (1)
</dependency>
1 Use a proper Wiremock version. All available versions can be found here.
build.gradle
testImplementation("org.wiremock:wiremock:${wiremock.version}") 1
1 Use a proper Wiremock version. All available versions can be found here.

在 Quarkus 测试中,当某些服务需要在 Quarkus 测试运行之前启动,我们使用 @io.quarkus.test.common.WithTestResource 注解指定一个 io.quarkus.test.common.QuarkusTestResourceLifecycleManager,它能启动服务并提供 Quarkus 将使用的配置值。

In Quarkus tests when some service needs to be started before the Quarkus tests are ran, we utilize the @io.quarkus.test.common.WithTestResource annotation to specify a io.quarkus.test.common.QuarkusTestResourceLifecycleManager which can start the service and supply configuration values that Quarkus will use.

关于 @WithTestResource 的更多详细信息,请参见 this part of the documentation

For more details about @WithTestResource refer to this part of the documentation.

让我们创建一个 QuarkusTestResourceLifecycleManager 的实现,称为 MockAuthorizationServerTestResource,就像下面这样:

Let’s create an implementation of QuarkusTestResourceLifecycleManager called MockAuthorizationServerTestResource like so:

import com.github.tomakehurst.wiremock.WireMockServer;
import com.github.tomakehurst.wiremock.client.WireMock;
import io.quarkus.test.common.QuarkusTestResourceLifecycleManager;

import java.util.Collections;
import java.util.Map;

public class MockAuthorizationServerTestResource implements QuarkusTestResourceLifecycleManager {  (1)

    private WireMockServer wireMockServer;

    @Override
    public Map<String, String> start() {
        wireMockServer = new WireMockServer();
        wireMockServer.start(); (2)

        // define the mock for the introspect endpoint
        WireMock.stubFor(WireMock.post("/introspect").willReturn(WireMock.aResponse() (3)
                .withBody(
                        "{\"active\":true,\"scope\":\"Echoer\",\"username\":null,\"iat\":1562315654,\"exp\":1562317454,\"expires_in\":1458,\"client_id\":\"my_client_id\"}")));


        return Collections.singletonMap("quarkus.oauth2.introspection-url", wireMockServer.baseUrl() + "/introspect"); (4)
    }

    @Override
    public void stop() {
        if (null != wireMockServer) {
            wireMockServer.stop();  (5)
        }
    }
}
1 The start method is invoked by Quarkus before any test is run and returns a Map of configuration properties that apply during the test execution.
2 Launch Wiremock.
3 Configure Wiremock to stub the calls to /introspect by returning an OAuth2 introspect response. You need to customize this line to return what’s needed for your application (at least the scope property as roles are derived from the scope).
4 As the start method returns configuration that applies for tests, we set the quarkus.oauth2.introspection-url property that controls the URL of the introspect endpoint used by the OAuth2 extension.
5 When all tests have finished, shutdown Wiremock.

你的测试类需要使用 @WithTestResource(MockAuthorizationServerTestResource.class) 加以注释以使用此 QuarkusTestResourceLifecycleManager

Your test class needs to be annotated like with @WithTestResource(MockAuthorizationServerTestResource.class) to use this QuarkusTestResourceLifecycleManager.

下面是一个使用 MockAuthorizationServerTestResource 的测试示例。

Below is an example of a test that uses the MockAuthorizationServerTestResource.

@QuarkusTest
@WithTestResource(MockAuthorizationServerTestResource.class) (1)
class TokenSecuredResourceTest {
    // use whatever token you want as the mock OAuth server will accept all tokens
    private static final String BEARER_TOKEN = "337aab0f-b547-489b-9dbd-a54dc7bdf20d"; (2)

    @Test
    void testPermitAll() {
        RestAssured.given()
                .when()
                .header("Authorization", "Bearer: " + BEARER_TOKEN) (3)
                .get("/secured/permit-all")
                .then()
                .statusCode(200)
                .body(containsString("hello"));
    }

    @Test
    void testRolesAllowed() {
        RestAssured.given()
                .when()
                .header("Authorization", "Bearer: " + BEARER_TOKEN)
                .get("/secured/roles-allowed")
                .then()
                .statusCode(200)
                .body(containsString("hello"));
    }
}
1 Use the previously created MockAuthorizationServerTestResource as a Quarkus test resource.
2 Define whatever token you want, it will not be validated by the OAuth2 mock authorization server.
3 Use this token inside the Authorization header to trigger OAuth2 authentication.

Configuration Reference

Unresolved directive in security-oauth2.adoc - include::{generated-dir}/config/quarkus-elytron-security-oauth2.adoc[]