Spring Securityの認証・認可の単体テストを書いたので、つまづいた点などを残しておく。
@SpringBootTest
などを付けた結合テストの事例は多かったけどUnitテストレベルの書き方が見つからなかったので。
WebアプリがREST APIだったので主にレスポンスのHTTPステータスを検証することが目的となっており、画面遷移などは無く、ログイン専用のControllerなども用意していない。
SpringSecurity5.7から、WebSecurityConfigurerAdapterを継承した書き方がDeprecatedになりました。
これによってこの記事で書かれているコードは古いのでこちらを参照
SpringSecurityのセキュリティ設定の書き方を変えたのでformLoginテストも対応した
説明している内容は引き続き有効なのでこの記事はこのまま残します。
参考
プロダクションコード
フォームログインに関連する部分を抜粋
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.context.annotation.Bean
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
import org.springframework.security.crypto.password.PasswordEncoder
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(private val authenticationService: AuthenticationService) : WebSecurityConfigurerAdapter() {
// Accountのパスワードをエンコードする際に必要となるのでBeanにする
@Bean
fun passwordEncoder(): PasswordEncoder = BCryptPasswordEncoder()
// パスワード認証時 `configure(AuthenticationManagerBuilder)` にエンコードするのでDI
@Autowired
private lateinit var passwordEncoder: PasswordEncoder
override fun configure(http: HttpSecurity) {
http.authorizeRequests()
.mvcMatchers("/login").permitAll()
.mvcMatchers("/admin/**").hasAuthority(RoleType.ADMIN.toString())
.anyRequest().authenticated()
.and()
.formLogin()
.loginProcessingUrl("/login")
.usernameParameter("email")
.passwordParameter("pass")
.successHandler(CustomeAuthenticationSuccessHandler())
.failureHandler(CustomeAuthenticationFailureHandler())
.and()
.exceptionHandling()
.authenticationEntryPoint(CustomeAuthenticationEntryPoint())
.accessDeniedHandler(CustomeAccessDeniedHandler())
.and()
.csrf()
.ignoringAntMatchers("/login")
.csrfTokenRepository(CookieCsrfTokenRepository())
.and()
.cors()
.configurationSource(corsConfigurationSource())
}
override fun configure(auth: AuthenticationManagerBuilder) {
auth.userDetailsService(CustomeUserDetailsService(authenticationService))
.passwordEncoder(passwordEncoder)
}
......
}
上の @Autowired
を付けてDIしているpasswordEncoder変数は書かずに、configure(AuthenticationManagerBUilder)
で、.passwordEncoder(passwordEncoder())
でも結果は同じ。
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
class CustomeUserDetailsService(private val authenticationService: AuthenticationService) : UserDetailsService {
override fun loadUserByUsername(username: String): UserDetails =
when (val account = authenticationService.findAccount(username)) {
null -> throw UsernameNotFoundException("無効なユーザー名です $username")
else -> CustomeUserDetails(account)
}
}
data class CustomeUserDetails(
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
}
}
テストコード
FormLoginのテストは、認証成功時と失敗時のハンドリングを確認することを主な目的としていて、以下の部分になる。
.formLogin()
.loginProcessingUrl("/login")
.usernameParameter("email")
.passwordParameter("pass")
.successHandler(CustomeAuthenticationSuccessHandler()) // HTTP_STATUS: 200(OK) を返す
.failureHandler(CustomeAuthenticationFailureHandler()) // HTTP_STATUS: 401(Unauthorized) を返す
ついでに以下のExceptionHandlingもテストコードに書いているが、特に認可については各Controllerでテストするべきだと思う
.exceptionHandling()
.authenticationEntryPoint(CustomeAuthenticationEntryPoint()) // HTTP_STATUS: 401(Unauthorized) を返す
.accessDeniedHandler(CustomeAccessDeniedHandler()) // HTTP_STATUS: 403(Forbidden) を返す
import com.nhaarman.mockitokotlin2.any
import com.nhaarman.mockitokotlin2.whenever
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
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.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
@WebMvcTest(controllers = [AdminController::class])
internal class FormLoginTest {
@Autowired
private lateinit var mockMvc: MockMvc
@Autowired
private lateinit var passwordEncoder: PasswordEncoder
@MockBean
private lateinit var authenticationService: AuthenticationService
// USERロールではアクセス出来ない仕様なのでテスト
@MockBean
private lateinit var adminService: AdminService
@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() as String)).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() as String)).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() as String)).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(unauthenticated())
.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)
}
}
つまずきポイント
@WebMvcTest
でControllerクラスを指定する
指定しないと全てのControllerが起動するため、それぞれでInjectionしているServiceなどのコンポーネントの@Autowired
が必要になる。
このアプリは勉強用のサンプルなのでServiceクラスの数も少なかったが実践を考えるとよろしく無いのでControllerを指定した方が良い。
今回は exceptionHandlingのテストも兼ねていたので、ADMIN権限だけがアクセスできるControllerを指定することにした。
AuthenticationServiceクラスをモック化する
UserDetailsを取得するためにDBにアクセスすることになる。 TestContainersなどを利用してテストDBを用いることもできるが、より簡易的なテストのためにMockにした。
PasswordEncordを忘れない
前述の通りDBからAccountオブジェクトの取得をMock化したが、パスワードをエンコードしていなかったので、認証エラーと判定されてしまっていた。
DBに接続していればエンコードされた状態で保管されていたんだけど、すっかり忘れてしまって悩んだ。
ちなみに平文状態でテストを実行すると「パスワードがエンコードされていないよ」とWarnログが出るのでそこで気づける。
userとpasswordのパラメータ名を確認する
SpringSecurityのデフォルトは、username
と password
だが、パラメータ名を変えている場合は正しいパラメータ名に対してテストデータをセットする必要がある。
その場合は、以下のようにuser()
およびpassword()
メソッドの引数に ("パラメータ名","入力値")
として渡す。
mockMvc
.perform(
formLogin()
.user("email", email)
.password("pass", "invalid")
)
認証失敗時のエラーハンドリング
登録されていないUserを検索した場合、 authenticationService.findAccount(username)
の戻り値がNullとなるが、呼び出し元のCustomeUserDetailsService.loadUserByUsername()
で、UsernameNotFoundExceptionをThrowしていなかったので、テストは成功するがエラーログが出力されていた。
なので以下のようにエラーハンドリングを行うようにコードを修正した。
*こういった発見ができるのもテストコードを書くことの目的だと再認識できたってお話。
class CustomeUserDetailsService(private val authenticationService: AuthenticationService) : UserDetailsService {
override fun loadUserByUsername(username: String): UserDetails =
when (val account = authenticationService.findAccount(username)) {
null -> throw UsernameNotFoundException("無効なユーザー名です $username")
else -> CustomeUserDetails(account)
}
}
まとめ
テストコードを書くと、SpringSecurityの仕組みも段々と理解できるようになる。