CLOVER🍀

That was when it all began.

QuarkusのOpenID Connect Extensionを試す(リソース保護のみ)

これは、なにをしたくて書いたもの?

QuarkusのExtensionに、OpenID Connect向けのものができていたようなので、ちょっと試してみようかなと。

Quarkus - Using OpenID Connect Adapter to Protect JAX-RS Applications

Quarkus 0.27.0を使います。

Quarkus OpenID Connect Extension

こちらのExtensionですね。

https://github.com/quarkusio/quarkus/tree/0.27.0/extensions/oidc

ガイドを参照すると、「bearer token authorization」でアプリケーションを保護するとあります。

This guide demonstrates how your Quarkus application can use an OpenID Connect Adapter to protect your JAX-RS applications using bearer token authorization, where these tokens are issued by OpenId Connect and OAuth 2.0 compliant Authorization Servers such as Keycloak.

Quarkus - Using OpenID Connect Adapter to Protect JAX-RS Applications

このガイドに書かれている内容と、試した感じだと、事前にアクセストークンを取得して、そのトークンを使ってアクセスすることを前提に
アプリケーションを保護するという使い方のようです。

このあたりですね。

Testing the Application

これは、認証???という気はしますが、そのあたりはこちらのガイドに記載があるようです。

Quarkus - Protecting Web Applications Using OpenID Connect

また、Keycloakの管理ポリシーを使うようにするExtensionもあります。

Keycloak Authorization Guide

が、いろいろあったので、今回はパス。あくまで、最初に記載したガイドの範囲で試すことにします。

QuarkusのOpenID Connect Adapterは、以下のモジュールがベースになっているようです。

The OAuth2 auth provider - Vert.x

また、QuarkusのSecurity Extensionにも統合されます。

https://github.com/quarkusio/quarkus/tree/0.27.0/extensions/security

また、CORSについては今回は扱いませんが、ガイドはこちら。

CORS filter

とまあ、前置きはこれくらいにして試してみましょう。

環境

今回の環境は、こちらです。

$ java -version
openjdk version "1.8.0_222"
OpenJDK Runtime Environment (build 1.8.0_222-8u222-b10-1ubuntu1~18.04.1-b10)
OpenJDK 64-Bit Server VM (build 25.222-b10, mixed mode)


$ mvn -version
Apache Maven 3.6.2 (40f52333136460af0dc0d7232c0dc0bcf0d9e117; 2019-08-28T00:06:16+09:00)
Maven home: $HOME/.sdkman/candidates/maven/current
Java version: 1.8.0_222, vendor: Private Build, runtime: /usr/lib/jvm/java-8-openjdk-amd64/jre
Default locale: ja_JP, platform encoding: UTF-8
OS name: "linux", version: "4.15.0-66-generic", arch: "amd64", family: "unix"


$ $GRAALVM_HOME/bin/native-image --version
GraalVM Version 19.2.1 CE

Keycloakは、7.0.1を使います。

2019-11-02 10:20:29,839 INFO  [org.jboss.as] (Controller Boot Thread) WFLYSRV0025: Keycloak 7.0.1 (WildFly Core 9.0.2.Final) started in 20519ms - Started 589 of 884 services (601 services are lazy, passive or on-demand)

Keycloakは、172.17.0.2のIPアドレスで動作しているものとします。

Keycloakの管理ユーザーも作成。

$ bin/add-user-keycloak.sh -u keycloak-admin -p password
$ bin/jboss-cli.sh -c --command=reload

お題

Quarkus上で、REST APIを作成しますが、一部のAPIはアクセス可能なロールを絞ったり、認証必須にしたりします。

また、APIによっては、認証されているユーザーの情報(JWTクレーム・セット)を返すようにしたりしましょう。

Keycloak上には、以下の情報でRealmやユーザーを作成します。

  • Realm … demo-api
  • Client-Id … sample-rest-api
  • Role … users、adminsの2つ
  • User … user001(users Role)、admin001(admins Role)の2つ

これらの前提で、進めていきます。

サンプルアプリケーションの作成

まずは、プロジェクトを作成します。Extensionに「oidc」を加えておくことがポイントです。

$ mvn io.quarkus:quarkus-maven-plugin:0.27.0:create \
    -DprojectGroupId=org.littlewings \
    -DprojectArtifactId=resteasy-oidc \
    -Dextensions="oidc, resteasy-jackson"

$ cd resteasy-oidc

「resteasy-jackson」は、JSONを扱うためですが、JSON-BではなくJacksonにしたのはなんとなくです。

まずは、未認証状態でアクセスできるJAX-RSリソースクラスを作成。
src/main/java/org/littlewings/quarkus/oidc/AnonymousResource.java

package org.littlewings.quarkus.oidc;

import java.security.Principal;
import javax.inject.Inject;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.core.MediaType;

import io.quarkus.security.identity.SecurityIdentity;

@Path("anonymous")
public class AnonymousResource {
    @Inject
    SecurityIdentity securityIdentity;

    @GET
    @Path("greeting")
    @Produces(MediaType.TEXT_PLAIN)
    public String hello() {
        return "Hello World!!";
    }

    @GET
    @Path("user-info")
    @Produces(MediaType.APPLICATION_JSON)
    public Principal userInfo() {
        System.out.println(securityIdentity.getPrincipal().getClass().getName());
        return securityIdentity.getPrincipal();
    }
}

とりあえずメッセージを返すだけのリソースメソッドと、認証されていればユーザーの情報を返すメソッドを用意してみました。

ユーザーの情報にアクセスするためには、SecurityIdentityを使うようです。

    @Inject
    SecurityIdentity securityIdentity;


    @GET
    @Path("user-info")
    @Produces(MediaType.APPLICATION_JSON)
    public Principal userInfo() {
        System.out.println(securityIdentity.getPrincipal().getClass().getName());
        return securityIdentity.getPrincipal();
    }

SecurityIdentity#getPrincipalで、現在のユーザー情報にアクセスすることができます。

他にも、現在が匿名ユーザーであるかどうかだったり、ロールなどの確認もできます。

SecurityIdentityがどのようなインターフェースを持つかは、こちらで確認するとよいでしょう。

https://github.com/quarkusio/quarkus-security/blob/1.0.0.Beta1/src/main/java/io/quarkus/security/identity/SecurityIdentity.java

QuarkusのSecurity Extensionに、このインターフェースの実装があります。

https://github.com/quarkusio/quarkus/tree/0.27.0/extensions/security/runtime/src/main/java/io/quarkus/security/runtime

次。

users(またはadmins)ロールを持っていれば、アクセス可能なJAX-RSリソースクラス。
src/main/java/org/littlewings/quarkus/oidc/UsersResource.java

package org.littlewings.quarkus.oidc;

import java.security.Principal;
import javax.annotation.security.RolesAllowed;
import javax.inject.Inject;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.core.MediaType;

import io.quarkus.security.identity.SecurityIdentity;

@Path("users")
public class UsersResource {
    @Inject
    SecurityIdentity securityIdentity;

    @GET
    @Path("user-info")
    @RolesAllowed({"users", "admins"})
    @Produces(MediaType.APPLICATION_JSON)
    public Principal userInfo() {
        return securityIdentity.getPrincipal();
    }
}

今回は、users、admins両方アクセス可能にしています。

    @RolesAllowed({"users", "admins"})

@RolesAllowedアノテーションは、Java EEで定義されているものであり、クラスまたはメソッドに付与することができます。

RolesAllowed (Java(TM) EE 7 Specification APIs)

次。

adminsロールのみ、許可するリソース。
src/main/java/org/littlewings/quarkus/oidc/AdminsResource.java

package org.littlewings.quarkus.oidc;

import java.security.Principal;
import javax.annotation.security.RolesAllowed;
import javax.inject.Inject;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.core.MediaType;

import io.quarkus.security.identity.SecurityIdentity;

@Path("admins")
public class AdminsResource {
    @Inject
    SecurityIdentity securityIdentity;

    @GET
    @Path("user-info")
    @RolesAllowed("admins")
    @Produces(MediaType.APPLICATION_JSON)
    public Principal userInfo() {
        return securityIdentity.getPrincipal();
    }
}

最後に、認証されている時のみアクセス可能なJAX-RSリソースクラス。
src/main/java/org/littlewings/quarkus/oidc/AuthenticatedResource.java

package org.littlewings.quarkus.oidc;

import java.security.Principal;
import javax.inject.Inject;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.core.MediaType;

import io.quarkus.security.Authenticated;
import io.quarkus.security.identity.SecurityIdentity;

@Path("authenticated")
public class AuthenticatedResource {
    @Inject
    SecurityIdentity securityIdentity;

    @GET
    @Path("user-info")
    @Authenticated
    @Produces(MediaType.APPLICATION_JSON)
    public Principal guardedUserInfo() {
        return securityIdentity.getPrincipal();
    }
}

この場合は、@Authenticatedアノテーションを使用します。@AuthenticatedアノテーションはQuarkusが提供するもので、こちらも
クラスおよびメソッドに付与することができます。

https://github.com/quarkusio/quarkus-security/blob/1.0.0.Beta1/src/main/java/io/quarkus/security/Authenticated.java

設定は、最低限の項目にしました。
src/main/resources/application.properties

quarkus.oidc.auth-server-url=http://172.17.0.2:8080/auth/realms/demo-api
quarkus.oidc.client-id=sample-rest-api

OpenID Connectの認証ProviderへのアクセスURLと、Client IDがあれば利用することができます。

設定項目は、こちら。

Configuring using the application.properties file

というか、この使い方だとClient Secretすら要りません…。

これら2つを設定しなかった場合は、アプリケーションがうまく動作しなくなります。

auth-server-urlを未定義にした場合。

java.lang.RuntimeException: java.net.MalformedURLException: no protocol: /.well-known/openid-configuration
    at io.vertx.ext.auth.oauth2.impl.OAuth2API.makeRequest(OAuth2API.java:112)
    at io.vertx.ext.auth.oauth2.providers.OpenIDConnectAuth.discover(OpenIDConnectAuth.java:42)
    at io.vertx.ext.auth.oauth2.providers.KeycloakAuth.discover(KeycloakAuth.java:120)
    at io.quarkus.oidc.runtime.OidcRecorder.setup(OidcRecorder.java:49)
    at io.quarkus.deployment.steps.OidcBuildStep$setup24.deploy_0(OidcBuildStep$setup24.zig:92)
    at io.quarkus.deployment.steps.OidcBuildStep$setup24.deploy(OidcBuildStep$setup24.zig:36)
    at io.quarkus.runner.ApplicationImpl1.doStart(ApplicationImpl1.zig:137)
    at io.quarkus.runtime.Application.start(Application.java:94)
    at io.quarkus.runner.RuntimeRunner.run(RuntimeRunner.java:143)
    at io.quarkus.dev.DevModeMain.doStart(DevModeMain.java:176)
    at io.quarkus.dev.DevModeMain.start(DevModeMain.java:94)
    at io.quarkus.dev.DevModeMain.main(DevModeMain.java:66)
Caused by: java.net.MalformedURLException: no protocol: /.well-known/openid-configuration
    at java.net.URL.<init>(URL.java:600)
    at java.net.URL.<init>(URL.java:497)
    at java.net.URL.<init>(URL.java:446)
    at io.vertx.ext.auth.oauth2.impl.OAuth2API.makeRequest(OAuth2API.java:93)
    ... 11 more

client-idを未定義にした場合。

Caused by: java.lang.IllegalArgumentException: Configuration missing. You need to specify [clientId]
    at io.vertx.ext.auth.oauth2.impl.flow.AbstractOAuth2Flow.throwIfNull(AbstractOAuth2Flow.java:48)
    at io.vertx.ext.auth.oauth2.impl.flow.AuthCodeImpl.<init>(AuthCodeImpl.java:39)
    at io.vertx.ext.auth.oauth2.impl.OAuth2AuthProviderImpl.<init>(OAuth2AuthProviderImpl.java:68)
    at io.vertx.ext.auth.oauth2.OAuth2Auth.create(OAuth2Auth.java:132)
    at io.vertx.ext.auth.oauth2.providers.OpenIDConnectAuth.lambda$discover$1(OpenIDConnectAuth.java:79)
    at io.vertx.ext.auth.oauth2.impl.OAuth2API.lambda$null$1(OAuth2API.java:129)
    at io.vertx.core.http.impl.HttpClientResponseImpl$BodyHandler.notifyHandler(HttpClientResponseImpl.java:292)
    at io.vertx.core.http.impl.HttpClientResponseImpl.lambda$bodyHandler$0(HttpClientResponseImpl.java:193)
    at io.vertx.core.http.impl.HttpClientResponseImpl.handleEnd(HttpClientResponseImpl.java:248)
    ... 35 more

確認する

では、動かして確認してみましょう。

パッケージングして起動。

$ mvn package
$ java -jar target/resteasy-oidc-1.0-SNAPSHOT-runner.jar

起動すると、Security ExtensionやVert.x Extensionも有効になっていることがわかります。

2019-11-02 20:35:41,365 INFO  [io.quarkus] (main) Installed features: [cdi, oidc, resteasy, resteasy-jackson, security, vertx]

では、ドキュメントに沿って試してみましょう。

Testing the Application

まずは、未認証状態でアクセス。

$ curl localhost:8080/anonymous/greeting
Hello World!!

ユーザー情報を取得しようとしてみます。

$ curl localhost:8080/anonymous/user-info
{"name":""}

無名のユーザーみたいです。

この時の、Principalは以下のようです。

io.quarkus.security.runtime.AnonymousIdentityProvider$1

ソースコードは、こちらですね。

https://github.com/quarkusio/quarkus/blob/0.27.0/extensions/security/runtime/src/main/java/io/quarkus/security/runtime/AnonymousIdentityProvider.java#L19

SecurityIdentityも、それっぽいのがあります。

https://github.com/quarkusio/quarkus/blob/0.27.0/extensions/security/runtime/src/main/java/io/quarkus/security/runtime/AnonymousIdentityProvider.java#L26

ロールでアクセス権限を制限したり、認証必須にしているリソースメソッドに対しては、すべてHTTPステータスコード401が返ってきます。

$ curl -i localhost:8080/users/user-info
HTTP/1.1 401 Unauthorized
Content-Length: 0


$ curl -i localhost:8080/admins/user-info
HTTP/1.1 401 Unauthorized
Content-Length: 0


$ curl -i localhost:8080/authenticated/user-info
HTTP/1.1 401 Unauthorized
Content-Length: 0

ちなみに、認証サーバーへリダイレクト、ということにはならないようです。
※これをやりたかったら、続きのガイド…みたいなのですが…

では、Keycloakから、アクセストークンを取得してみます。

まずは、user001から。

$ access_token=$( \
curl -X POST http://172.17.0.2:8080/auth/realms/demo-api/protocol/openid-connect/token \
    --user sample-rest-api:client-secret \
    -H 'content-type: application/x-www-form-urlencoded' \
    -d 'username=user001&password=password&grant_type=password' | jq --raw-output '.access_token' \
)

取得したアクセストークンを使って、ユーザー情報を取得してみます。最初に、認証やロールの制限をかけていないリソースメソッドから。

$ curl localhost:8080/anonymous/user-info -H "Authorization: Bearer "$access_token
{"rawToken":null,"claims":{"claimsMap":{"jti":"3026f20d-76be-4676-a5a9-d6e3535da1c8","exp":1572699465,"nbf":0,"iat":1572699165,"iss":"http://172.17.0.2:8080/auth/realms/demo-api","aud":"account","sub":"f4a47f71-c5c3-4364-920e-58b284df1523","typ":"Bearer","azp":"sample-rest-api","auth_time":0,"session_state":"78ebf1fc-247a-4f80-a56f-1c53b681fc2f","acr":"1","allowed-origins":[{"string":"http://localhost:8080","valueType":"STRING","chars":"http://localhost:8080"}],"realm_access":{"roles":[{"string":"offline_access","valueType":"STRING","chars":"offline_access"},{"string":"uma_authorization","valueType":"STRING","chars":"uma_authorization"},{"string":"users","valueType":"STRING","chars":"users"}]},"resource_access":{"account":{"roles":[{"string":"manage-account","valueType":"STRING","chars":"manage-account"},{"string":"manage-account-links","valueType":"STRING","chars":"manage-account-links"},{"string":"view-profile","valueType":"STRING","chars":"view-profile"}]}},"scope":"profile email","email_verified":false,"preferred_username":"user001"},"rawJson":"{\"jti\":\"3026f20d-76be-4676-a5a9-d6e3535da1c8\",\"exp\":1572699465,\"nbf\":0,\"iat\":1572699165,\"iss\":\"http://172.17.0.2:8080/auth/realms/demo-api\",\"aud\":\"account\",\"sub\":\"f4a47f71-c5c3-4364-920e-58b284df1523\",\"typ\":\"Bearer\",\"azp\":\"sample-rest-api\",\"auth_time\":0,\"session_state\":\"78ebf1fc-247a-4f80-a56f-1c53b681fc2f\",\"acr\":\"1\",\"allowed-origins\":[\"http://localhost:8080\"],\"realm_access\":{\"roles\":[\"offline_access\",\"uma_authorization\",\"users\"]},\"resource_access\":{\"account\":{\"roles\":[\"manage-account\",\"manage-account-links\",\"view-profile\"]}},\"scope\":\"profile email\",\"email_verified\":false,\"preferred_username\":\"user001\"}","notBefore":{"value":0,"valueInMillis":0},"issuedAt":{"value":1572699165,"valueInMillis":1572699165000},"jwtId":"3026f20d-76be-4676-a5a9-d6e3535da1c8","expirationTime":{"value":1572699465,"valueInMillis":1572699465000},"issuer":"http://172.17.0.2:8080/auth/realms/demo-api","audience":["account"],"subject":"f4a47f71-c5c3-4364-920e-58b284df1523","claimNames":["jti","exp","nbf","iat","iss","aud","sub","typ","azp","auth_time","session_state","acr","allowed-origins","realm_access","resource_access","scope","email_verified","preferred_username"]},"audience":["account"],"groups":[],"claimNames":["sub","resource_access","email_verified","allowed-origins","raw_token","iss","typ","preferred_username","aud","acr","nbf","realm_access","azp","auth_time","scope","exp","session_state","iat","jti"],"name":"user001","expirationTime":1572699465,"issuer":"http://172.17.0.2:8080/auth/realms/demo-api","subject":"f4a47f71-c5c3-4364-920e-58b284df1523","tokenID":"3026f20d-76be-4676-a5a9-d6e3535da1c8","issuedAtTime":1572699165}

長いので、ユーザー名を絞って取得してみましょう。

$ curl -s localhost:8080/anonymous/user-info -H "Authorization: Bearer "$access_token | jq --raw-output '.name'
user001

OKそうです。

なお、この時のPrincipalは、以下のようになります。

io.quarkus.oidc.runtime.OidcJwtCallerPrincipal

こちらですね。

https://github.com/quarkusio/quarkus/blob/0.27.0/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcJwtCallerPrincipal.java

users(およびadmins)ロールでアクセス制限をかけているリソースメソッドおよび、認証のみ制限をかけたリソースメソッドには
アクセスすることができます。

$ curl -s localhost:8080/users/user-info -H "Authorization: Bearer "$access_token | jq --raw-output '.name'
user001


$ curl -s localhost:8080/authenticated/user-info -H "Authorization: Bearer "$access_token | jq --raw-output '.name'
user001

adminsロールのみアクセス可能なリソースメソッドに対しては、HTTPステータスコード403となります。

$ curl -i localhost:8080/admins/user-info -H "Authorization: Bearer "$access_token
HTTP/1.1 403 Forbidden
content-length: 0

では、今度はadmin001ユーザーに切り替えます。

$ access_token=$( \
curl -X POST http://172.17.0.2:8080/auth/realms/demo-api/protocol/openid-connect/token \
    --user sample-rest-api:client-secret \
    -H 'content-type: application/x-www-form-urlencoded' \
    -d 'username=admin001&password=password&grant_type=password' | jq --raw-output '.access_token' \
)

この場合、いずれのリソースメソッドにもアクセスできるようになります。

$ curl -s localhost:8080/anonymous/user-info -H "Authorization: Bearer "$access_token | jq --raw-output '.name'
admin001


$ curl -s localhost:8080/users/user-info -H "Authorization: Bearer "$access_token | jq --raw-output '.name'
admin001


$ curl -s localhost:8080/admins/user-info -H "Authorization: Bearer "$access_token | jq --raw-output '.name'
admin001


$ curl -s localhost:8080/authenticated/user-info -H "Authorization: Bearer "$access_token | jq --raw-output '.name'
admin001

ネイティブイメージで実行する場合は、こちら。

$ mvn -P native package
$ ./target/resteasy-oidc-1.0-SNAPSHOT-runner

結果は、変わらず…と言いたいところですが、先ほどのコードだとPrincipalのJSONシリアライズに失敗するので、各リソースクラスに
メソッドを追加しました…。

Principal#getNameの結果だけを返すメソッドで。

@Path("anonymous")
public class AnonymousResource {

    /* 省略 */

    @GET
    @Path("name")
    @Produces(MediaType.TEXT_PLAIN)
    public String name() {
        return securityIdentity.getPrincipal().getName();
    }
}


@Path("users")
public class UsersResource {

    /* 省略 */

    @GET
    @Path("name")
    @RolesAllowed({"users", "admins"})
    @Produces(MediaType.TEXT_PLAIN)
    public String name() {
        return securityIdentity.getPrincipal().getName();
    }
}


@Path("admins")
public class AdminsResource {

    /* 省略 */

    @GET
    @Path("name")
    @RolesAllowed("admins")
    @Produces(MediaType.TEXT_PLAIN)
    public String name() {
        return securityIdentity.getPrincipal().getName();
    }
}


@Path("authenticated")
public class AuthenticatedResource {

    /* 省略 */

    @GET
    @Path("name")
    @Authenticated
    @Produces(MediaType.TEXT_PLAIN)
    public String name() {
        return securityIdentity.getPrincipal().getName();
    }
}

こちらを使って、確認するようにしました。

未認証状態。

$ curl -i localhost:8080/anonymous/name
HTTP/1.1 200 OK
Content-Length: 0
Content-Type: text/plain;charset=UTF-8


$ curl -i localhost:8080/users/name
HTTP/1.1 401 Unauthorized
Content-Length: 0


$ curl -i localhost:8080/admins/name
HTTP/1.1 401 Unauthorized
Content-Length: 0


$ curl -i localhost:8080/authenticated/name
HTTP/1.1 401 Unauthorized
Content-Length: 0

user001でアクセス。

access_token=$( \
curl -X POST http://172.17.0.2:8080/auth/realms/demo-api/protocol/openid-connect/token \
    --user sample-rest-api:client-secret \
    -H 'content-type: application/x-www-form-urlencoded' \
    -d 'username=user001&password=password&grant_type=password' | jq --raw-output '.access_token' \
)


$ curl localhost:8080/anonymous/name -H "Authorization: Bearer "$access_token
user001


$ curl localhost:8080/users/name -H "Authorization: Bearer "$access_token
user001


$ curl localhost:8080/authenticated/name -H "Authorization: Bearer "$access_token
user001


$ curl -i localhost:8080/admins/name -H "Authorization: Bearer "$access_token
HTTP/1.1 403 Forbidden
Content-Length: 9
Content-Type: text/plain;charset=UTF-8

Forbidden

admin001でアクセス。

access_token=$( \
curl -X POST http://172.17.0.2:8080/auth/realms/demo-api/protocol/openid-connect/token \
    --user sample-rest-api:client-secret \
    -H 'content-type: application/x-www-form-urlencoded' \
    -d 'username=admin001&password=password&grant_type=password' | jq --raw-output '.access_token' \
)


$ curl localhost:8080/anonymous/name -H "Authorization: Bearer "$access_token
admin001


$ curl localhost:8080/users/name -H "Authorization: Bearer "$access_token
admin001


$ curl localhost:8080/admins/name -H "Authorization: Bearer "$access_token
admin001


$ curl localhost:8080/authenticated/name -H "Authorization: Bearer "$access_token
admin001

ちょっとソースコードを変えてしまいましたが、認証の有無やロールによる動作が同じことは確認できました、と。

もう少し、コードを追う

OpenID Connect Extensionを追加したQuarkusは、起動時に認証サーバーを探しにいきます。

このあたりですね。処理に使われているクラス名だけ見ると、Keycloak…?

https://github.com/quarkusio/quarkus/blob/0.27.0/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcRecorder.java#L49

また、@RolesAllowedや@Authenticatedアノテーションを付与すると、インターセプターが起動します。

ここにありますね。

https://github.com/quarkusio/quarkus/tree/0.27.0/extensions/security/runtime/src/main/java/io/quarkus/security/runtime/interceptor

他のアノテーションとしては、@DenyAll、@PermitAllがあるようです。

また、インターセプター内から呼び出され、トークンの情報に対して、認証・認可に関する確認を行っているのはこちらのクラスです。

https://github.com/quarkusio/quarkus/blob/0.27.0/extensions/security/runtime/src/main/java/io/quarkus/security/runtime/interceptor/SecurityConstrainer.java

自分で作成するプログラム内で、こういったことをやりたい場合はSecurityIdentityを使ってif文などを書いたりすることになるのでしょう。

https://github.com/quarkusio/quarkus-security/blob/1.0.0.Beta1/src/main/java/io/quarkus/security/identity/SecurityIdentity.java

まとめ

QuarkusのOpenID Connect Extensionを、リソース保護のみですが、試してみました。

未認証状態でアクセスしたら、Keycloakのログインページにリダイレクトされたりして欲しいのですが、そのあたりはまた今度
確認してみようと思います。

このあたりを見ることになると思います。

Quarkus - Protecting Web Applications Using OpenID Connect

が、今は少し早そうです。

unittestライブラリで、Pythonのテストコードを書いて実行する

これは、なにをしたくて書いたもの?

Pythonを勉強するにあたって、テストコードまわりについて少し押さえておいた方がいいかなぁと思いまして。

Pythonには、いくつかテストをサポートするライブラリ、ツールがあるようです。

コードのテスト — The Hitchhiker's Guide to Python

Testing Your Code — The Hitchhiker's Guide to Python

今回は基本である、unittestを使ってみることにしました。

unittest

Python標準に含まれる、JUnitに触発されたテスティングフレームワークです。

26.4. unittest --- ユニットテストフレームワーク — Python 3.6.9 ドキュメント

unittest ユニットテストフレームワークは元々 JUnit に触発されたもので、 他の言語の主要なユニットテストフレームワークと同じような感じです。 テストの自動化、テスト用のセットアップやシャットダウンのコードの共有、テストのコレクション化、そして報告フレームワークからのテストの独立性をサポートしています。

テストケースの作成のためのクラスやアサーションを含み、ランナーも提供します。

アサーションについては、こちら。

アサートメソッド一覧

assertAlmostEqual
※assertAlmostEqualメソッドのリファレンスの上に、一覧があります

実行については、unittestをコマンドラインからモジュール指定で実行することで行います。

コマンドラインインターフェイス

特定のディレクトリの配下のテストコードを検出する、テストディスカバリも可能です。

テストディスカバリ

ところで、コードのテストでも紹介されていますが、pytestというものも押さえておいた方がよさそうなので、こちらもそのうち。

pytest: helps you write better programs — pytest documentation

環境

今回の環境は、こちらです。

$ python3 -V
Python 3.6.8

お題とプロジェクト構成

テスト対象のコード(アプリケーションコード)と、テストコードを同じディレクトリに配置して、サンプル的に動かしてみても
いいのですが、せっかくなら実際に使う時の構成を意識してみたいなぁと思います。

テストコードを配置するディレクトリ構成は、pytestのドキュメントを参考に。

Choosing a test layout / import rules

テストコードをアプリケーションコードの外に置くスタイルと

Tests outside application code

テストコードをアプリケーションコードの中に置くスタイルがあるようです。

Tests as part of application code

今回は、テストコードをアプリケーションコードの外に置くことにしました。

こんなディレクトリ構成にします。

sample  ## ← アプリケーションコードを置く
tests  ## ← テストコードを置く

テストコードのディスカバリも試すために、アプリケーションコードを2つのファイルで作成し、対応するテストも2つ用意する構成に
したいと思います。

テストを作成して、実行してみる

まず最初に、アプリケーションコードを書きます。 sample/calc.py

class Calc:
    def add(self, x, y):
        return x + y

    def minus(self, x, y):
        return x - y

    def multiply(self, x, y):
        return x * y

    def divide(self, x, y):
        return x / y

これに対応する、テストコードを書きましょう。

unittestを使う場合、テストはTestCaseクラスのサブクラスとして作成するようです。

基本的な例

また、テストを行うメソッド名は、「test」で始まる必要があるようです。

テストケースは、 unittest.TestCase のサブクラスとして作成します。メソッド名が test で始まる三つのメソッドがテストです。テストランナーはこの命名規約によってテストを行うメソッドを検索します。

アサーションは、unittest(というかTestCaseクラス)が提供するメソッドを使用して行います。

で、作成したのがこちら。
tests/test_calc.py

import unittest

from sample.calc import Calc

class CalcTestCase(unittest.TestCase):
    def setUp(self):
        print("setUp!!")

    def tearDown(self):
        print("tearDown!!")

    def test_add(self):
        sut = Calc()
        self.assertEqual(sut.add(1, 3), 4)

    def test_minus(self):
        sut = Calc()
        self.assertEqual(sut.minus(5, 3), 2)

    def test_multiply(self):
        sut = Calc()
        self.assertEqual(sut.multiply(2, 3), 6)

    def test_divide(self):
        sut = Calc()
        self.assertEqual(sut.divide(10, 2), 5)

    def foo(self):
        print("foo!!")

しれっと、「test」で始まらないメソッドも含めてあります。

テストメソッドごとに実行するsetUpやtearDownも書いてみました。クラス単位のsetUpClass、tearDownClassなどもあるようなので、
ドキュメントを参照するとよいでしょう。

class unittest.TestCase

クラスとモジュールのフィクスチャ

setUpClass と tearDownClass

テストを実行してみます。

$ python3 -m unittest tests.test_calc
setUp!!
tearDown!!
.setUp!!
tearDown!!
.setUp!!
tearDown!!
.setUp!!
tearDown!!
.
----------------------------------------------------------------------
Ran 4 tests in 0.001s

OK

setUpやtearDownが、テストメソッドごとに動いているような感じがします。

が、詳細がわからないので「-v」を付けてみます。

$ python3 -m unittest tests.test_calc -v
test_add (tests.test_calc.CalcTestCase) ... setUp!!
tearDown!!
ok
test_divide (tests.test_calc.CalcTestCase) ... setUp!!
tearDown!!
ok
test_minus (tests.test_calc.CalcTestCase) ... setUp!!
tearDown!!
ok
test_multiply (tests.test_calc.CalcTestCase) ... setUp!!
tearDown!!
ok

----------------------------------------------------------------------
Ran 4 tests in 0.000s

OK

なるほど、これだと実行されたテストメソッドもわかりますね。

以下のメソッドが対象に含まれていないことも確認できました。

    def foo(self):
        print("foo!!")

また、テストに失敗するようなコードになっている場合は、こんな表示になります。

======================================================================
FAIL: test_add (tests.test_calc.CalcTestCase)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/path/to/tests/test_calc.py", line 14, in test_add
    self.assertEqual(sut.add(1, 3), 5)
AssertionError: 4 != 5

あともうひとつ、アプリケーションコードを追加して
sample/message.py

class Decorator:
    def decorate(self, message, character):
        return "{0}{1}{2}".format(character, message, character)

テストも足しておきましょう。
tests/test_message.py

import unittest

from sample.message import Decorator

class DecoratorTestCase(unittest.TestCase):
    def test_decorate(self):
        sut = Decorator()
        self.assertEqual(sut.decorate("Hello World!!", "***"), "***Hello World!!***")

確認。

$ python3 -m unittest tests.test_message -v
test_decorate (tests.test_message.DecoratorTestCase) ... ok

----------------------------------------------------------------------
Ran 1 test in 0.000s

OK

なお、テスト対象を複数指定して実行することもできます。

$ python3 -m unittest tests.test_calc tests.test_message -v
test_add (tests.test_calc.CalcTestCase) ... setUp!!
tearDown!!
ok
test_divide (tests.test_calc.CalcTestCase) ... setUp!!
tearDown!!
ok
test_minus (tests.test_calc.CalcTestCase) ... setUp!!
tearDown!!
ok
test_multiply (tests.test_calc.CalcTestCase) ... setUp!!
tearDown!!
ok
test_decorate (tests.test_message.DecoratorTestCase) ... ok

----------------------------------------------------------------------
Ran 5 tests in 0.001s

OK
unittest#main

ドキュメントの以下の部分にも書いているのですが、

基本的な例

基本的な使い方だと、unittest#mainを呼び出すように、テストコードに書くようです。

if __name__ == '__main__':
    unittest.main()

で、Pythonコマンドで直接実行する、と。

$ python3 test_example.py

これでも良いのですが、今回のようにアプリケーションコードとテストコードを別々にする方法だと、モジュールのパス解決で
困ったことになったので、今回はパス…。

テストディスカバリを行う

ここまでは、テストコードをひとつひとつ指定して実行してきましたが、テストディスカバリを使うとテストコードを見つけて
くれるようです。

テストディスカバリ

シンプルな実行方法は、以下だとか。

$ python3 -m unittest

### または
$ python3 -m unittest discover

試してみます。

$ python3 -m unittest

----------------------------------------------------------------------
Ran 0 tests in 0.000s

OK

…テストが実行されなかったようです。

こここで、TestLoader#discoverの説明を読んでみます。

TestLoader / discover

指定された開始ディレクトリからサブディレクトリに再帰することですべてのテストモジュールを検索し、それらを含む TestSuite オブジェクトを返します。pattern にマッチしたテストファイルだけがロードの対象になります。

モジュールについて、もうちょっと調べてみます。

パッケージ

あるディレクトリを、パッケージが入ったディレクトリとしてPython に扱わせるには、ファイル __init__.py が必要です。

どうやら、__init__.pyが必要な雰囲気があります。

作成。

$ touch tests/__init__.py

再度、実行。

$ python3 -m unittest
setUp!!
tearDown!!
.setUp!!
tearDown!!
.setUp!!
tearDown!!
.setUp!!
tearDown!!
..
----------------------------------------------------------------------
Ran 5 tests in 0.000s

OK

今度は動きましたね。

「-v」オプションを指定してみましょう。

$ python3 -m unittest -v
test_add (tests.test_calc.CalcTestCase) ... setUp!!
tearDown!!
ok
test_divide (tests.test_calc.CalcTestCase) ... setUp!!
tearDown!!
ok
test_minus (tests.test_calc.CalcTestCase) ... setUp!!
tearDown!!
ok
test_multiply (tests.test_calc.CalcTestCase) ... setUp!!
tearDown!!
ok
test_decorate (tests.test_message.DecoratorTestCase) ... ok

----------------------------------------------------------------------
Ran 5 tests in 0.001s

OK

また、ディスカバリを開始するディレクトリを「-s」オプションで指定したり、テストが書かれたファイル名のパターンを「-p」で
指定したりできますが、これらを使う場合は「discover」サブコマンドの指定が必須になります。

「discover」サブコマンドの指定なしで、「-p」オプションを指定するとエラーになりますが

$ python3 -m unittest -p 'test_*.py'
usage: python3 -m unittest [-h] [-v] [-q] [--locals] [-f] [-c] [-b]
                           [tests [tests ...]]
python3 -m unittest: error: unrecognized arguments: -p

「discover」サブコマンドを指定すると、動作します。

$ python3 -m unittest discover -p 'test_*.py'
setUp!!
tearDown!!
.setUp!!
tearDown!!
.setUp!!
tearDown!!
.setUp!!
tearDown!!
..
----------------------------------------------------------------------
Ran 5 tests in 0.000s

OK

まとめ

Pythonの標準テストライブラリであるunittestを、実行方法の点からちょっと見てみました。

アサーションや、その他の機能についてはあまり見れていませんが、とりあえず初歩的な使い方としてはわかった感じかなと。