これは、なにをしたくて書いたもの?
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
このガイドに書かれている内容と、試した感じだと、事前にアクセストークンを取得して、そのトークンを使ってアクセスすることを前提に
アプリケーションを保護するという使い方のようです。
このあたりですね。
これは、認証???という気はしますが、そのあたりはこちらのガイドに記載があるようです。
Quarkus - Protecting Web Applications Using OpenID Connect
また、Keycloakの管理ポリシーを使うようにするExtensionもあります。
が、いろいろあったので、今回はパス。あくまで、最初に記載したガイドの範囲で試すことにします。
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については今回は扱いませんが、ガイドはこちら。
とまあ、前置きはこれくらいにして試してみましょう。
環境
今回の環境は、こちらです。
$ 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がどのようなインターフェースを持つかは、こちらで確認するとよいでしょう。
QuarkusのSecurity Extensionに、このインターフェースの実装があります。
次。
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が提供するもので、こちらも
クラスおよびメソッドに付与することができます。
設定は、最低限の項目にしました。
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]
では、ドキュメントに沿って試してみましょう。
まずは、未認証状態でアクセス。
$ curl localhost:8080/anonymous/greeting Hello World!!
ユーザー情報を取得しようとしてみます。
$ curl localhost:8080/anonymous/user-info {"name":""}
無名のユーザーみたいです。
この時の、Principalは以下のようです。
io.quarkus.security.runtime.AnonymousIdentityProvider$1
ソースコードは、こちらですね。
SecurityIdentityも、それっぽいのがあります。
ロールでアクセス権限を制限したり、認証必須にしているリソースメソッドに対しては、すべて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
こちらですね。
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…?
また、@RolesAllowedや@Authenticatedアノテーションを付与すると、インターセプターが起動します。
ここにありますね。
他のアノテーションとしては、@DenyAll、@PermitAllがあるようです。
また、インターセプター内から呼び出され、トークンの情報に対して、認証・認可に関する確認を行っているのはこちらのクラスです。
自分で作成するプログラム内で、こういったことをやりたい場合はSecurityIdentityを使ってif文などを書いたりすることになるのでしょう。
まとめ
QuarkusのOpenID Connect Extensionを、リソース保護のみですが、試してみました。
未認証状態でアクセスしたら、Keycloakのログインページにリダイレクトされたりして欲しいのですが、そのあたりはまた今度
確認してみようと思います。
このあたりを見ることになると思います。
Quarkus - Protecting Web Applications Using OpenID Connect
が、今は少し早そうです。