SpringSecurityのセキュリティ設定の書き方を変えたのでformLoginテストも対応した

## これは何？

SpringSecurityのセキュリティ設定の書き方が変わり、WebSecurityConfigurerAdapterを継承する書き方がDeprecatedになり、SecurityFilterChainでBean定義するようになった。

https://qiita.com/suke_masa/items/908805dd45df08ba28d8

そこで前に書いたコードに適用してみることにした

https://qiita.com/kazokmr/items/17e4aef45a1ff8c394a0

## セキュリティ設定の書き方を変える

SecurityConifg の `cofigure(HttpSecurity http)` メソッドは次のように変更した

``` SecurityConifg.kt
package com.presentation.config

import com.domain.enum.RoleType
import com.presentation.handler.CustomAccessDeniedHandler
import com.presentation.handler.CustomAuthenticationEntryPoint
import com.presentation.handler.CustomAuthenticationFailureHandler
import com.presentation.handler.CustomAuthenticationSuccessHandler
import org.springframework.context.annotation.Bean
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.security.web.SecurityFilterChain
import org.springframework.security.web.csrf.CookieCsrfTokenRepository
import org.springframework.web.cors.CorsConfiguration
import org.springframework.web.cors.CorsConfigurationSource
import org.springframework.web.cors.UrlBasedCorsConfigurationSource

@EnableWebSecurity
class SecurityConfig {

    @Bean
    fun passwordEncoder(): PasswordEncoder = BCryptPasswordEncoder()

    @Bean
    fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {

        http.authorizeHttpRequests { authz ->
            authz.mvcMatchers("/greeter/**").permitAll()
                .mvcMatchers("/admin/**").hasAuthority(RoleType.ADMIN.toString())
                .anyRequest().authenticated()
        }.formLogin { login ->
            login.loginProcessingUrl("/login").permitAll()
                .usernameParameter("email")
                .passwordParameter("pass")
                .successHandler(CustomAuthenticationSuccessHandler())
                .failureHandler(CustomAuthenticationFailureHandler())
        }.exceptionHandling { ex ->
            ex.authenticationEntryPoint(CustomAuthenticationEntryPoint())
                .accessDeniedHandler(CustomAccessDeniedHandler())
        }.csrf { csrf ->
            csrf.ignoringAntMatchers("/login")
                .csrfTokenRepository(CookieCsrfTokenRepository())
        }.cors { cors ->
            cors.configurationSource(corsConfigurationSource())
        }
        return http.build()
    }

    private fun corsConfigurationSource(): CorsConfigurationSource {
        val corsConfiguration = CorsConfiguration()
        corsConfiguration.addAllowedMethod(CorsConfiguration.ALL)
        corsConfiguration.addAllowedHeader(CorsConfiguration.ALL)
        corsConfiguration.addAllowedOrigin("http://localhost:8081")
        corsConfiguration.allowCredentials = true

        val corsConfigurationSource = UrlBasedCorsConfigurationSource()
        corsConfigurationSource.registerCorsConfiguration("/**", corsConfiguration)

        return corsConfigurationSource
    }
}
```

もう一つ、`configure(auth: AuthenticationManagerBuilder)` で、AuthenticationManagerにカスタムのUserDetailsServiceを定義していたが、これはCustomUserDetailsServiceクラスに `@Service` を付けて直接Bean定義するようにした

``` CustomUserDetailsService.kt
package com.application.service.security

import com.application.service.AuthenticationService
import com.domain.enum.RoleType
import com.domain.model.Account
import org.springframework.security.core.GrantedAuthority
import org.springframework.security.core.authority.AuthorityUtils
import org.springframework.security.core.userdetails.UserDetails
import org.springframework.security.core.userdetails.UserDetailsService
import org.springframework.security.core.userdetails.UsernameNotFoundException
import org.springframework.stereotype.Service
import java.io.Serial

@Service
class CustomUserDetailsService(private val authenticationService: AuthenticationService) : UserDetailsService {

    override fun loadUserByUsername(username: String): UserDetails =
        when (val account = authenticationService.findAccount(username)) {
            null -> throw UsernameNotFoundException("無効なユーザー名です $username")
            else -> CustomUserDetails(account)
        }
}

data class CustomUserDetails(
    val id: Long,
    val email: String,
    val pass: String,
    val name: String,
    val roleType: RoleType
) : UserDetails {
    constructor(account: Account) : this(account.id, account.email, account.password, account.name, account.roleType)

    override fun getAuthorities(): MutableCollection<out GrantedAuthority> {
        return AuthorityUtils.createAuthorityList(this.roleType.toString())
    }

    override fun getPassword(): String {
        return this.pass
    }

    override fun isEnabled(): Boolean {
        return true
    }

    override fun getUsername(): String {
        return this.email
    }

    override fun isAccountNonExpired(): Boolean {
        return true
    }

    override fun isAccountNonLocked(): Boolean {
        return true
    }

    override fun isCredentialsNonExpired(): Boolean {
        return true
    }

    companion object {
        @Serial
        private const val serialVersionUID: Long = 3887265448650931817L
    }
}
```

## テストを実行する

FormLoginテストのテストコードは今まで通り実行すると、認証情報がNullになっていてエラーになってしまった。IntegrationTestはテストが成功し、プロダクトコードを直接起動して実際にログインテストをしても問題ないので、テストコードが原因だと考えた。

結論としては、
`@ContextConfiguration(classes = [SecurityConfig::class, CustomUserDetailsService::class])` を追加して、定義したBeanをLoadすれば直った。

テストコードをデバッグモードで起動して確認したら、AuthenticationManagerが管理しているProviderが参照しているUserDetailsServiceが、`InMemoryUserDetailsService`になっていたので気づいた。

``` SecurityConfigTest.kt
package com.presentation.config

import com.application.service.AuthenticationService
import com.application.service.mockuser.WithCustomMockUser
import com.application.service.security.CustomUserDetailsService
import com.domain.enum.RoleType
import com.domain.model.Account
import com.presentation.controller.AdminController
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.kotlin.any
import org.mockito.kotlin.whenever
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
import org.springframework.boot.test.mock.mockito.MockBean
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestBuilders.formLogin
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf
import org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.authenticated
import org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.unauthenticated
import org.springframework.test.context.ContextConfiguration
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status

@ContextConfiguration(classes = [SecurityConfig::class, CustomUserDetailsService::class])
@WebMvcTest(controllers = [AdminController::class])
internal class SecurityConfigTest(@Autowired val mockMvc: MockMvc) {

    @Autowired
    private lateinit var passwordEncoder: PasswordEncoder

    @MockBean
    private lateinit var authenticationService: AuthenticationService

    @Test
    @DisplayName("ユーザー名とパスワードが一致すればログイン認証に成功する")
    fun `formLogin when account exists then success authentication`() {

        // Given
        val email = "test@example.com"
        val pass = "passpass"
        val role = RoleType.USER

        val account = Account(1, email, passwordEncoder.encode(pass), "test", role)
        whenever(authenticationService.findAccount(any())).thenReturn(account)

        // When
        mockMvc
            .perform(
                formLogin()
                    .loginProcessingUrl("/login")
                    .user("email", email)
                    .password("pass", pass)
            )
            .andExpect(authenticated().withUsername(email))
            .andExpect(status().isOk)
    }

    @Test
    @DisplayName("パスワードが違うとログイン認証に失敗する")
    fun `formLogin when password is incorrectly then failure authentication`() {

        // Given
        val email = "test@example.com"
        val pass = "passpass"
        val role = RoleType.USER
        val account =
            Account(1, email, passwordEncoder.encode(pass), "test", role)
        whenever(authenticationService.findAccount(any())).thenReturn(account)

        // When
        mockMvc
            .perform(
                formLogin()
                    .loginProcessingUrl("/login")
                    .user("email", email)
                    .password("pass", "invalid")
            )
            .andExpect(unauthenticated())
            .andExpect(status().isUnauthorized)
    }

    @Test
    @DisplayName("登録されていないユーザー名ならログイン認証に失敗する")
    fun `formLogin when account does not exist then failure authentication`() {

        // Given
        whenever(authenticationService.findAccount(any())).thenReturn(null)

        // When
        mockMvc
            .perform(
                formLogin()
                    .loginProcessingUrl("/login")
                    .user("email", "unregister")
                    .password("pass", "invalid")
            )
            .andExpect(unauthenticated())
            .andExpect(status().isUnauthorized)
    }

    @Test
    @DisplayName("認証されていなければアクセスできない")
    fun `exceptionHandling when account does not authenticate then can not access except login`() {

        // When
        mockMvc
            .perform(
                delete("/admin/delete/100")
                    .with(csrf().asHeader())
            )
            .andExpect(status().isUnauthorized)
    }

    @Test
    @DisplayName("認証アカウントが権限を持たなければアクセスできない")
    @WithCustomMockUser(roleType = RoleType.USER)
    fun `exceptionHandling when account has not authorization then can not access`() {

        // When
        mockMvc
            .perform(
                delete("/admin/delete/200")
                    .with(csrf().asHeader())
            )
            .andExpect(status().isForbidden)
    }
}
```

## まとめ

追加したBeanをテストでも利用したい場合は、明示的に追加することを忘れないようにしましょう。