0. 背景
Json Web Token(JWT)は、Jsonに基づく公開仕様[RFC 7519](https://tools.ietf.org/html/rfc7519)です。
通常はクライアントとサーバーの間に、安全且つ確実にユーザー識別情報を転送するために使用されます。
それに、Token情報には、ユーザー識別情報以外、業務データも転送することができます。
JWTの利点は以下の通りです。
- サーバー側でユーザー情報を保存する必要はありません。
サーバーが分散してデプロイされている場合、
sessionのようにユーザー情報を各サーバーに同期する必要はなく、
各サーバーはJWT Tokenを解析することでユーザー情報を取得できます。 - クロスドメインアクセスをサポートします。
- クライアントはCookieをサポートしていなくても使用できます。
クライアントがさまざまなデバイスやアプリケーションのアクセスをサポートする必要がある場合は、JWT Token認証を使用した方は互換性が良いです。
1. 依存ライブラリを追加する
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の生成と復号化を実現する
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を提供する
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
を設定する。
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 二つのインターフェイスを作成する:一つはログインインタフェースであり、一つは認証してからアクセスできるインターフェースである
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. 評価
-
client側はtokenなしでアクセスする時、403エラーが表示される。
curl "http://localhost:8080/hello" # {"timestamp":"2021-04-01T05:22:28.167+00:00","status":403,"error":"Forbidden","message":"","path":"/hello"}
-
先に
login
を呼び出してログインし、その後login
によって返されたtoken
をHeaderに入れ、再び/hello
を呼び出すと、データは正常に返される。curl "http://localhost:8080/login" # {"name":"user01","token":"eyJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6InVzZXIwMSIsInN1YiI6InVzZXIwMSIsImV4cCI6MTYyNTg5NDcwNH0.yxdtwaXJ9i7F-JZeFWijWLR0CAFTDUkrCqcfmv65zWs"}
curl "http://localhost:8080/hello" -H "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6InVzZXIwMSIsInN1YiI6InVzZXIwMSIsImV4cCI6MTYyNTg5NDcwNH0.yxdtwaXJ9i7F-JZeFWijWLR0CAFTDUkrCqcfmv65zWs" # user01 hello!