LoginSignup
9
13

More than 3 years have passed since last update.

KeycloakとSpringBoot/SecurityでOpenID Connect(リソースサーバー編)

Last updated at Posted at 2020-04-24

目標

KeycloakをID Providerとして、RESTfulAPIが動くリソースサーバーを作る。

本当はSpring Framework(not Boot)で作りたかったのですが、スピード重視で一旦Bootでやります。
(検索するとBootの情報しかない気がします・・・)

環境など

ツールなど バージョンなど
MacbookPro macOS Mojave 10.14.5
IntelliJ IDEA Ultimate 2019.3.3
Java AdoptOpenJDK 11
apache maven 3.6.3
JUnit 5.6.0
Postman 7.19.1
Spring Boot 2.2.6.RELEASE
Spring Security 5.2.2.RELEASE
Keycloak 9.0.3
Docker 19.03.8, build afacb8b

Keycloakの準備

Keycloakを実行するのに、Dockerから使います。
こちらなどが参考になります。

公式のチュートリアル
https://www.keycloak.org/getting-started/getting-started-docker

DockerとKeycloakで世界最速OpenID Connect!!
https://tech-lab.sios.jp/archives/19319

ただ、localhostのポート番号がSpringのサンプルアプリとかぶると困るので、ポート番号を変えておきます。
コマンドは次のようになります。

$ docker run -p 8088:8088 -e KEYCLOAK_USER=admin -e KEYCLOAK_PASSWORD=admin quay.io/keycloak/keycloak:9.0.3 -Djboss.http.port=8088

-pオプションの後だけ変更するのでは足りません。-Djboss.http.portでの指定も必要になります。
AdminLogin用のURLは以下のようになります。

あとは、上記の参考ページに従って、クライアントアプリの登録と、ユーザーアカウントの登録を行っておきます。

最後にサンプルクライアントアプリの登録で、https://www.keycloak.org/app/ につなぎますが、その時もポート番号を変更するのを忘れないでください。その後の動きが変なことになります。

sample_app.png

これから作るサンプルアプリケーションの登録はまた後ほどやります。

Spring Bootアプリケーションの準備

1.雛形の作成

Spring Initializerのサイトで雛形を作成します。
DependenciesにはいったんSpring Webだけ設定します。

spring_initializer.png

Project Metadataはお好みのものに変えてください。

[GENERATE]をクリックするとプロジェクトをダウンロードできます。
解凍し、IntelliJで[Import Project]します。

2.とりあえず実行

なにも考えずに、そのままデバッグボタンを押します。

  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::        (v2.2.6.RELEASE)

2020-04-23 19:03:06.048  INFO 57394 --- [           main] com.example.demo.DemoApplication         : Starting DemoApplication on kanedev.local with PID 57394 (/Users/myuser_name/Documents/workspace/boot_demo/target/classes started by myuser_name in /Users/myuser_name/Documents/workspace/boot_demo)
2020-04-23 19:03:06.050  INFO 57394 --- [           main] com.example.demo.DemoApplication         : No active profile set, falling back to default profiles: default
2020-04-23 19:03:06.845  INFO 57394 --- [           main] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat initialized with port(s): 8080 (http)
2020-04-23 19:03:06.879  INFO 57394 --- [           main] o.apache.catalina.core.StandardService   : Starting service [Tomcat]
2020-04-23 19:03:06.880  INFO 57394 --- [           main] org.apache.catalina.core.StandardEngine  : Starting Servlet engine: [Apache Tomcat/9.0.33]
2020-04-23 19:03:06.974  INFO 57394 --- [           main] o.a.c.c.C.[Tomcat].[localhost].[/]       : Initializing Spring embedded WebApplicationContext
2020-04-23 19:03:06.975  INFO 57394 --- [           main] o.s.web.context.ContextLoader            : Root WebApplicationContext: initialization completed in 893 ms
2020-04-23 19:03:07.190  INFO 57394 --- [           main] o.s.s.concurrent.ThreadPoolTaskExecutor  : Initializing ExecutorService 'applicationTaskExecutor'
2020-04-23 19:03:07.469  INFO 57394 --- [           main] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat started on port(s): 8080 (http) with context path ''
2020-04-23 19:03:07.474  INFO 57394 --- [           main] com.example.demo.DemoApplication         : Started DemoApplication in 1.685 seconds (JVM running for 7.407)

なんかこんなログが出ればOK.

3.REST APIを作成

とりあえずGETでなんか返すのを作ります。

ProductController.java
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;

@RestController
public class ProductController {
    private final ProductService productService;

    @Autowired
    public ProductController(ProductService productService) {
        this.productService = productService;
    }

    @GetMapping(path = "/list")
    public List<String> getProducts() {
        return productService.getProducts();
    }
}
ProductService.java
import org.springframework.stereotype.Service;

import java.util.Arrays;
import java.util.List;

@Service
public class ProductService {

    public List getProducts(){
        return Arrays.asList("Mazda","Toyota","Audi");
    }
}

4.実行

実行して、ブラウザなどから127.0.0.1:8080/listにアクセスします。
以下のように表示されればOK.

first_run.png

Spring Securityを導入

1.依存関係の追加

pom.xml
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>

これだけで実行してみると・・・

login.png

何も作ってないのにログインページが表示されました:open_mouth:
今は何を入れてもエラーになります。
IDプロバイダーの設定をしていないので当然ですね。

2. application.propertiesの設定

以下のように設定します。

application.properties
# 認可サーバーのIssuer Identifier
spring.security.oauth2.resourceserver.jwt.issuer-uri= http://localhost:8088/auth/realms/myrealm

この状態で再起動すると・・・

401.png

401が返ってきました。
401=Unauthorizedですね。だから今はこの状態で、正常な動作です。

リソースサーバーの設定はここまでです。

認証アクセス

認可してもらうようにするためにKeycloakにアプリを登録しアクセストークンを取得し、それを使ってアクセスを試します。

1.Keycloakにクライアントアプリを追加

Keycloakのadmin管理画面から、Clientを追加します。

add_sclient_boot.png

  • [Access Type]Confidentialにします。
  • [Authorization Enabled]ONにします。
    • 付随して他にもONになるものがあります。
confidential.png

それ以外はデフォルトのままで大丈夫なはずです。
[Save]します。

2.Client Secretsの取得

Keycloakのadmin管理ページで、クライアント設定画面を開き、タブの[Credentials]をクリックします。

表示されているSecretをコピーしておきます。

credential.png

3.アクセストークンを取得

(1)エンドポイントからアクセストークンを取得

クライアントアプリを作成するなら、ログイン画面を出すようにリダイレクトでいいのですが、今回、リソースサーバーはクライアントアプリとは別に動作するという前提で作っています。なので、ここはアクセストークンを受け取ることだけ考えます。

そこで、アクセストークン発行エンドポイントをcurlなどで叩いて、取得します。

curlで以下のコマンドを叩きます。

$ curl -X POST "http://localhost:8088/auth/realms/[クライアントを作ったrealm名]/protocol/openid-connect/token" \
--data "grant_type=client_credentials&client_secret={さっきコピーしたsecret}&client_id=boot-sample"

Postmanだとこうします。

token_postman.png

こんなJsonが返ってくるかと思います。

{
    "access_token": "eyJhbGciOiJSUzI1....",
    "expires_in": 600,
    "refresh_expires_in": 1800,
    "refresh_token": "....",
    "token_type": "bearer",
    "not-before-policy": 0,
    "session_state": "5b53443e-67fb-457d-9540-5ae58bb2ed1b",
    "scope": "profile email"
}

"access_token"の中身をコピーしておきます。(長いよ)

(2)アクセストークンの中身を見る

https://jwt.io/ などでアクセストークンの中身を見ることが出来ます。コピーした"access_token"を貼り付け、ふむふむと思っておきましょう。

4.アクセストークンを指定して実行

curlコマンドなどで先ほどコピーしたアクセストークンを使います。

$ curl -v -X GET -H "Authorization: Bearer 先ほどコピーしたアクセストークン" http://localhost:8088/list

Postmanの例。

postman_with_token.png

または、[Authorization]タブにアクセストークンをセットします。

postman_auth.png

jsonが取得できるようになりました!

ここまで、Keycloakとの連携は、application.propertiesに書いただけです。
それと、依存ライブラリにspring-boot-starter-oauth2-resource-serverを追加しただけです。
これだけで、受け取ったアクセストークン(JWT)の検証したりいろいろしてくれちゃう(※)んですね。意外でした。

※トークンが期限切れで401になることがよくあったので、やっているものと認識しました。それとも、自前でJWTの検証もすべきなんでしょうか?やるならオフライン認証かなあと思っていますが・・・

スコープによるアクセス制御

先程は、Client Secretでアクセストークンをもらいました。
つまり、登録されたアプリから発行されたアクセストークンならばなんでもどこでもアクセスが許可されます。
せっかくなので、アクセス制御方法も見てみましょう。

OpenID Connectではスコープを複数指定できますね。ユーザーの属性(Claimと言うのかな?)を任意でアクセストークンに含めることが出来ます。
このスコープを使って、アクセスできる/出来ないAPIを作ってみます。

1.スコープマッピングをリソースサーバーに設定する

Springプロジェクトのリソースサーバーに、以下のクラスを追加します。

SecurityConfig.java
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;

@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .antMatchers(HttpMethod.GET, "/hello").hasAuthority("SCOPE_hello")
                .antMatchers(HttpMethod.GET, "/list").hasAuthority("SCOPE_user")
                .antMatchers(HttpMethod.GET, "/").permitAll()
                .anyRequest().authenticated();
        http.oauth2ResourceServer()
                .jwt();
    }
}

エンドポイントを3つ用意して、それぞれスコープが許可されている場合、されていない場合としてみました。

  • /helloパス
    • helloスコープを持ったアクセストークンでのみアクセス可能
  • /listパス
    • userスコープを持ったアクセストークンでのみアクセス可能
  • /(index)パス
    • すべてのアクセスを許可

スコープ名の指定ですが、"SCOPE_"というprefixが必要だそうです。

続いて、HelloContorollerIndexControllerをそれぞれ適当に作成します。

HelloController.java
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class HelloController {

    @GetMapping("/hello")
    public String hello() {
        return "Hello!";
    }
}
IndexController.java
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class IndexController {

    @GetMapping("/index")
    public String index() {
        return "Welcome to Sample Page!!";
    }
}

2.Keycloakでスコープを設定する

hellouserというスコープを追加する必要があります。
なお、追加するのが面倒な人は、定義済みのスコープを使ってもいいでしょう。

(1)Client Scopeの追加

  • Keycloakの管理画面で、[Client Scopes]を選びます。
  • 右上の[create]をクリック
create_scope.png
  • Nameなどに任意の設定をする
  • Include In Token ScopeONにする
config_scope.png

上記の図では、userスコープを作っています。
最後に[Save]してください。

同様にして、helloスコープも作ります。

(2)マッパーを作成

  • 作成したスコープを[Client Scopes]からクリック
  • [Mappers]タブをクリック
  • [Create]をクリック
create_mapper.png
  • Nameは任意
  • Mapper TypeUser Attributeを選択
  • User Attributeに入力
    • 任意のものでよいが後で使うので覚えておく
  • Token Claim Nameに入力
    • アクセストークンなどに含まれるときのJsonの名前です
  • Claim JSON Typeは任意
    • あとで各ユーザーにこのタイプのものを指定するので覚えておく

Hello Mapperはこうしました。

hello-mapper.png

User Mapperはこうしました。

user_mapper.png

[Save]を忘れないで!

(3)クライアントにスコープを設定する

  • [Clients]から対象のクライアントを選ぶ
  • [Client Scopes]タブをクリック
  • Optional Client Scopesにあるスコープを選択して、[Add Selected]をクリック
client_optional_scope.png

3.アクセストークンを再発行する

scopeopenidと、helloまたはuserを追加して、アクセストークンを再発行します。
openidは必須。

Postmanだとこうです。

renew_access_token.png

4.各ページアクセス

(1)ルートページへのアクセス

  • http://127.0.0.1:8080/へブラウザからアクセスしてみます。 /へのマッピングは、permitAllですから、アクセストークンがなくても誰でもアクセスできます。 だから、ブラウザに結果がちゃんと表示されます。

それ以外のページは、401(Unauthorized)になるはずです。

(2)helloページへのアクセス

以下のような結果になるはずです。

  • ブラウザからのアクセス、アクセストークンを指定しない場合
    • 401(Unauthorized)
  • アクセストークンのscopehelloが含まれる場合
    • アクセス可能("Hello"が表示される)
  • アクセストークンのscopehelloが含まれない場合
    • 403(Forbidden)

(3)/listページへのアクセス

  • ブラウザからのアクセス、アクセストークンを指定しない場合
    • 401(Unauthorized)
  • アクセストークンのscopeuserが含まれる場合
    • アクセス可能(Jsonが表示される)
  • アクセストークンのscopeuserが含まれない場合
    • 403(Forbidden)

ユーザーアクセス

ここまで、Client Secretによるアクセストークンを発行していましたが、ユーザーごとに発行してみます。
ユーザーのロール制御については今回は見送りますが、時間があればそのうちやります。

1.Keycloakにユーザーを追加する

もしKeycloakのチュートリアルで追加していなければ、adminでKeycloakにログインして、[Users]から[Add user]してください。
詳しくはこちらを参照してください。

パスワード設定では、Temporaryを必ずOFFにして、右上の[Save]ボタンを押してください。

temporary_off.png

2.ユーザーに属性を追加する

先程、スコープを作ったときに、User Attributeというのを指定したと思います。
私の場合は、

  • hello
  • listuser

でした。これらの属性の値をユーザーにセットします。

  • 作成したユーザーを選び、[Attributes]タブをクリック
  • Key : 属性名
    • スコープ作成時に指定したUser Attributeの値
  • Value : 任意の文字列
    • スコープ作成時に指定したClaim JSON Typeで設定すること
  • [Add]をクリック
  • [Save]をクリック

hellolistuserをセットしました。

user_attr.png

3.ユーザーのアクセストークンを発行する

先ほど作成したのはクライアントのサービスアカウントに対するアクセストークンでしたが、今回はユーザーごとのアクセストークンを取得して試します。

スコープの指定は、openid helloか、openid scopeとします。

$ curl -X POST "http://localhost:8088/auth/realms/[クライアントを作ったrealm名]/protocol/openid-connect/token" \
--data "grant_type=password&username={作ったユーザー名}&password={ユーザーのパスワード}client_secret={さっきコピーしたsecret}&client_id=boot-sample&scope=openid hello"

Postmanだとこうです。

user_access_token.png

返されたJsonを眺めると、scopeにちゃんと指定した物があるのがわかります。
profileemailはデフォルトで付いてきちゃうようです。

そして、アクセストークン(JWT)を検証すると、ちゃんとそこにも

"scope": "openid hello profile email",

というのがいるのがわかります。

ユーザー(またはクライアント)がこのスコープでアクセスしてきたらGETはOK。別のスコープだったらCREATEは不可。
こんなアクセス制御に使えますね。

ちなみに、Access Tokenの中身は、Keycloakの管理画面でも見られます。

  • [Clients]から該当のクライアントを選び、[Client Scopes]をクリック後、[Evaluate]をクリック
  • Client Scopesを選ぶ'(任意)
  • Userを選ぶ
    • 追加済みユーザーのusernameを入れる必要あり(入力を始めると候補が出る)
  • [Evaluate]をクリック
  • [Generated Access Token]をクリック
evaluate.png

userinfoのエンドポイントを叩くと、ユーザー情報を取得でき、スコープに応じて、hellolist_userの項目があるはずです。

以下は、scope=openid hello userとして得たアクセストークンでuserinfoエンドポイントを叩いた結果です。

userinfo.png

感想

実装より、Keycloakの設定に苦労した感じです^^;
コードはほとんど書いてないですからね。

本当はレルムのロールによるユーザーアクセス制御もしたかったのですが、ちょっと長くなったので一旦区切ります。

それと、毎回アクセストークンを手動で発行してコピペするのも大変なので、クライアントアプリも作って結合した状態で流してみたいですね。リフレッシュトークンも対応しなきゃですが。

Bootを使わないで書く方法は、任せた(誰に)

サンプルプロジェクト

ここまでのプロジェクトは、以下にアップしてあります。
https://github.com/le-kamba/SpringSecurityAndKeycloakSamples

参考サイト

Dockerで起動するKeycloakのポート番号変更の参考になりました。
https://stackoverflow.com/questions/57430811/changing-default-port-of-keycloak-in-docker

Spring Bootアプリ(クライアント)の設定の参考になりました。
http://www.javafoundation.xyz/2018/07/integrate-keycloak-with-spring-security.html

リソースサーバーの作り方の参考になりました。
https://qiita.com/suke_masa/items/0f75fa75a22a6551065b

アクセストークンの取得方法の参考にしました。
https://qiita.com/ryou56/items/4c6fa618d1f6b960ba71

スコープを追加する参考になりました。
https://www.janua.fr/using-client-scope-with-redhat-sso-keycloak/
https://qiita.com/rawr/items/d4f45e094c39ef43cbdf

9
13
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
13