LoginSignup
0
2

More than 1 year has passed since last update.

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

Posted at

これは何?

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

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

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

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をテストでも利用したい場合は、明示的に追加することを忘れないようにしましょう。

0
2
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
0
2