Using OAuth2 RBAC

本指南介绍了 Quarkus 应用程序如何利用 OAuth2 令牌来提供对 Jakarta REST(以前称为 JAX-RS)端点的安全访问。 OAuth2 是一个授权框架,使应用程序能够代表用户获取对 HTTP 资源的访问权限。可以通过委托外部服务器(身份验证服务器)进行用户身份验证并为身份验证上下文提供令牌,来使用它实施基于令牌的应用程序身份验证机制。 此扩展提供轻量级的支持来使用不透明的 Bearer 令牌并通过调用查询端点来验证它们。 如果 OAuth2 身份验证服务器提供 JWT Bearer 令牌,请考虑改用 OIDC Bearer token authenticationSmallRye JWT扩展。如果 Quarkus 应用程序需要使用 OIDC 授权代码流来验证用户,则必须使用 OpenID Connect 扩展。有关更多信息,请参阅 OIDC code flow mechanism for protecting web applications指南。

Solution

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

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

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

Creating the Maven project

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

CLI
quarkus create app {create-app-group-id}:{create-app-artifact-id} \
    --no-code
cd {create-app-artifact-id}

要创建一个 Gradle 项目,添加 --gradle--gradle-kotlin-dsl 选项。 有关如何安装和使用 Quarkus CLI 的详细信息,请参见 Quarkus CLI 指南。

Maven
mvn {quarkus-platform-groupid}:quarkus-maven-plugin:{quarkus-version}:create \
    -DprojectGroupId={create-app-group-id} \
    -DprojectArtifactId={create-app-artifact-id} \
    -DnoCode
cd {create-app-artifact-id}

要创建一个 Gradle 项目,添加 -DbuildTool=gradle-DbuildTool=gradle-kotlin-dsl 选项。

适用于 Windows 用户:

  • 如果使用 cmd,(不要使用反斜杠 \ ,并将所有内容放在同一行上)

  • 如果使用 Powershell,将 -D 参数用双引号引起来,例如 "-DprojectArtifactId={create-app-artifact-id}"

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

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

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 文件:

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 特定功能,所以我们来添加一些。

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

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 表示给定端点对于任何呼叫者都是可访问的,无论是否经过身份验证。
2 在这里,我们注入 Jakarta REST SecurityContext 来检查呼叫的安全状态。
3 在这里,我们获得当前请求用户/呼叫者 Principal。对于不安全的呼叫,这将为 null,因此我们通过检查 caller 是否为 null 来构建用户名。
4 我们构建的答复利用了请求的呼叫者名称、isSecure()getAuthenticationScheme() 状态 SecurityContext

Setting up application.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。然后,扩展程序将使用此信息验证令牌并恢复与之关联的信息。

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

Run the application

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

CLI
quarkus dev
Maven
./mvnw quarkus:dev
Gradle
./gradlew --console=plain quarkusDev

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

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

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

  • username is anonymous

  • isSecure 为 false,因为未启用 https

  • authScheme is null

Securing the endpoint

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

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 此新端点将位于 /secured/roles-allowed
2 @RolesAllowed 表示如果呼叫者分配了“Echoer”或“Subscriber”角色,则该呼叫者可以访问给定的端点。

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

$ 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) 生成具有适当声明的匹配令牌。

Generating a token

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

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

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

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

{"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 端点发出安全请求

$ 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

成功!我们现在拥有:

  • 一个 client_id 的非匿名调用者名称

  • 一个 OAuth2 身份验证方案

Roles mapping

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

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

Package and run the application

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

CLI
quarkus build
Maven
./mvnw install
Gradle
./gradlew build

并使用 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]

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

CLI
quarkus build --native
Maven
./mvnw install -Dnative
Gradle
./gradlew build -Dquarkus.native.enabled=true
[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 来模拟授权服务器。

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

pom.xml
<dependency>
    <groupId>org.wiremock</groupId>
    <artifactId>wiremock</artifactId>
    <scope>test</scope>
    <version>${wiremock.version}</version> (1)
</dependency>
1 使用正确的 Wiremock 版本。所有可用的版本都可在 here 找到。
build.gradle
testImplementation("org.wiremock:wiremock:${wiremock.version}") 1
1 使用正确的 Wiremock 版本。所有可用的版本都可在 here 找到。

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

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

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

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 start 方法在任何测试运行之前都会被 Quarkus 调用,并且在测试执行期间返回一个 Map 配置属性。
2 Launch Wiremock.
3 配置 Wiremock 以存根对 /introspect 的调用,通过返回 OAuth2 省察响应。你需要自定义此行以返回应用程序所需的内容(至少是范围属性,因为角色是从范围派生的)。
4 由于 start 方法返回适用于测试的配置,因此我们设置 quarkus.oauth2.introspection-url 属性以控制 OAuth2 扩展使用的省察端点的 URL。
5 当所有测试都完成时,关闭 Wiremock。

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

下面是一个使用 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 将先前创建的 MockAuthorizationServerTestResource 作为 Quarkus 测试资源使用。
2 定义你想要的任何令牌,OAuth2 模拟授权服务器不会对其进行验证。
3 Authorization 标头中使用此令牌以触发 OAuth2 认证。

Configuration Reference

Unresolved include directive in modules/ROOT/pages/security-oauth2.adoc - include::../../../target/quarkus-generated-doc/config/quarkus-elytron-security-oauth2.adoc[]