Keycloak認証を使うリソースサーバーのテスト

概要

Keycloakによる認証を使う、リソースサーバー(Spring Bootクライアント)でのテスト実装メモ。

この手順で使用したコードは、以下に公開しているので、こちらも参考にしてください。
https://github.com/yo1000/kc-resource/584c4c92ce#try-testing-with-only-kc-resource-server

また、テストコード以外の部分については、過去のポストを前提としています。関連するものについては軽く触れますが、詳細を確認したい場合は、そちらを確認してください。

要件

環境

今回の作業環境は以下のとおりです。

  • Java 1.8.0_131
  • Spring Boot 1.5.9.RELEASE
  • Keycloak 3.4.1.Final
$ sw_vers
ProductName:	Mac OS X
ProductVersion:	10.12.5
BuildVersion:	16F2073

$ java -version
java version "1.8.0_131"
Java(TM) SE Runtime Environment (build 1.8.0_131-b11)
Java HotSpot(TM) 64-Bit Server VM (build 25.131-b11, mixed mode)

テストターゲットの準備

プロジェクトの作成

Spring Initializrでプロジェクトを作成します。

$ curl https://start.spring.io/starter.tgz \
  -d dependencies="web,security,keycloak" \
  -d language="kotlin" \
  -d javaVersion="1.8" \
  -d packaging="jar" \
  -d bootVersion="1.5.9.RELEASE" \
  -d type="maven-project" \
  -d groupId="com.yo1000" \
  -d artifactId="kc-resource-server" \
  -d version="1.0.0-SNAPSHOT" \
  -d name="kc-resource-server" \
  -d description="Keycloak Client Testing - Resource Server" \
  -d packageName="com.yo1000.keycloak.resource.server" \
  -d baseDir="kc-resource-server" \
  -d applicationName="KcResourceServerApplication" \
  | tar -xzvf -

$ ls kc-resource-server
mvnw		mvnw.cmd	pom.xml		src

$ cd kc-resource-server

設定ファイルの配置

以下、2ファイルを変更します。

  • pom.xml
  • application.yml
$ sed -i '' \
  's/<keycloak.version>3.4.0.Final<\/keycloak.version>/<keycloak.version>3.4.1.Final<\/keycloak.version>/g' \
  pom.xml

$ mv \
  src/main/resources/application.properties \
  src/main/resources/application.yml

$ echo 'server.port: 18080

keycloak:
  realm: kc-resource
  resource: kc-resource-server
  bearer-only: true
  auth-server-url: http://127.0.0.1:8080/auth
  ssl-required: external
' > src/main/resources/application.yml

セキュリティ構成の実装

ここでは、テストを理解するのに役立つ実装の一部のみ説明します。内容の詳細については、以下を確認してください。
/keycloak/keycloak-collabo-ressrv-rescli.html#implements-security-configuration-for-resource-server

コード例の後に、要点をまとめます。

package com.yo1000.keycloak.resource.server

import org.keycloak.adapters.springsecurity.config.KeycloakWebSecurityConfigurerAdapter
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
import org.springframework.security.web.authentication.session.NullAuthenticatedSessionStrategy
import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy
import org.springframework.security.core.authority.mapping.SimpleAuthorityMapper
import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper
import org.keycloak.adapters.springsecurity.authentication.KeycloakAuthenticationProvider
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.boot.web.servlet.FilterRegistrationBean
import org.keycloak.adapters.springsecurity.filter.KeycloakPreAuthActionsFilter
import org.keycloak.adapters.springsecurity.filter.KeycloakAuthenticationProcessingFilter
import org.keycloak.adapters.springboot.KeycloakSpringBootConfigResolver
import org.keycloak.adapters.KeycloakConfigResolver

@Configuration
@EnableWebSecurity
class KcSecurityConfigurer: KeycloakWebSecurityConfigurerAdapter() {
    @Bean
    fun grantedAuthoritiesMapper(): GrantedAuthoritiesMapper {
        val mapper = SimpleAuthorityMapper()
        mapper.setConvertToUpperCase(true)
        return mapper
    }

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

    @Bean
    fun keycloakAuthenticationProcessingFilterRegistrationBean(
            filter: KeycloakAuthenticationProcessingFilter): FilterRegistrationBean {
        val registrationBean = FilterRegistrationBean(filter)
        registrationBean.isEnabled = false
        return registrationBean
    }

    @Bean
    fun keycloakPreAuthActionsFilterRegistrationBean(
            filter: KeycloakPreAuthActionsFilter): FilterRegistrationBean {
        val registrationBean = FilterRegistrationBean(filter)
        registrationBean.isEnabled = false
        return registrationBean
    }

    override fun sessionAuthenticationStrategy(): SessionAuthenticationStrategy {
        return NullAuthenticatedSessionStrategy()
    }

    override fun keycloakAuthenticationProvider(): KeycloakAuthenticationProvider {
        val provider = super.keycloakAuthenticationProvider()
        provider.setGrantedAuthoritiesMapper(grantedAuthoritiesMapper())
        return provider
    }

    override fun configure(auth: AuthenticationManagerBuilder?) {
        auth!!.authenticationProvider(keycloakAuthenticationProvider())
    }

    override fun configure(http: HttpSecurity) {
        super.configure(http)
        http
                .authorizeRequests()
                .antMatchers("/kc/resource/server/admin").hasRole("ADMIN")
                .antMatchers("/kc/resource/server/user").hasRole("USER")
                .anyRequest().permitAll()
    }
}

grantedAuthoritiesMapper(): GrantedAuthoritiesMapper

認証基盤でロール名を小文字や、大文字小文字混在で設定しても、mapper.setConvertToUpperCase(true)を設定することで、プログラムから扱う場合に、すべて大文字で統一することができます。

configure(http: HttpSecurity)

エンドポイントと、そのアクセスに必要なロールのマッピングを定義します。

antMatchers("/kc/resource/server/admin").hasRole("ADMIN")では、ADMINロールをもっているユーザーが、/kc/resource/server/adminにアクセスできるように設定します。

antMatchers("/kc/resource/server/user").hasRole("USER")では、USERロールをもっているユーザーが、/kc/resource/server/userにアクセスできるように設定します。

コントローラーの実装

リソースを返却するエンドポイントとなる、API用コントローラーを実装します。

package com.yo1000.keycloak.resource.server

import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController

@RestController
@RequestMapping("/kc/resource/server")
class KcResourceServerController {
    @GetMapping("/admin")
    fun getAdminResource(): String {
        return "ADMIN Resource!!"
    }

    @GetMapping("/user")
    fun getUserResource(): String {
        return "USER Resource."
    }
}

テストの実装

テストコード

Spekで書いてしまいたいところですが、Spekだとフィールドインジェクションとの相性が非常に悪いので、JUnitで書いてしまったほうがすっきり書けます。DIを必要とするテストについては、(現時点では)Spekの適用は避けたほうが良いといえるでしょう。

コード例の後に、要点をまとめます。

package com.yo1000.keycloak.resource.server

import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.keycloak.KeycloakPrincipal
import org.keycloak.adapters.RefreshableKeycloakSecurityContext
import org.keycloak.adapters.springsecurity.account.SimpleKeycloakAccount
import org.keycloak.adapters.springsecurity.token.KeycloakAuthenticationToken
import org.mockito.Mockito
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors
import org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers
import org.springframework.test.context.junit4.SpringRunner
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders
import org.springframework.test.web.servlet.result.MockMvcResultHandlers
import org.springframework.test.web.servlet.result.MockMvcResultMatchers
import org.springframework.test.web.servlet.setup.DefaultMockMvcBuilder
import org.springframework.test.web.servlet.setup.MockMvcBuilders
import org.springframework.web.context.WebApplicationContext

@RunWith(SpringRunner::class)
@SpringBootTest(webEnvironment= SpringBootTest.WebEnvironment.RANDOM_PORT)
class KcResourceServerControllerTests {
    @Autowired
    lateinit var context: WebApplicationContext
    lateinit var mockMvc: MockMvc

    @Before
    fun beforeTestEach() {
        mockMvc = MockMvcBuilders
                .webAppContextSetup(context)
                .apply<DefaultMockMvcBuilder>(SecurityMockMvcConfigurers.springSecurity())
                .build()
    }

    /**
     * When the user has Admin and User roles,
     * then can access endpoints that require Admin role or User role.
     */
    @Test
    fun when_the_user_has_Admin_and_User_roles_then_can_access_endpoints_that_require_Admin_role_or_User_role() {
        val token = KeycloakAuthenticationToken(
                SimpleKeycloakAccount(
                        Mockito.mock(KeycloakPrincipal::class.java),
                        setOf("admin", "user"),
                        Mockito.mock(RefreshableKeycloakSecurityContext::class.java)),
                false)

        mockMvc.perform(MockMvcRequestBuilders
                .get("/kc/resource/server/admin")
                .with(SecurityMockMvcRequestPostProcessors
                        .authentication(token)))
                .andDo(MockMvcResultHandlers
                        .print())
                .andExpect(MockMvcResultMatchers
                        .status().isOk)

        mockMvc.perform(MockMvcRequestBuilders
                .get("/kc/resource/server/user")
                .with(SecurityMockMvcRequestPostProcessors
                        .authentication(token)))
                .andDo(MockMvcResultHandlers
                        .print())
                .andExpect(MockMvcResultMatchers
                        .status().isOk)
    }

    /**
     * When the user has only User role,
     * then can access endpoints that require User role,
     * but can't access endpoints that require Admin role.
     */
    @Test
    fun when_the_user_has_only_User_role_then_can_access_endpoints_that_require_User_role_but_cant_access_endpoints_that_require_Admin_role() {
        val token = KeycloakAuthenticationToken(
                SimpleKeycloakAccount(
                        Mockito.mock(KeycloakPrincipal::class.java),
                        setOf("user"),
                        Mockito.mock(RefreshableKeycloakSecurityContext::class.java)),
                false)

        mockMvc.perform(MockMvcRequestBuilders
                .get("/kc/resource/server/admin")
                .with(SecurityMockMvcRequestPostProcessors
                        .authentication(token)))
                .andDo(MockMvcResultHandlers
                        .print())
                .andExpect(MockMvcResultMatchers
                        .status().isForbidden)

        mockMvc.perform(MockMvcRequestBuilders
                .get("/kc/resource/server/user")
                .with(SecurityMockMvcRequestPostProcessors
                        .authentication(token)))
                .andDo(MockMvcResultHandlers
                        .print())
                .andExpect(MockMvcResultMatchers
                        .status().isOk)
    }
}

KeycloakAuthenticationToken

Keycloakにより認証を受けた、認証トークンを表現するクラスです。ここでは、第一引数に認証済みユーザーの情報を、第二引数に対話ログインによる認証トークンであるかどうかを設定します。

SimpleKeycloakAccount

Keycloakで管理されているユーザー情報を表現するクラスです。第一引数にユーザー情報の詳細を、第二引数にロールを、第三引数に認証トークンのリフレッシュを管理するオブジェクトを設定します。

今回の例では、ロールのみ設定し、エンドポイントに正しく認可制御を設定できているかどうかを確認しています。なお、ロール名は小文字で指定していますが、セキュリティ構成の実装により、mapper.setConvertToUpperCase(true)を設定しているため、Spring Security側で大文字に変換されて検査されます。

.with(SecurityMockMvcRequestPostProcessors.authentication(token))

RequestBuilderに、認証情報を設定します。このメソッドで認証情報を設定することで、設定された認証状態に応じたテストが可能になります。

デモ

参考までに実際に動かした結果の一部を、以下に残しておきます。

$ ./mvnw clean test

..

2017-12-28 22:44:37.182  INFO 65336 --- [           main] o.s.t.web.servlet.TestDispatcherServlet  : FrameworkServlet '': initialization completed in 23 ms

MockHttpServletRequest:
      HTTP Method = GET
      Request URI = /kc/resource/server/admin
       Parameters = {}
          Headers = {}

Handler:
             Type = com.yo1000.keycloak.resource.server.KcResourceServerController
           Method = public java.lang.String com.yo1000.keycloak.resource.server.KcResourceServerController.getAdminResource()

Async:
    Async started = false
     Async result = null

Resolved Exception:
             Type = null

ModelAndView:
        View name = null
             View = null
            Model = null

FlashMap:
       Attributes = null

MockHttpServletResponse:
           Status = 200
    Error message = null
          Headers = {X-Content-Type-Options=[nosniff], X-XSS-Protection=[1; mode=block], Cache-Control=[no-cache, no-store, max-age=0, must-revalidate], Pragma=[no-cache], Expires=[0], X-Frame-Options=[DENY], Content-Type=[text/plain;charset=UTF-8], Content-Length=[16]}
     Content type = text/plain;charset=UTF-8
             Body = ADMIN Resource!!
    Forwarded URL = null
   Redirected URL = null
          Cookies = []

..

参考

コード例

https://github.com/keycloak/keycloak/blob/master/adapters/oidc/spring-security/src/test/java/org/keycloak/adapters/springsecurity/authentication/KeycloakAuthenticationProviderTest.java

avatar

Written by yo1000 | YO!CHI KIKUCHI
Loves 🌱 Spring, 🦢 Pelikan fountain pen and 🦁 FINAL FANTASY VIII