Spring SecurityにJWT（Json Web Token）を統合して、ユーザーのログインと認証を実現する

# 0. 背景

Json Web Token（JWT）は、Jsonに基づく公開仕様[RFC 7519]（https://tools.ietf.org/html/rfc7519）です。
通常はクライアントとサーバーの間に、安全且つ確実にユーザー識別情報を転送するために使用されます。
それに、Token情報には、ユーザー識別情報以外、業務データも転送することができます。

JWTの利点は以下の通りです。

1. サーバー側でユーザー情報を保存する必要はありません。
サーバーが分散してデプロイされている場合、
sessionのようにユーザー情報を各サーバーに同期する必要はなく、
各サーバーはJWT Tokenを解析することでユーザー情報を取得できます。
2. クロスドメインアクセスをサポートします。
3. クライアントはCookieをサポートしていなくても使用できます。 
クライアントがさまざまなデバイスやアプリケーションのアクセスをサポートする必要がある場合は、JWT Token認証を使用した方は互換性が良いです。

# 1. 依存ライブラリを追加する

```kotlin
implementation("org.springframework.boot:spring-boot-starter-security")
implementation("io.jsonwebtoken:jjwt-api:0.11.2")
implementation("io.jsonwebtoken:jjwt-impl:0.11.2")
implementation("io.jsonwebtoken:jjwt-jackson:0.11.2")
```

# 2. 実現方法

## 2.1 JwtTokenProviderクラスをカプセル化して、JWT Tokenの生成と復号化を実現する

```kotlin
package com.example.demo.utils

import io.jsonwebtoken.JwtBuilder
import io.jsonwebtoken.JwtParser
import io.jsonwebtoken.Jwts
import io.jsonwebtoken.SignatureAlgorithm
import io.jsonwebtoken.security.Keys
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.Authentication
import org.springframework.security.core.userdetails.User
import java.time.LocalDateTime
import java.time.ZoneId
import java.util.*

class JwtTokenProvider {
    private val jwtParser: JwtParser
    private val jwtBuilder: JwtBuilder
    init {
        val base64SecretKey = "secretkeysecretkeysecretkeysecretkeysecretkeysecre" +
                "tkeysecretkeysecretkeysecretkeysecretkeysecretkeysecretkey"
        val keyBytes = Base64.getDecoder().decode(base64SecretKey)
        val key = Keys.hmacShaKeyFor(keyBytes)
        jwtParser = Jwts.parserBuilder()
            .setSigningKey(key)
            .build()
        jwtBuilder = Jwts.builder()
            .signWith(key, SignatureAlgorithm.HS256)
    }

    /**
     * JWT Tokenの生成
     * @param authentication ユーザー情報
     * @param expiredAt token有効期限
     * @return token文字列
     */
    fun createToken(authentication: Authentication, expiredAt: LocalDateTime): String {
        val zoneId = ZoneId.systemDefault()
        val zdt = expiredAt.atZone(zoneId)
        val expiredDateAt = Date.from(zdt.toInstant())
        return jwtBuilder
            .claim("username", authentication.name)
            .setSubject(authentication.name)
            .setExpiration(expiredDateAt)
            .compact()
    }
    /**
     * tokenにより、ユーザー情報を解析する
     * @param token ユーザーがリクエストする時に、Headerに持っているtoken
     * @return ユーザー認証情報
     */
    fun getAuthentication(token: String): Authentication {
        val claims = jwtParser.parseClaimsJws(token).body
        val principal = User(claims.subject, "", listOf())
        return UsernamePasswordAuthenticationToken(principal, token, listOf())
    }
}
```

## 2.2 JwtToken解析用のinterceptorを提供する

```kotlin
package com.example.demo.interceptor

import com.example.demo.utils.JwtTokenProvider
import org.slf4j.LoggerFactory
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.web.filter.GenericFilterBean
import javax.servlet.FilterChain
import javax.servlet.ServletRequest
import javax.servlet.ServletResponse
import javax.servlet.http.HttpServletRequest

class JwtTokenFilter(private val jwtTokenProvider: JwtTokenProvider) : GenericFilterBean() {
    private val log = LoggerFactory.getLogger(javaClass)
    override fun doFilter(request: ServletRequest, response: ServletResponse, chain: FilterChain) {
        val servletRequest = request as HttpServletRequest
        val token = resolveToken(servletRequest)
        if (token == null) {
            chain.doFilter(request, response)
            return
        }
        try {
            SecurityContextHolder.getContext().authentication =
                jwtTokenProvider.getAuthentication(token)
        } catch (ex: Exception) {
            if (log.isWarnEnabled) {
                log.warn("parse jwt token error, token: $token", ex)
            }
        }
        chain.doFilter(request, response)
    }

    private fun resolveToken(request: HttpServletRequest): String? {
        return request.getHeader("Authorization")?.let {
            if (it.startsWith("Bearer ")) {
                it.removePrefix("Bearer ")
            } else {
                null
            }
        }
    }

}
```

## 2.3 Spring Securityを配置する

`UsernamePasswordAuthenticationFilter`の前に`JwtTokenFilter`を追加し、認証を必要としないように`/login`を設定する。

```kotlin
package com.example.demo.configuration

import com.example.demo.interceptor.JwtTokenFilter
import com.example.demo.utils.JwtTokenProvider
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter
import org.springframework.security.config.http.SessionCreationPolicy
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter

@Configuration
class SecurityConfig : WebSecurityConfigurerAdapter() {
    override fun configure(http: HttpSecurity) {
        http.csrf().disable()
            .addFilterBefore(JwtTokenFilter(tokenProvider()), UsernamePasswordAuthenticationFilter::class.java)
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
            .authorizeRequests()
            .antMatchers("/login").permitAll()
            .anyRequest().authenticated()
    }

    @Bean
    fun tokenProvider() = JwtTokenProvider()
}
```

## 2.4 二つのインターフェイスを作成する：一つはログインインタフェースであり、一つは認証してからアクセスできるインターフェースである

```kotlin
package com.example.demo.controller

import com.example.demo.service.dto.TestQueryDto
import com.example.demo.utils.JwtTokenProvider
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RestController
import java.time.LocalDateTime

@RestController
class TestController(private val tokenProvider: JwtTokenProvider) {
    @GetMapping("/login")
    fun get(dto: TestQueryDto): Map<String, Any> {
        val authentication = UsernamePasswordAuthenticationToken("user01", "***")
        val token = tokenProvider.createToken(authentication, LocalDateTime.now().plusDays(100))
        return mapOf("name" to "user01", "token" to token)
    }

    @GetMapping("/hello")
    fun getUser(): String {
        val authentication = SecurityContextHolder.getContext().authentication
        return authentication.name + " hello!"
    }
}
```

# 3. 評価

1. client側はtokenなしでアクセスする時、403エラーが表示される。

    ```shell
    curl "http://localhost:8080/hello"
    # {"timestamp":"2021-04-01T05:22:28.167+00:00","status":403,"error":"Forbidden","message":"","path":"/hello"}
    ```

2. 先に `login`を呼び出してログインし、その後`login`によって返された `token`をHeaderに入れ、再び`/hello`を呼び出すと、データは正常に返される。

    ```shell
    curl "http://localhost:8080/login"
    # {"name":"user01","token":"eyJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6InVzZXIwMSIsInN1YiI6InVzZXIwMSIsImV4cCI6MTYyNTg5NDcwNH0.yxdtwaXJ9i7F-JZeFWijWLR0CAFTDUkrCqcfmv65zWs"}
    ```

    ```shell
    curl "http://localhost:8080/hello" -H "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6InVzZXIwMSIsInN1YiI6InVzZXIwMSIsImV4cCI6MTYyNTg5NDcwNH0.yxdtwaXJ9i7F-JZeFWijWLR0CAFTDUkrCqcfmv65zWs"
    # user01 hello!
    ```