😽

見ないようにしていたSpring Securityの認証実装と向き合った話(MVC)

2022/08/10に公開

最近のSpringを使った実装は大体フロントから何かしらで呼ばれる想定のAPI実装が多いけど認証の実装をちゃんとやったことがなかったので色々調べた。
Spring SecurityはSecurityConfigを作って色々設定して独自でUserDetails実装するくらいの知識しかなかったのと以下のような理由で迷宮入りする。

  • Spring Security5.7で書き方が結構変わってる。
  • Springで全部書いてる想定のフォーム認証の方法がやっぱり多い。古めの記事は大体そう
  • SPAとかのAPIとしてのSpring Securityの認証記事が少ない。
  • Spring Securityが基本カスタムして実装するようになってて、自由度が割と高く、書き方が同じものが意外とない。(みんな微妙に違う)

Spring Securityが理由でSpringが嫌いにならないように実装方法とポイントを書いていきます。
(SpringSecurityのアーキテクチャを完全に理解したわけではないので間違ってるところがあればマサカリくださいー)

実装したコード
https://github.com/JY8752/auth-kotlin-spring

対象読者

  • Spring bootある程度わかる人
  • Spring Securityの認証実装をなんとなく理解してるくらいの人

Spring Security5.7について

以下の記事で雰囲気は掴めるはず
https://qiita.com/suke_masa/items/908805dd45df08ba28d8

Session認証

完成イメージ

login

curl -X POST -H "Content-Type: application/json" -d '{"email": "test@test.com", "password": "password"}' localhost:8080/login -i -c cookie.txt

HTTP/1.1 200 
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Set-Cookie: SESSION=ZDE2NTkxNzQtYzk3NC00MDcxLTg4ZWQtNTFmYjg5NWRlOTZh; Path=/; HttpOnly; SameSite=Lax
Content-Length: 0
Date: Tue, 09 Aug 2022 15:19:59 GMT

hello

curl -i -b cookie.txt localhost:8080/hello                                                                                           

HTTP/1.1 200 
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Type: text/plain;charset=UTF-8
Content-Length: 5
Date: Tue, 09 Aug 2022 15:22:26 GMT

Hello%    

実装

認証の処理はUserDetailsServiceをカスタムするか、UsernamePasswordAuthenticationFilterとAuthenticationProviderを実装してするかが一番見かける気がする。
今回のセッション認証はAuthenticationProviderをカスタムして認証を行う。
認証したユーザー情報はSecurityContextHolderの中のAuthentication内に保持されてるのでそこから認証ユーザー情報を取得したりもする。
やってることはざっくり以下のような感じ。

  • リクエストで送られて来たパラメーターをUsernamePasswordAuthenticationFilterを継承した独自クラスでUsernamePasswordAuthenticationToken作成する。
  • authenticationManager#authenticationを呼び出す。
  • AuthenticationProviderを継承した独自クラスでユーザー情報を取得して、UsernamePasswordAuthenticationTokenを返す。

詳しく知りたい人は以下の記事が詳しく実装追っていてまじ神
https://volkruss.com/?p=2727

認証処理

Authentication.kt
package com.example.mvcsessiondemo.config

import com.example.mvcsessiondemo.AuthenticationRequest
import com.fasterxml.jackson.databind.ObjectMapper
import org.springframework.security.authentication.AuthenticationManager
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.Authentication
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter
import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse

class AuthenticationFilter(
    authenticationManager: AuthenticationManager,
    private val objectMapper: ObjectMapper,
) : UsernamePasswordAuthenticationFilter(authenticationManager) {
    override fun attemptAuthentication(request: HttpServletRequest, response: HttpServletResponse?): Authentication {
        //リクエストパラメーターをデコードして認証情報をセットする
        val principal = this.objectMapper.readValue(request.inputStream, AuthenticationRequest::class.java)
        val authRequest = UsernamePasswordAuthenticationToken(principal.email, principal.password)
        this.setDetails(request, authRequest)
        return this.authenticationManager.authenticate((authRequest))
    }
}

data class AuthenticationRequest(
    val email: String,
    val password: String
)
AuthenticationProvider
package com.example.mvcsessiondemo.config

import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.Authentication
import org.springframework.stereotype.Component

/**
 * 実際の認証処理を行うクラス
 */
@Component
class AuthenticationProvider : org.springframework.security.authentication.AuthenticationProvider {
    override fun authenticate(authentication: Authentication): Authentication {
        //前段のAuthenticationFilterで設定した認証情報を取り出す
        val email = authentication.principal as String
        val password = authentication.credentials as String

        /** ユーザー情報の検証 */

        val user = HelloUserDetails(email, password)
        return UsernamePasswordAuthenticationToken(user, null, emptyList())
    }

    override fun supports(authentication: Class<*>?): Boolean {
        return UsernamePasswordAuthenticationToken::class.java.isAssignableFrom(authentication)
    }
}

SecurityConfigの作成

AuthenticationManagerをBean登録して置かないと動かないのが少しはまった。

SecurityConfig.kt
package com.example.mvcsessiondemo.config

import com.fasterxml.jackson.databind.ObjectMapper
import org.springframework.context.annotation.Bean
import org.springframework.http.HttpStatus
import org.springframework.security.authentication.AuthenticationManager
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration
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.web.SecurityFilterChain
import org.springframework.security.web.util.matcher.AntPathRequestMatcher


@EnableWebSecurity
class SecurityConfig(
    private val authenticationProvider: AuthenticationProvider,
    private val objectMapper: ObjectMapper
) {
    @Bean
    fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
        http.csrf().disable()

        http.authorizeRequests()
            .antMatchers("/login").permitAll()
            .anyRequest().authenticated()

        return http.build()
    }

    @Bean
    fun authenticationFilter(
        authenticationManager: AuthenticationManager
    ): AuthenticationFilter {
        val authFilter = AuthenticationFilter(authenticationManager, this.objectMapper).also {
            it.setRequiresAuthenticationRequestMatcher(AntPathRequestMatcher("/login", "POST"))
            it.setAuthenticationSuccessHandler { _, response, _ -> response.status = HttpStatus.OK.value() }
        }
        return authFilter
    }

    @Bean
    fun authenticationManager(authenticationConfiguration: AuthenticationConfiguration): AuthenticationManager {
        return authenticationConfiguration.authenticationManager
    }

    @Bean
    fun passwordEncoder() = BCryptPasswordEncoder()
}

セッションの設定

セッションの保存先をRedisに変更する。

HttpSessionConfig.kt
package com.example.mvcsessiondemo.config

import org.springframework.context.annotation.Bean
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory
import org.springframework.session.data.redis.config.annotation.web.http.EnableRedisHttpSession

@EnableRedisHttpSession
class HttpSessionConfig {
    @Bean
    fun connectionFactory() = LettuceConnectionFactory()
}

依存関係に以下の追加が必要

build.gradle.kts
	//Redis
	implementation("org.springframework.boot:spring-boot-starter-data-redis")
	implementation("org.springframework.session:spring-session-data-redis")

JWTを使った認証

APIとしてはセッションよりもトークンによる認証の方が使われるのかな?
JWTトークンを生成して認証処理を実装してみる。

完成イメージ

login

curl -X POST -H "Content-Type: application/json" -d '{"username": "user", "password": "password"}' localhost:8080/login -i                                                                                                          

HTTP/1.1 200 
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ1c2VyIiwiaWF0IjoxNjYwMTE0MjYyLCJleHAiOjE2NjAxNDMwNjJ9.tF0Y0leK749jOsEPKRtrcJmHEkKgO_oYtniY6fmqo44sGp3Sg6Po0G5ERi-5g88RrFpuFz0idFreNnq3vox1aA
X-Content-Type-Options: nosniff

hello

curl localhost:8080/hello -v -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ1c2VyIiwiaWF0IjoxNjYwMTE0MjYyLCJleHAiOjE2NjAxNDMwNjJ9.tF0Y0leK749jOsEPKRtrcJmHEkKgO_oYtniY6fmqo44sGp3Sg6Po0G5ERi-5g88RrFpuFz0idFreNnq3vox1aA"

*   Trying ::1:8080...
* Connected to localhost (::1) port 8080 (#0)
> GET /hello HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.77.0
> Accept: */*
> Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ1c2VyIiwiaWF0IjoxNjYwMTE0MjYyLCJleHAiOjE2NjAxNDMwNjJ9.tF0Y0leK749jOsEPKRtrcJmHEkKgO_oYtniY6fmqo44sGp3Sg6Po0G5ERi-5g88RrFpuFz0idFreNnq3vox1aA
> 
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 
< Vary: Origin
< Vary: Access-Control-Request-Method
< Vary: Access-Control-Request-Headers
< Set-Cookie: JSESSIONID=B1AB9DDB852B51FB63A2F7F247765DEA; Path=/; HttpOnly
< X-Content-Type-Options: nosniff
< X-XSS-Protection: 1; mode=block
< Cache-Control: no-cache, no-store, max-age=0, must-revalidate
< Pragma: no-cache
< Expires: 0
< X-Frame-Options: DENY
< Content-Type: text/plain;charset=UTF-8
< Content-Length: 5
< Date: Wed, 10 Aug 2022 07:41:00 GMT
< 
* Connection #0 to host localhost left intact
Hello%   

実装

こちらの記事が大変参考になりました!
https://qiita.com/nyasba/items/f9b1b6be5540743f8bac

  • セッション認証の時と同じUsernamePasswordAuthenticationFilterを継承した独自クラスを作成し、UsernamePasswordAuthenticationTokenを作成するのは同じ。
  • 認証が成功したときの処理にレスポンスヘッダーに生成したJWTを設定する。
  • 認証以外の全てのリクエストにおいてJWT検証をするようにもう一つフィルターをカスタムし追加する。

JWTトークンを作成するのに以下追加。

build.gradle.kts
    //jjwt
    implementation("io.jsonwebtoken:jjwt-api:0.11.5")
    implementation("io.jsonwebtoken:jjwt-impl:0.11.5")
    implementation("io.jsonwebtoken:jjwt-jackson:0.11.5")

トークン生成したりトークンの有効期限検証したりとかあるので先にユーティリティークラスを作成。

JWTUtils.kt
package com.example.mvcjwtdemo.utils

import io.jsonwebtoken.Claims
import io.jsonwebtoken.Jwts
import io.jsonwebtoken.security.Keys
import java.util.*

class JWTUtils {
    companion object {
        private const val secret = "ThisIsSecretForJWTHS512SignatureAlgorithmThatMUSTHave64ByteLength"
        private const val expirationTime = "28800" //sec

        private val key by lazy { Keys.hmacShaKeyFor(secret.toByteArray()) }

        /**
         * tokenからClaim(キーと値のペア)を取得する
         */
        fun getAllClaimsFromToken(token: String): Claims? {
            return Jwts.parserBuilder()
                .setSigningKey(key)
                .build()
                .parseClaimsJws(token)
                .body
        }

        /**
         * トークンからユーザー名を取得する
         */
        fun getUsernameFromToken(token: String): String {
            return getAllClaimsFromToken(token)?.subject ?: ""
        }

        /**
         * トークンから有効期限を取得する
         */
        fun getExpirationDateFromToken(token: String): Date? {
            return getAllClaimsFromToken(token)?.expiration
        }

        /**
         * トークンを作成する
         */
        fun generateToken(username: String): String {
            val expirationTimeLong = expirationTime.toLong()
            val createdDate = Date()
            val expirationDate = Date(createdDate.time + expirationTimeLong * 1000)

            return Jwts.builder()
                .setSubject(username)
                .setIssuedAt(createdDate)
                .setExpiration(expirationDate)
                .signWith(key)
                .compact()
        }

        /**
         * トークンが有効か検証する
         */
        fun validateToken(token: String) = !isTokenExpired(token)

        private fun isTokenExpired(token: String): Boolean {
            val expiration = getExpirationDateFromToken(token) ?: run {
                return true
            }
            return expiration.before(Date())
        }
    }
}

認証フィルター

AuthenticationFilter.kt
package com.example.mvcjwtdemo.security

import com.example.mvcjwtdemo.utils.JWTUtils
import com.fasterxml.jackson.databind.ObjectMapper
import org.springframework.http.HttpHeaders
import org.springframework.security.authentication.AuthenticationManager
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.Authentication
import org.springframework.security.core.userdetails.UserDetails
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter
import javax.servlet.FilterChain
import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse

/**
 * 実際に認証処理をするクラス
 */
class Authenticationfilter(
    authenticationManager: AuthenticationManager,
    private val objectMapper: ObjectMapper
) : UsernamePasswordAuthenticationFilter() {
    init {
        this.authenticationManager = authenticationManager
    }

    override fun attemptAuthentication(request: HttpServletRequest, response: HttpServletResponse?): Authentication {
        val authRequest = this.objectMapper.readValue(request.inputStream, AuthRequest::class.java)
        return this.authenticationManager.authenticate(
            UsernamePasswordAuthenticationToken(
                authRequest.username,
                authRequest.password,
                emptyList()
            )
        )
    }

    /**
     * 認証が成功したときの処理
     */
    override fun successfulAuthentication(
        request: HttpServletRequest,
        response: HttpServletResponse,
        chain: FilterChain,
        authResult: Authentication
    ) {
        val user = authResult.principal as UserDetails
        val token = JWTUtils.generateToken(user.username)

        response.addHeader(HttpHeaders.AUTHORIZATION, "Bearer $token")
    }
}

data class AuthRequest(
    val username: String,
    val password: String
)

認可フィルター

AuthorizationFilter.kt
package com.example.mvcjwtdemo.security

import com.example.mvcjwtdemo.utils.JWTUtils
import org.springframework.http.HttpHeaders
import org.springframework.security.authentication.AuthenticationManager
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter
import javax.servlet.FilterChain
import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse

/**
 * 認可の処理
 */
class AuthorizationFilter(authenticationManager: AuthenticationManager) :
    BasicAuthenticationFilter(authenticationManager) {
    override fun doFilterInternal(request: HttpServletRequest, response: HttpServletResponse, chain: FilterChain) {
        val token = request.getHeader(HttpHeaders.AUTHORIZATION).let {
            if (it == null || !it.startsWith("Bearer ")) {
                chain.doFilter(request, response)
                return
            }
            it.substring(7)
        }

        if (!JWTUtils.validateToken(token)) {
            chain.doFilter(request, response)
            return
        }

        val username = JWTUtils.getUsernameFromToken(token)
        //principalをuserDetailsにすればSecurityContextHolderから取得できるかな??
        val authentication =
            UsernamePasswordAuthenticationToken(HelloUserDetails(username, "password"), null, emptyList())
        SecurityContextHolder.getContext().authentication = authentication

        chain.doFilter(request, response)
    }
}

SecurityConfig

SecurityConfig.kt
@EnableWebSecurity
class SecurityConfig {
    @Bean
    fun securityFilterChain(
        http: HttpSecurity,
        authenticationManager: AuthenticationManager,
        objectMapper: ObjectMapper
    ): SecurityFilterChain {
        http.cors()

        http.authorizeRequests()
            .antMatchers("/login").permitAll()
            .anyRequest().authenticated()

        http.csrf().disable()

        http.addFilter(AuthorizationFilter(authenticationManager))
        http.addFilter(Authenticationfilter(authenticationManager, objectMapper))

        return http.build()
    }

    @Bean
    fun passwordEncoder() = BCryptPasswordEncoder()

    @Bean
    fun authenticationManager(authenticationConfiguration: AuthenticationConfiguration): AuthenticationManager {
        return authenticationConfiguration.authenticationManager
    }
}

まとめ

SpringSecurityの構成クラスは多いうえに継承関係とかあって処理の流れが頭に入っていないとまじでわかんなくなるけど、大まかにはやってることは一緒。(リクエストから認証情報取り出して認証。認証する場所や方法が微妙に変わる、認証した情報は取り出せる。)
今回の実装でざっくり認証の流れをつかめたので次はOAuthの認証とかも触って見たいけどもうお腹いっぱいなのでまた次回。
一応、WebFlux版でも実装したのでその記事も書く予定。

SpringSecurityは怖くないからJava(kotlin)を嫌いにならないでね!!

GitHubで編集を提案

Discussion