【Spring Boot + Kotlin】 REST APIサーバとして認証機能を実装する
最近はSpring Bootを使った個人用のWebアプリケーションを作っているが、認証機能で思った以上に苦戦中。フロントエンドはSpring Bootのtemplateを使っていないため、単純なREST APIとしてSpring Bootを使う場合どう認証をすればいいのか理解が大変だった。 今回はユーザ名とパスワードによる基本的なログイン処理を作る。JWTなどのトークンは導入せず、サーバ側でセッション管理できるようにする。
まずspring-securityを入れる。
dependencies {
implementation("org.springframework.boot:spring-boot-starter-security")
}
次にConfigurationを設定する。Spring Securityでは、実際の認証処理(ユーザ名・パスワードの照合)は AuthenticationProvider
で行われる。ユーザ名・パスワード認証では DaOAuthenticationProvider
で行われ、その中ではサーバに保持されているユーザの情報を 受け取るための UserDetailService
と、入力されたパスワードをEncodeする PasswordEncoder
が使われる。これらを定義して渡してやると認証ができるようになる。
@Configuration @EnableWebSecurity class WebSecurityConfig { @Bean fun filterChain(http: HttpSecurity, authenticationManager: AuthenticationManager): SecurityFilterChain { http { // /api/loginには認証なしでアクセス可、それ以外は要認証 authorizeHttpRequests { authorize("/api/login", permitAll) authorize(anyRequest, authenticated) HttpMethod.PUT HttpMethod.DELETE } csrf { disable() } // APIサーバなのでdisableしておく } return http.build() } @Bean fun passwordEncoder(): PasswordEncoder { return BCryptPasswordEncoder(8) } @Bean fun authenticationManager( userDetailsService: UserDetailsService, passwordEncoder: PasswordEncoder, ): AuthenticationManager { val authenticationProvider = DaoAuthenticationProvider() authenticationProvider.setUserDetailsService(userDetailsService) authenticationProvider.setPasswordEncoder(passwordEncoder) return ProviderManager(authenticationProvider) } }
UserDetailService
に実装を加える。今回はデータベースの users
テーブルから、ユーザ名を使ってユーザ情報を取得できるようにする。
@Service @Transactional class UserDetailServiceImpl( private val userRepository: UserRepository, ) : UserDetailsService { override fun loadUserByUsername(username: String): UserDetails { val loginUser = userRepository.findByUsername(username) ?: throw UsernameNotFoundException("User not found") return LoginUserDetails(loginUser) } }
次にDBからユーザ情報を取得する部分を実装する。以下の資料がとても参考になった。
最新の6.0で学ぶ!初めてのひとのためのSpring Security | ドクセル
UserRepositoryは以下。今回はO/R mapperとしてExposedを使っている。 UserTable
の定義はテーブルのスキーマ定義があればほぼそのまま書けるので省略。
@Repository class UserRepository { fun findByUsername(username: String): LoginUser? { return UserTable.select { UserTable.username eq username } .map { LoginUser(it[UserTable.username], it[UserTable.password]) } .firstOrNull() } }
Repositoryで返している LoginUser
というのは自作の型。認証のためには UserDetails
を返さなければいけないが、フィールドが多くてRepositoryが知るべき情報ではないので、Repositoryの段階では簡易的な LoginUser
型を返し、それをもとにUserDetails
の実装である LoginUserDetails
を生成する形にする。
data class LoginUser( val username: String, val password: String, ) class LoginUserDetails(private val loginUser: LoginUser) : UserDetails { override fun getAuthorities(): MutableCollection<out GrantedAuthority> { return mutableListOf() } override fun getPassword(): String { return loginUser.password } override fun getUsername(): String { return loginUser.username } override fun isAccountNonExpired(): Boolean { return true } override fun isAccountNonLocked(): Boolean { return true } override fun isCredentialsNonExpired(): Boolean { return true } override fun isEnabled(): Boolean { return true } }
最後にログイン時に使うControllerを作る。Springのドキュメントの例を参考にする。
注意点として、今回のようにREST APIサーバとして認証を行うときは、その後にセッションを保持する処理は自分で明示的に書かなければいけない。 Spring Bootからテンプレートを使ってログインフォームを配信する場合は自動で行ってくれるらしいが、上のドキュメントには以下のような注意書きがある。
この例では、必要に応じて、認証されたユーザーを SecurityContextRepository に保存するのはユーザーの責任です。例: HttpSession を使用してリクエスト間で SecurityContext を永続化する場合は、HttpSessionSecurityContextRepository を使用できます。
セッション保持の処理はこちらのドキュメントを参考にする。
authenticationManager.authenticate()
が終わった後の処理は、セッション保持のためのもの。
data class LoginRequestParams( val username: String, val password: String, ) @RestController class LoginController(private val authenticationManager: AuthenticationManager) { private val securityContextHolderStrategy = SecurityContextHolder.getContextHolderStrategy() private val securityContextRepository: SecurityContextRepository = HttpSessionSecurityContextRepository() @PostMapping("/api/login") fun login( @RequestBody loginRequestParams: LoginRequestParams, request: HttpServletRequest, response: HttpServletResponse, ) { val authenticationToken = UsernamePasswordAuthenticationToken(loginRequestParams.username, loginRequestParams.password) val authentication = authenticationManager.authenticate(authenticationToken) val securityContext = securityContextHolderStrategy.createEmptyContext() securityContext.authentication = authentication securityContextHolderStrategy.context = securityContext securityContextRepository.saveContext(securityContext, request, response) } }
これでログイン認証はできたので、APIでアクセスを試してみる。新規登録導線は作っていないので、DBに手動でユーザ名とパスワード ( PasswordEncoder
で指定したのと同じ形式でハッシュ化したもの) を入れておく。
curl -X POST -H "Content-Type: application/json" -d '{"username": "Udomomo", "password": "<password>"}' http://localhost:8080/api/login -i -c cookie.txt HTTP/1.1 200 Set-Cookie: JSESSIONID=<cookie value>; Path=/; HttpOnly X-Content-Type-Options: nosniff X-XSS-Protection: 0 Cache-Control: no-cache, no-store, max-age=0, must-revalidate Pragma: no-cache Expires: 0 X-Frame-Options: DENY Content-Length: 0 Date: Sat, 25 Nov 2023 10:45:44 GMT
無事に200が返ってきて、 JSESSIONID
がcookieにセットされる。