📝

memo:Keycloak Spring Boot Adapter

2021/09/05に公開

はじめに

KeycloakのSpring Boot Adapterを使ってOpenID Connectを実装したメモ

OpenID Connect

Keycloak

1. 環境

Spring Bootのプロジェクトで KeycloakのSpring Boot Adapter を使う

2. 準備

Keycloak

インストール

docker-compose.yml

version: '3'
services:
  keycloak:
    image: jboss/keycloak 
    container_name: oidc-keycloak-spring-boot-starter
    ports:
      - 8081:8080
    environment:
      - KEYCLOAK_USER=admin
      - KEYCLOAK_PASSWORD=password

Dockerイメージ作成

docker-compose build
  • Image jboss/keycloak が作成されてることを確認
% docker image ls

REPOSITORY           TAG       IMAGE ID       CREATED       SIZE
jboss/keycloak       latest    4ed3cf7f13a5   7 weeks ago   714MB

コンテナ起動

docker-compose -p oidc-keycloak-spring-boot-starter up -d
  • Container oidc-keycloak-spring-boot-starter が作成されて起動していることを確認
% docker ps

CONTAINER ID   IMAGE            COMMAND                  CREATED         STATUS         PORTS                                                 NAMES
4f6a9179e655   jboss/keycloak   "/opt/jboss/tools/do…"   5 minutes ago   Up 5 minutes   0.0.0.0:8080->8080/tcp, :::8080->8080/tcp, 8443/tcp   oidc-keycloak-spring-boot-starter

設定

Keycloak設定画面にログイン

ブラウザから接続

http://localhost:8081/auth/

Keycloakにログイン

admin / password

Client作成

  • Master - Configure - Clients - Create
    • Add Client
      • Client ID : spring-boot-app
      • Client Protocol : openid-connect
      • Root URL :
  • Master - Configure - Clients - Spring-boot-app
    • Settingsタブ
      • Access Type : confidential [1]
      • Valid Redirect URIs : http://localhost:8080/* [2]
      • それ以外はデフォルト値のまま
    • Credentialsタブ
      • Client Authenticator : Client Id and Secret
      • Secret : [3]

User作成

  • Master - Manage - Users - Add user

    • Username : hoge
    • Email : hoge@gebogebo.com
    • First Name : Ho
    • Last Name : Ge
  • Master - Manage - Users - hoge

    • Credentialsタブ
      • Password : 任意のパスワード
      • Temporary : OFF
      • Set Password

Spring boot

プロジェクト作成

Spring initializr

  • Project : Gradle Project
  • Language : Kotlin
  • Dependencies
    • Spring Web
    • Thymeleaf

画面作成

index.html

  • トップページ、ログイン前
  • ボタンを押したらログインする
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.w3.org/1999/xhtml">
<head>
    <meta charset="UTF-8">
    <title>Login</title>
</head>
<body>
    <h3>OpenID Connect Login Test</h3>
    <button onclick="location.href='./secure/welcome'">
        Login with Keycloak
    </button>
</body>
</html>

welcome.html

  • ログイン(Keycloak認証)後に表示する
  • ログインしたユーザーの情報を表示する
  • Logoutボタンでログアウトする
  • Settingで設定画面に遷移する
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.w3.org/1999/xhtml">
<head>
    <meta charset="UTF-8">
    <title>Welcome</title>
</head>
<body>
    <h3>Welcome</h3>
		<div th:text="|user name = ${userinfo.username}|"></div>
    <div th:text="|email = ${userinfo.email}|"></div>
    <div th:text="|last name = ${userinfo.lastname}|"></div>
    <div th:text="|first name = ${userinfo.firstname}|"></div>
    <div th:text="|id token.issuer = ${userinfo.idTokenInfo.issuer}|"></div>
    <div th:text="|id token.audience = ${userinfo.idTokenInfo.audience}|"></div>
    <div th:text="|access token.issuer = ${userinfo.accessTokenInfo.issuer}|"></div>
    <div th:text="|access token.audience = ${userinfo.accessTokenInfo.audience}|"></div>
    <div th:text="|access token.roles = ${userinfo.accessTokenInfo.roles}|"></div>
    <div th:text="|access token.scopes = ${userinfo.accessTokenInfo.scopes}|"></div>
    <div>
        <button onclick="location.href='./logout'">
            Logout
        </button>
    </div>
    <div>
        <button onclick="location.href='./setting'">
            Setting
        </button>
    </div>
</body>
</html>

setting.html

  • 設定画面
  • Admin専用
  • openid-configurationデータを表示する
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.w3.org/1999/xhtml">
<head>
    <meta charset="UTF-8">
    <title>Setting</title>
</head>
<body>
    <h3>Admin Only</h3>
    <div th:text="|openid-configuration|"></div>
    <div th:text="|${openidconfiguration}|"></div>
    <button type="button" onclick="history.back()">
        Back
    </button>
</body>
</html>

コントローラ作成

AppController

@Controller
class AppController {

    @GetMapping("/")
    fun index():String{
        return "index"
    }

    @GetMapping("/secure/welcome")
    fun welcome(model: Model):String{
        model.addAttribute("userinfo", UserInfoService().getUserInfo())
        return "welcome"
    }

    @GetMapping("/secure/logout")
    fun logout(request: HttpServletRequest):String{
        request.logout()
        return "redirect:/"
    }

    @GetMapping("/secure/setting")
    fun setting(model: Model):String{
        model.addAttribute("openidconfiguration", "No data")
        return "setting"
    }
}

UserInfoService

class UserInfoService {
    fun getUserInfo():UserInfo{
        return UserInfo()
    }

    class UserInfo {
        var username = "No data"
        var emailID = "No data"
        var lastname = "No data"
        var firstname = "No data"
        var idTokenInfo = IdTokenInfo()
        var accessTokenInfo = AccessTokenInfo()
    }

    class IdTokenInfo {
        var issuer = "No data"
        var audience = "No data"
    }

    class AccessTokenInfo {
        var issuer = "No data"
        var audience = "No data"
        var roles = "No data"
        var scopes = "No data"
    }
}

この時点で一旦一連の動作確認

  • http://localhost:8080 で index.html を表示する
  • Login with Keycloak で welcome.html に遷移(認証は無い)
    • UserInfoはすべて No data と表示
  • Setting で setting.html を表示
    • openid-configuration は No data と表示
    • back で戻る
  • Logout 最初の画面に戻る

3. Spring Boot Adapterの導入

実装

build.gradle.kts

...

dependencies {
	implementation("org.springframework.boot:spring-boot-starter-thymeleaf")
	implementation("org.springframework.boot:spring-boot-starter-web")
	implementation("org.springframework.boot:spring-boot-starter-security") <- 追加
	implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
	implementation("org.jetbrains.kotlin:kotlin-reflect")
	implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
	implementation("org.keycloak:keycloak-spring-boot-starter:15.0.2") <- 追加
	testImplementation("org.springframework.boot:spring-boot-starter-test")
}

...
  • keycloak-spring-boot-starterのバージョンは latest の値 を使う

  • 修正したら画面の[Gradle]から更新ボタンを押さないと反映されてないので注意

SecurityConfig[4]

@KeycloakConfiguration
class SecurityConfig : KeycloakWebSecurityConfigurerAdapter() {

    @Autowired
    fun configureGlobal(auth: AuthenticationManagerBuilder) {
        val keycloakAuthenticationProvider = keycloakAuthenticationProvider()
        keycloakAuthenticationProvider.setGrantedAuthoritiesMapper(SimpleAuthorityMapper())
        auth.authenticationProvider(keycloakAuthenticationProvider)
    }

    @Bean
    override fun sessionAuthenticationStrategy(): SessionAuthenticationStrategy {
        return RegisterSessionAuthenticationStrategy(SessionRegistryImpl())
    }

    @Bean
    fun keycloakConfigResolver() : KeycloakConfigResolver {
        return KeycloakSpringBootConfigResolver()
    }

    override fun configure(http: HttpSecurity) {
        super.configure(http)

        http.authorizeRequests()
            .antMatchers("/").permitAll()
            .antMatchers("/secure/setting").hasRole("admin")
            .anyRequest().authenticated()
    }

    @Autowired
    lateinit var keycloakClientRequestFactory: KeycloakClientRequestFactory

    @Bean
    @Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)
    fun keycloakRestTemplate(): KeycloakRestTemplate {
        return KeycloakRestTemplate(keycloakClientRequestFactory)
    }
}
  • configureGlobal()
    • auth.authenticationProvider(keycloakAuthenticationProvider())だけのサンプルが公式[5]
    • ただし、この場合Keyacloakで設定されているRole名にROLE_とプリフィックスされている必要がある。その意味不明な制約をSimpleAuthorityMapper()で解除できるらしい[6]
  • configure()
  • permitAll()/ は認証不要
    • hasRole("admin")/secure/setting は admin だけがアクセス可能
  • anyRequest().authenticated()→それ以外 は 認証 が必要
  • keycloakRestTemplate()
    • Keycloakのエンドポイントに個別にアクセスする際に便利なKeycloakRestTemplateのBean[7]

UserInfoService.getUserInfo

fun getUserInfo():UserInfo{
  val authentication = SecurityContextHolder.getContext().authentication
  val principal = authentication.principal as KeycloakPrincipal<*>
  val session = principal.keycloakSecurityContext
  val accessToken = session.token
  val idToken = session.idToken
  val info = UserInfo()
  info.username = idToken.preferredUsername?:""
  info.email = idToken.email?:""
  info.lastname = idToken.familyName?:""
  info.firstname = idToken.givenName?:""
  info.idTokenInfo.issuer = idToken.issuer?:""
  info.idTokenInfo.audience = idToken.audience?.joinToString(",")?:""
  info.accessTokenInfo.issuer = accessToken.issuer?:""
  info.accessTokenInfo.audience = accessToken.audience?.joinToString(",")?:""
  val realmAccess = accessToken.realmAccess
  info.accessTokenInfo.roles = realmAccess.roles?.toString()?:""
  info.accessTokenInfo.scopes = accessToken.scope?:""
  return info
}

setting

@Controller
class AppController {
    ...

    @GetMapping("/secure/setting")
    fun setting(model: Model):String{
        val response = template.getForEntity(endpoint, String::class.java)
        model.addAttribute("openidconfiguration", response.body)
        return "setting"
    }

    @Autowired
    private lateinit var template: KeycloakRestTemplate
    private val endpoint = "http://localhost:8081/auth/realms/master/.well-known/openid-configuration"

}

application.properties

keycloak.enabled=true
keycloak.auth-server-url=http://localhost:8081/auth
keycloak.realm=master
keycloak.public-client=false
keycloak.resource=spring-boot-app
keycloak.credentials.secret=daa4d455-68d6-4724-9ce5-3a76f5bda0d1

動作確認

Login with Keycloak

OpenID Connect の Authorization Code Flow でログインし、トークンに含まれるユーザー情報を表示する

  • jwks_uriへのアクセスが無い→Token(JWT)の署名検証はしていないようだ[8]
Source Destination
Login with Keycloak をクリック
Browser App GET /secure/welcome HTTP/1.1
Adapter が Intercept
Adapter Browser HTTP/1.1 302
Browser Adapter GET /sso/login HTTP/1.1
Adapter Browser HTTP/1.1 302
Browser Keycloak GET
/auth/realms/master/protocol/openid-connect/auth
?response_type=code
&client_id=spring-boot-app
&redirect_uri=http%3A%2F%2Flocalhost%3A8080%2Fsso%2Flogin
&state=59056b4f-0599-462f-9165-56f2e0df4bb5
&login=true
&scope=openid
HTTP/1.1
Keycloak Browser HTTP/1.1 200 OK (text/html)
認証画面を表示
User/Passwordを入力してSubmit
Browser Keycloak POST
/auth/realms/master/login-actions/authenticate
?session_code=RBWjMrRlRtS1OdJrrnqPt7WezK4KEk32gBJC7oiPKFE&execution=50b110a9-fbd3-4853-a240-ca5b0b60cca4
&client_id=spring-boot-app
&tab_id=hhOKRt1DJm8
HTTP/1.1 (application/x-www-form-urlencoded)
Keycloak Browser HTTP/1.1 302 Found
Browser Adaptor GET
/sso/login
?state=59056b4f-0599-462f-9165-56f2e0df4bb5
&session_state=8fd4668f-8793-4237-98cf-a4b29d10f1ef
&code=3c82ed51-9f94-43d0-9753-c67a6c93bc28.8fd4668f-8793-4237-98cf-a4b29d10f1ef.abeaef51-8f0e-468a-aaf2-d17dca021dbr7
HTTP/1.1
Adaptor Keycloak POST
/auth/realms/master/protocol/openid-connect/token
HTTP/1.1 (application/x-www-form-urlencoded)
Keycloak Adaptor HTTP/1.1 200 OK , JavaScript Object Notation (application/json)
Adaptor Browser HTTP/1.1 302
Browser App GET /secure/welcome HTTP/1.1
AppController.welcome()
App Browser HTTP/1.1 200 (text/html)
welcome.htmlを表示

Access Token期限切れ時の再取得

  • アクセストークンが切れた頃にログインし直すとリフレッシュトークンを使ってアクセストークンを再取得する
  • アクセストークンの有効期限は1分
  • リフレッシュトークンの有効期限は30分
Source Destination
Login with Keycloak をクリック
Browser App GET /secure/welcome HTTP/1.1
Adapter が Intercept
Adapter Keycloak POST /auth/realms/master/protocol/openid-connect/token HTTP/1.1 (application/x-www-form-urlencoded)
Keycloak Adapter HTTP/1.1 200 OK , JavaScript Object Notation (application/json)
Adapter Browser HTTP/1.1 200 (text/html)

Logout

Source Destination
Logout をクリック
Browser App GET /secure/logout HTTP/1.1
AppController.logout()
- request.logout()
Adapter Keycloak POST /auth/realms/master/protocol/openid-connect/logout HTTP/1.1 (application/x-www-form-urlencoded)
Keycloak Adapter HTTP/1.1 204 No Content
AppController.logout()
- return "redirect:/"
Adapter Browser HTTP/1.1 302
Browser App GET / HTTP/1.1
AppController.index()
App Browser HTTP/1.1 200 (text/html)

openid-configuration

  • KeycloakRestTemplateを使ってopenid-configuration にアクセスする
Source Destination
setting をクリック
Browser App GET /secure/setting HTTP/1.1
AppController.setting()
template.getForEntity()
App Keycloak GET /auth/realms/master/.well-known/openid-configuration HTTP/1.1
Keycloak App HTTP/1.1 200 OK , JavaScript Object Notation (application/json)
App Browser HTTP/1.1 200 (text/html)
openid-configuration(JSON)を画面に表示
脚注
  1. Access Type : confidential
    OAuth ClientType
    http://openid-foundation-japan.github.io/rfc6749.ja.html#client-types
    https://datatracker.ietf.org/doc/html/rfc6749#section-2.1 ↩︎

  2. Valid Redirect URIs
    https://keycloak-documentation.openstandia.jp/3.4/ja_JP/server_admin/index.html ↩︎

  3. Secret
    https://qiita.com/TakahikoKawasaki/items/63ed4a9d8d6e5109e401#14-client_secret_basic
    https://openid.net/specs/openid-connect-core-1_0.html#ClientAuthentication ↩︎

  4. https://www.baeldung.com/spring-boot-keycloak#securityconfig ↩︎

  5. https://www.keycloak.org/docs/latest/securing_apps/index.html#_spring_security_adapter
    https://keycloak-documentation.openstandia.jp/master/ja_JP/securing_apps/index.html#_spring_security_adapter ↩︎

  6. https://www.keycloak.org/docs/latest/securing_apps/index.html#naming-security-roles ↩︎

  7. https://www.keycloak.org/docs/latest/securing_apps/index.html#client-to-client-support ↩︎

  8. (このフローの中で) ID Token を Client と Token Endpoint の間の直接通信により受け取ったならば, トークンの署名確認の代わりに TLS Server の確認を issuer の確認のために利用してもよい (MAY).
    http://openid-foundation-japan.github.io/openid-connect-core-1_0.ja.html#IDTokenValidation ↩︎

Discussion