これは何?
SpringSecurityのセキュリティ設定の書き方が変わり、WebSecurityConfigurerAdapterを継承する書き方がDeprecatedになり、SecurityFilterChainでBean定義するようになった。
そこで前に書いたコードに適用してみることにした
セキュリティ設定の書き方を変える
SecurityConifg の cofigure(HttpSecurity http)
メソッドは次のように変更した
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定義するようにした
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
になっていたので気づいた。
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をテストでも利用したい場合は、明示的に追加することを忘れないようにしましょう。