【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のドキュメントの例を参考にする。

spring.pleiades.io

注意点として、今回のようにREST APIサーバとして認証を行うときは、その後にセッションを保持する処理は自分で明示的に書かなければいけない。 Spring Bootからテンプレートを使ってログインフォームを配信する場合は自動で行ってくれるらしいが、上のドキュメントには以下のような注意書きがある。

この例では、必要に応じて、認証されたユーザーを SecurityContextRepository に保存するのはユーザーの責任です。例: HttpSession を使用してリクエスト間で SecurityContext を永続化する場合は、HttpSessionSecurityContextRepository を使用できます。

セッション保持の処理はこちらのドキュメントを参考にする。

spring.pleiades.io

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が返ってきて、 JSESSIONIDcookieにセットされる。