目標
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/ につなぎますが、その時もポート番号を変更するのを忘れないでください。その後の動きが変なことになります。
これから作るサンプルアプリケーションの登録はまた後ほどやります。
Spring Bootアプリケーションの準備
1.雛形の作成
Spring Initializerのサイトで雛形を作成します。
DependenciesにはいったんSpring Web
だけ設定します。
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でなんか返すのを作ります。
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();
}
}
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.
Spring Securityを導入
1.依存関係の追加
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
これだけで実行してみると・・・
何も作ってないのにログインページが表示されました
今は何を入れてもエラーになります。
IDプロバイダーの設定をしていないので当然ですね。
2. application.propertiesの設定
以下のように設定します。
# 認可サーバーのIssuer Identifier
spring.security.oauth2.resourceserver.jwt.issuer-uri= http://localhost:8088/auth/realms/myrealm
この状態で再起動すると・・・
401が返ってきました。
401=Unauthorized
ですね。だから今はこの状態で、正常な動作です。
リソースサーバーの設定はここまでです。
認証アクセス
認可してもらうようにするためにKeycloakにアプリを登録しアクセストークンを取得し、それを使ってアクセスを試します。
1.Keycloakにクライアントアプリを追加
Keycloakのadmin管理画面から、Clientを追加します。
- [Access Type]はConfidentialにします。
-
[Authorization Enabled]をONにします。
- 付随して他にもONになるものがあります。
それ以外はデフォルトのままで大丈夫なはずです。
[Save]します。
2.Client Secretsの取得
Keycloakのadmin管理ページで、クライアント設定画面を開き、タブの[Credentials]をクリックします。
表示されているSecretをコピーしておきます。
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だとこうします。
こんな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の例。
または、[Authorization]タブにアクセストークンをセットします。
jsonが取得できるようになりました!
ここまで、Keycloakとの連携は、application.properties
に書いただけです。
それと、依存ライブラリにspring-boot-starter-oauth2-resource-server
を追加しただけです。
これだけで、受け取ったアクセストークン(JWT)の検証したりいろいろしてくれちゃう(※)んですね。意外でした。
※トークンが期限切れで401になることがよくあったので、やっているものと認識しました。それとも、自前でJWTの検証もすべきなんでしょうか?やるならオフライン認証かなあと思っていますが・・・
スコープによるアクセス制御
先程は、Client Secretでアクセストークンをもらいました。
つまり、登録されたアプリから発行されたアクセストークンならばなんでもどこでもアクセスが許可されます。
せっかくなので、アクセス制御方法も見てみましょう。
OpenID Connectではスコープを複数指定できますね。ユーザーの属性(Claimと言うのかな?)を任意でアクセストークンに含めることが出来ます。
このスコープを使って、アクセスできる/出来ないAPIを作ってみます。
1.スコープマッピングをリソースサーバーに設定する
Springプロジェクトのリソースサーバーに、以下のクラスを追加します。
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が必要だそうです。
続いて、HelloContoroller
とIndexController
をそれぞれ適当に作成します。
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!";
}
}
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でスコープを設定する
hello
やuser
というスコープを追加する必要があります。
なお、追加するのが面倒な人は、定義済みのスコープを使ってもいいでしょう。
(1)Client Scopeの追加
- Keycloakの管理画面で、[Client Scopes]を選びます。
- 右上の[create]をクリック
- Nameなどに任意の設定をする
- Include In Token ScopeをONにする
上記の図では、user
スコープを作っています。
最後に[Save]してください。
同様にして、hello
スコープも作ります。
(2)マッパーを作成
- 作成したスコープを[Client Scopes]からクリック
- [Mappers]タブをクリック
- [Create]をクリック
- Nameは任意
- Mapper TypeはUser Attributeを選択
-
User Attributeに入力
- 任意のものでよいが後で使うので覚えておく
-
Token Claim Nameに入力
- アクセストークンなどに含まれるときのJsonの名前です
-
Claim JSON Typeは任意
- あとで各ユーザーにこのタイプのものを指定するので覚えておく
Hello Mapperはこうしました。
User Mapperはこうしました。
[Save]を忘れないで!
(3)クライアントにスコープを設定する
- [Clients]から対象のクライアントを選ぶ
- [Client Scopes]タブをクリック
- Optional Client Scopesにあるスコープを選択して、[Add Selected]をクリック
3.アクセストークンを再発行する
scope
にopenid
と、hello
またはuser
を追加して、アクセストークンを再発行します。
※openid
は必須。
Postmanだとこうです。
4.各ページアクセス
(1)ルートページへのアクセス
-
http://127.0.0.1:8080/
へブラウザからアクセスしてみます。
/
へのマッピングは、permitAll
ですから、アクセストークンがなくても誰でもアクセスできます。
だから、ブラウザに結果がちゃんと表示されます。
それ以外のページは、401
(Unauthorized)になるはずです。
(2)hello
ページへのアクセス
以下のような結果になるはずです。
- ブラウザからのアクセス、アクセストークンを指定しない場合
-
401
(Unauthorized)
-
- アクセストークンの
scope
にhello
が含まれる場合- アクセス可能(
"Hello"
が表示される)
- アクセス可能(
- アクセストークンの
scope
にhello
が含まれない場合-
403
(Forbidden)
-
(3)/list
ページへのアクセス
- ブラウザからのアクセス、アクセストークンを指定しない場合
-
401
(Unauthorized)
-
- アクセストークンの
scope
にuser
が含まれる場合- アクセス可能(Jsonが表示される)
- アクセストークンの
scope
にuser
が含まれない場合-
403
(Forbidden)
-
ユーザーアクセス
ここまで、Client Secretによるアクセストークンを発行していましたが、ユーザーごとに発行してみます。
ユーザーのロール制御については今回は見送りますが、時間があればそのうちやります。
1.Keycloakにユーザーを追加する
もしKeycloakのチュートリアルで追加していなければ、adminでKeycloakにログインして、[Users]から[Add user]してください。
詳しくはこちらを参照してください。
パスワード設定では、Temporaryを必ずOFFにして、右上の[Save]ボタンを押してください。
2.ユーザーに属性を追加する
先程、スコープを作ったときに、User Attributeというのを指定したと思います。
私の場合は、
- hello
- listuser
でした。これらの属性の値をユーザーにセットします。
- 作成したユーザーを選び、[Attributes]タブをクリック
- Key : 属性名
- スコープ作成時に指定したUser Attributeの値
- Value :
任意の文字列
- スコープ作成時に指定したClaim JSON Typeで設定すること
- [Add]をクリック
- [Save]をクリック
hello
とlistuser
をセットしました。
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だとこうです。
返されたJsonを眺めると、scope
にちゃんと指定した物があるのがわかります。
profile
とemail
はデフォルトで付いてきちゃうようです。
そして、アクセストークン(JWT)を検証すると、ちゃんとそこにも
"scope": "openid hello profile email",
というのがいるのがわかります。
ユーザー(またはクライアント)がこのスコープでアクセスしてきたらGETはOK。別のスコープだったらCREATEは不可。
こんなアクセス制御に使えますね。
ちなみに、Access Token
の中身は、Keycloakの管理画面でも見られます。
- [Clients]から該当のクライアントを選び、[Client Scopes]をクリック後、[Evaluate]をクリック
- Client Scopesを選ぶ'(任意)
-
Userを選ぶ
- 追加済みユーザーのusernameを入れる必要あり(入力を始めると候補が出る)
- [Evaluate]をクリック
- [Generated Access Token]をクリック
userinfoのエンドポイントを叩くと、ユーザー情報を取得でき、スコープに応じて、hello
とlist_user
の項目があるはずです。
以下は、scope=openid hello user
として得たアクセストークンでuserinfoエンドポイントを叩いた結果です。
感想
実装より、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