本章將介紹在 Spring Security 下,如何實作 JWT-Token-Based 驗證的方法。我們將解釋如何從 JPA 讀取 Username/Password 來做登入驗證,如何產生 JWT Token,以及如何驗證 JWT Token。最後,展示如何在 Swagger 中整合 Token 驗證。
Table of Contents
JWT-Token-Based Authentication
近幾年來,前端不再是只有網頁,還包含了手機(Android/iOS)。在早期,網頁前端的程式碼都是後端輸出的,如 PHP、ASP、JSP。在這些架構下,前後端是利用 Cookie 來紀錄 Session ID。而這 Session ID 也就會被用來檢驗是否已經登入,這樣的流程稱為 Form Login。Spring Security 當然有支援 Form Login 驗證,可以參考以下這篇文章。
然而,Form Login 這樣的驗證流程,對於手機端來說並不好實作,原因是 Cookie。手機端不使用 Cookie,也不需要後端輸出程式碼,只要資料。因此 RESl APIs 對手機端是相當友好的串接方式。在近期,前端也發展出 Single Page Application (SPA) 架構。SPA 可以使用 Cookie,但不需要後端輸出程式碼,只要資料。所以,SPA 和 RESTf APIs 也一起運作的相當良好。
為了要滿足手機端的需求,後端必須要提供一種不使用 Cookie 的驗證方式。其中一種就是基於 Token 的驗證方式。簡單來說,就是將放在 Cookie 的東西,改放在 HTTP Request Header 裡。Spring Security 提供 Basic Authentication 和 Digest Authentication。這兩者的流程和 Form Login 一樣都是 Username/Password Authentication,但是是基於 Token 的驗證方式。而本章要介紹的 JWT-Token-Based Authentication 也是 Username/Password Authentication,但是使用 JSON Web Token (JWT) 作為 Token 的格式。
JWT-Token-Based Authentication 流程
在開始動手寫程式之前,先概略地了解一下,我們要實作的流程,如下圖所示:

若對圖中的 FilterChain、SecurityFilterChain、ExceptionTranslatorFilter、FilterSecurityInterceptor 不熟悉的話,請先閱讀下面的文章,不然無法理解此圖。
想要在 Spring Security 中實現我們的流程,構想是在 SecurityFilterChain 中插入一個 JwtFilter。它會從 HTTP Request 的 Header 中,取得 JWT Token。然後,驗證這個 JWT Token 是不是合法的 JWT Token。最後,將這 JWT Token 轉成 Authentication 物件,並設定到 SecurityContextHolder,這就表示這個 HTTP Request 或 Session 是有驗證過的。
若是,在 Header 中沒有 JWT Token,或是 JWT Token 是不合法的,那在 FilterSecurityInterceptor 裡就會丟出 Exception,ExceptionTranslationFilter 就會來處理這個 Exception。在原本的流程中,authenticationEntryPoint.commence() 和 accessDeniedHandler.handle() 都會做重新導向的處理。但,我們只希望它們回傳錯誤訊息就好,所以必須要重新實作這兩個部分。
若進來的是 POST /login 的話,它會通過 JwtFilter,也會通過 ExceptionTranslatorFilter 和 FilterSecurityInterceptor,因為我們會設定 /login 不需要權限。所以,最終會到 LoginController.login()。
LoginController.login() 會驗證使用者,然後回傳一個 JWT Token。之後,前端就可以用這個 JWT Token 來存取後端的 API。
流程不算複雜,接下來,我們來看實作的部分。
JWT-Token-Based Authentication 專案
在這專案中,我們從 JPA 讀出使用者資料來做驗證。在 JWT 的部分,不會做深入的解釋。
建立專案
建立一個 Spring Boot 專案,並引入圖中四個 Dependencies。

如果不知道要怎麼建立專案,或不了解上圖的話,可以先參考下面這篇文章。
建立資料庫
新增 Member Entity,包含 username、password、和 authorities。
package com.waynestalk.jwtexample.model
import javax.persistence.*
@Entity
data class Member(
@Column(unique = true) val username: String,
val password: String,
val name: String,
@ElementCollection val authorities: Collection<String>,
@Id @GeneratedValue var id: Long? = null
)新增 MemberRepository 來讀取資料庫。
package com.waynestalk.jwtexample.repository
import com.waynestalk.jwtexample.model.Member
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query
interface MemberRepository : JpaRepository<Member, Long> {
@Query("SELECT m FROM Member m JOIN FETCH m.authorities WHERE m.username = (:username)")
fun findByUsername(username: String): Member?
}為了方便稍後的測試,我們這邊先預先創建幾個 Member 到資料庫裡。注意只有 Monika 才有 ADMIN 權限。
package com.waynestalk.jwtexample.config
import com.waynestalk.jwtexample.repository.MemberRepository
import com.waynestalk.jwtexample.model.Member
import org.springframework.boot.ApplicationRunner
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.security.crypto.password.PasswordEncoder
@Configuration
class MemberConfiguration {
@Bean
fun initMembers(memberRepository: MemberRepository, passwordEncoder: PasswordEncoder) = ApplicationRunner {
memberRepository.saveAll(listOf(
Member("monika", passwordEncoder.encode("123456"), "Monika", listOf("ROLE_ADMIN", "ROLE_USER")),
Member("jack", passwordEncoder.encode("123456"), "Jack", listOf("ROLE_USER")),
Member("peter", "123456", "Peter", listOf("ROLE_USER"))
))
}
}建立 JwtTokenProvider
新增 JwtTokenProvider,它提供三個方法 – 產生 JWT Token、驗證 JWT Token、以及轉換 JWT Token 成 Authentication 物件。
package com.waynestalk.jwtexample.auth
import io.jsonwebtoken.Jwts
import io.jsonwebtoken.SignatureAlgorithm
import io.jsonwebtoken.io.Decoders
import io.jsonwebtoken.security.Keys
import org.slf4j.LoggerFactory
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.Authentication
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.core.userdetails.User
import org.springframework.stereotype.Component
import java.util.*
@Component
class JwtTokenProvider {
companion object {
const val claimAuthorities = "authorities"
}
private val logger = LoggerFactory.getLogger(this::class.java)
private val secret = "qsbWaaBHBN/I7FYOrev4yQFJm60sgZkWIEDlGtsRl7El/k+DbUmg8nmWiVvEfhZ91Y67Sc6Ifobi05b/XDwBy4kXUcKTitNqocy7rQ9Z3kMipYjbL3WZUJU2luigIRxhTVNw8FXdT5q56VfY0LcQv3mEp6iFm1JG43WyvGFV3hCkhLPBJV0TWnEi69CfqbUMAIjmymhGjcbqEK8Wt10bbfxkM5uar3tpyqzp3Q=="
private val key = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secret))
fun generate(authentication: Authentication): String {
val authorities = authentication.authorities?.joinToString { it.authority } ?: ""
val expiration = Date(System.currentTimeMillis() + (60 * 60 * 1000))
return Jwts.builder()
.setSubject(authentication.name)
.claim(claimAuthorities, authorities)
.signWith(key, SignatureAlgorithm.HS512)
.setExpiration(expiration)
.compact()
}
fun toAuthentication(token: String): Authentication {
val jwtParser = Jwts.parserBuilder().setSigningKey(key).build()
val claims = jwtParser.parseClaimsJws(token).body
val authorities = claims[claimAuthorities].toString().split(",").map { SimpleGrantedAuthority(it) }
val user = User(claims.subject, "", authorities)
return UsernamePasswordAuthenticationToken(user, token, authorities)
}
fun validate(token: String): Boolean {
val jwtParser = Jwts.parserBuilder().setSigningKey(key).build()
try {
jwtParser.parse(token)
return true
} catch (e: Exception) {
logger.error(e.message)
}
return false
}
}程式碼中的 secret 可以用下面的指令來產生:
% openssl rand -base64 172 qsbWaaBHBN/I7FYOrev4yQFJm60sgZkWIEDlGtsRl7El/k+DbUmg8nmWiVvEfhZ91Y67Sc6Ifobi05b/XDwBy4kXUcKTitNqocy7rQ9Z3kMipYjbL3WZUJU2luigIRxhTVNw8FXdT5q56VfY0LcQv3mEp6iFm1JG43WyvGFV3hCkhLPBJV0TWnEi69CfqbUMAIjmymhGjcbqEK8Wt10bbfxkM5uar3tpyqzp3Q==
在 generate(),我們將到期時間放入 JWT Token 裡,讓 Token 有時效性。
在 toAuthentication(),建立 UsernamePasswordAuthentication 時,其 Credentials 的部分就放入 Token 本身,不需要再從資料庫中取出密碼放入。
仔細看一下,generate() 並沒有儲存產生出來的 JWT Token。那 validate() 要怎麼驗證呢?這就是 JWT 強大的地方。簡單來說,整個 JWT Token 包含了明文和明文的加密字串。只有後端擁有加密的 Secret,所以將 JWT Token 的明文部分加密後,和 JWT Token 的加密字串部分,相比較就可以了。此外,明文裡還包含著到期日期(Expiration),過期的 Token 當然也會驗證不通過。
建立 JwtFilter
新增 JwtFilter,程式碼如下:
package com.waynestalk.jwtexample.auth
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.util.StringUtils
import org.springframework.web.filter.GenericFilterBean
import javax.servlet.FilterChain
import javax.servlet.ServletRequest
import javax.servlet.ServletResponse
import javax.servlet.http.HttpServletRequest
class JwtFilter(private val jwtTokenProvider: JwtTokenProvider) : GenericFilterBean() {
companion object {
const val authenticationHeader = "Authorization"
const val authenticationScheme = "Bearer"
}
override fun doFilter(request: ServletRequest, response: ServletResponse, chain: FilterChain) {
val token = extractToken(request as HttpServletRequest)
if (StringUtils.hasText(token) && jwtTokenProvider.validate(token)) {
SecurityContextHolder.getContext().authentication = jwtTokenProvider.toAuthentication(token)
}
chain.doFilter(request, response)
}
private fun extractToken(request: HttpServletRequest): String {
val bearerToken = request.getHeader(authenticationHeader)
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("$authenticationScheme ")) {
return bearerToken.substring(authenticationScheme.length + 1)
}
return ""
}
}JwtFilter 嘗試從 Header 中取得 Authentication 的值,其格式如下:
Authentication: Bearer <jwt-token>
取得 JWT Token 後,再用 JwtTokenProvider.validate() 來驗證。通過的話,再用 JwtTokenProvider.toAuthentication() 轉成 Authentication 物件,並設定到 SecurityContextHolder。這樣,這個 HTTP Request 就是有驗證過的。
覆寫 ExceptionTranslationFilter 的錯誤處理
當前端還沒有登入就要存取後端資源時,就會到 startAuthentication() 流程。這流程最後就是呼叫 AuthenticationEntryPoint.commence()。顧名思義,AuthenticationEntryPoint 是提供前端驗證的入口。Spring Security 預設的 AuthenticationEntryPoint 是重新導向到登入頁面。而我們這邊只想要告知前端,此 HTTP Request 是沒有驗證過的,這裡就沒有 EntryPoint 的意思了。所以新增 JwtAuthenticationEntryPoint,程式碼如下:
package com.waynestalk.jwtexample.auth
import org.springframework.security.core.AuthenticationException
import org.springframework.security.web.AuthenticationEntryPoint
import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse
class JwtAuthenticationEntryPoint : AuthenticationEntryPoint {
override fun commence(request: HttpServletRequest, response: HttpServletResponse, authException: AuthenticationException) {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, authException.message)
}
}當前端已經登入,但是卻存取沒有權限的資源。如,Jack 只有 USER 權限,但是想要存取要有 ADMIN 權限的資源時,就會到 AccessDeniedHandler 裡去。和 JwtAuthenticationEntryPoint 一樣,告知前端此 HTTP Request 是沒有驗證過的。新增 JwtAccessDeniedHandler,程式碼如下:
package com.waynestalk.jwtexample.auth
import org.springframework.security.access.AccessDeniedException
import org.springframework.security.web.access.AccessDeniedHandler
import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse
class JwtAccessDeniedHandler : AccessDeniedHandler {
override fun handle(request: HttpServletRequest, response: HttpServletResponse, accessDeniedException: AccessDeniedException) {
response.sendError(HttpServletResponse.SC_FORBIDDEN, accessDeniedException.message)
}
}建立 LoginController
當進來的 HTTP Request 是 POST /login 時,就會進到 LoginController.login() 做登入的流程。其程式碼如下:
package com.waynestalk.jwtexample.controller
import com.waynestalk.jwtexample.auth.JwtFilter
import com.waynestalk.jwtexample.auth.JwtTokenProvider
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RestController
import javax.servlet.http.HttpServletResponse
import javax.validation.Valid
import javax.validation.constraints.NotBlank
@RestController
class LoginController(val jwtTokenProvider: JwtTokenProvider,
val authenticationManagerBuilder: AuthenticationManagerBuilder) {
@PostMapping("/login")
fun login(@Valid @RequestBody request: LoginRequest, httpServletResponse: HttpServletResponse): LoginResponse {
val authenticationToken = UsernamePasswordAuthenticationToken(request.username, request.password)
val authentication = authenticationManagerBuilder.`object`.authenticate(authenticationToken)
SecurityContextHolder.getContext().authentication = authentication
val token = jwtTokenProvider.generate(authentication)
httpServletResponse.addHeader(JwtFilter.authenticationHeader, "${JwtFilter.authenticationScheme} $token")
return LoginResponse(token)
}
data class LoginRequest(@field:NotBlank val username: String,
@field:NotBlank val password: String)
data class LoginResponse(val token: String)
}可以看到,就是 authenticationManagerBuilder.object.authenticate(authenticationToken) 會做驗證。這裡面的整個驗證流程,底下的文章中,有詳細的解說。
它裡面最終會呼叫 UserDetailsService 來取得 Username 的 Password。而且,它是一個 Bean。所以,我們新增 MemberUserDetailsService,在裡面從資料庫取出 Password 和 Authorities。其程式碼如下:
package com.waynestalk.jwtexample.auth
import com.waynestalk.jwtexample.repository.MemberRepository
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.core.userdetails.User
import org.springframework.security.core.userdetails.UserDetails
import org.springframework.security.core.userdetails.UserDetailsService
import org.springframework.security.core.userdetails.UsernameNotFoundException
import org.springframework.stereotype.Component
@Component
class MemberUserDetailsService(private val memberRepository: MemberRepository) : UserDetailsService {
override fun loadUserByUsername(username: String): UserDetails {
val member = memberRepository.findByUsername(username)
?: throw UsernameNotFoundException("$username was not found")
val authority = member.authorities.map { SimpleGrantedAuthority(it) }
return User(member.username, member.password, authority)
}
}然後,利用 JwtTokenProvider.generate() 產生一個 JWT Token,並回傳給前端。整個登入流程就完成了。
設定 Spring Security
再來就是要設定 Spring Security 了。新增 SecurityConfiguration,其程式碼如下:
package com.waynestalk.jwtexample.config
import com.waynestalk.jwtexample.auth.JwtAccessDeniedHandler
import com.waynestalk.jwtexample.auth.JwtAuthenticationEntryPoint
import com.waynestalk.jwtexample.auth.JwtFilter
import com.waynestalk.jwtexample.auth.JwtTokenProvider
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter
import org.springframework.security.config.http.SessionCreationPolicy
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
class SecurityConfiguration(val jwtTokenProvider: JwtTokenProvider) : WebSecurityConfigurerAdapter() {
@Bean
fun passwordEncoder(): PasswordEncoder = BCryptPasswordEncoder()
override fun configure(http: HttpSecurity) {
http
.csrf().disable()
.addFilterBefore(JwtFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter::class.java)
.exceptionHandling()
.authenticationEntryPoint(JwtAuthenticationEntryPoint())
.accessDeniedHandler(JwtAccessDeniedHandler())
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
.antMatchers("/login").permitAll()
.anyRequest().authenticated()
}
}我們建立一個 BCryptPasswordEncoder 來取代預設的 DelegatingPasswordEncoder。
在 configure() 裡,我們將 JwtFilter 插入到 UsernamePasswordAuthenticationFilter 的前面。然後在 exceptionHandling() 中,用 JwtAuthenticationEntryPoint 和 JwtAccessDeniedHandler 來取代預設的。設定 SessionCreationPolicy 為 Stateless。最後,允許 /login 不需要驗證就可以存取,這樣才前端才有辦法做登入。
整個專案,大致這樣就完成了。但,我們還要新增 MemberController。它提供 GET /greet 資源,而且要 ADMIN 權限才可以存取,等等我們要用這個 API 來測試權限的部分。
package com.waynestalk.jwtexample.controller
import com.waynestalk.jwtexample.repository.MemberRepository
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RestController
import javax.servlet.http.HttpServletRequest
@RestController
class MemberController(private val memberRepository: MemberRepository) {
@GetMapping("/greet")
@PreAuthorize("hasRole('ROLE_ADMIN')")
fun greet(request: HttpServletRequest): GreetResponse {
val member = memberRepository.findByUsername(request.userPrincipal.name)!!
return GreetResponse("Hello ${member.name}")
}
data class GreetResponse(val message: String)
}測試
現在來測試我們剛剛建立好的專案。我們利用下面的指令來登入 Monika。
% curl --location --request POST 'http://localhost:8080/login' \
--header 'Content-Type: application/json' \
--data-raw '{
"username": "monika",
"password": "123456"
}'登入成功後,會拿到含有 Token 的 Response,如下:
{
"token": "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJtb25pa2EiLCJhdXRob3JpdGllcyI6IlJPTEVfQURNSU4sIFJPTEVfVVNFUiIsImV4cCI6MTU5Nzk5MzM0MX0.eUZFFKO_q31kT4soZCCphwxVIQEN-Ha2nZoo_kpb2ckpr9I8exMN8YDNxn-NflOK4xqCEi_eSwx9Eu8Q7XKg4Q"
}再用這個 Token 來存取 GET /greet。
% curl --location --request GET 'http://localhost:8080/greet' \ --header 'Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJtb25pa2EiLCJhdXRob3JpdGllcyI6IlJPTEVfQURNSU4sIFJPTEVfVVNFUiIsImV4cCI6MTU5Nzk5MzM0MX0.eUZFFKO_q31kT4soZCCphwxVIQEN-Ha2nZoo_kpb2ckpr9I8exMN8YDNxn-NflOK4xqCEi_eSwx9Eu8Q7XKg4Q'
會收到下面的 JSON。
{
"message": "Hello Monika"
}若是用 Jack 的身份去存取 GET /greet 的話,會收到 403 錯誤。
{
"timestamp": "2020-08-21T06:09:34.225+00:00",
"status": 403,
"error": "Forbidden",
"message": "",
"path": "/greet"
}JWT-Token-Based Authentication + Springdoc
Springdoc 套件可以幫我們產生 Swagger 介面的 API 文件。如果不了解 Springdoc,可以先閱讀下面這篇文章。
引入 Springdoc 後,新增 SaggerConfiguration 來設定 Springdoc,其程式碼如下:
package com.waynestalk.jwtexample.config
import io.swagger.v3.oas.models.Components
import io.swagger.v3.oas.models.OpenAPI
import io.swagger.v3.oas.models.info.Info
import io.swagger.v3.oas.models.security.SecurityRequirement
import io.swagger.v3.oas.models.security.SecurityScheme
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
@Configuration
class SwaggerConfiguration {
@Bean
fun customOpenAPI(): OpenAPI {
val securitySchemeName = "Auth JWT"
return OpenAPI()
.addSecurityItem(SecurityRequirement().addList(securitySchemeName))
.components(
Components()
.addSecuritySchemes(securitySchemeName,
SecurityScheme()
.name(securitySchemeName)
.type(SecurityScheme.Type.HTTP)
.scheme("bearer")
.bearerFormat("JWT")
)
)
.info(Info().title("Wayne's Talk APIs").version("v1.0.0"))
}
}這樣還不夠,還要設定 Spring Security,讓它允許 Springdoc 相關的 URI 可以不需要驗證就通過。
class SecurityConfiguration(val jwtTokenProvider: JwtTokenProvider) : WebSecurityConfigurerAdapter() {
override fun configure(http: HttpSecurity) {
http
.authorizeRequests()
.antMatchers("/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html").permitAll()
.antMatchers("/login").permitAll()
.anyRequest().authenticated()
}
}現在可以瀏覽 http://localhost:8080/swagger-ui.html 了。然後試著在 Swagger 上做登入,登入後拷貝收到的 Token。然後,點擊右上方的 Authorize 按鈕,會出現下面的畫面。貼上剛剛收到的 Token,記得不需要 Bearer 前綴,再按下 Authorize。接下來,在 Swagger 上去存取 GET /greet,可以看到 Swagger 會幫你帶入 Token。

結語
本章中的登入與驗證流程,可以看出客製化了很多。或許就會覺得,既然幾乎都是要自己寫,那何必還硬要整進 Spring Security 呢?這樣說也並不無道理。不整合 Spring Security 的話,也許程式碼還可以更簡單一些。不過,這樣的話就無法使用 Spring Security 的 Authorization 與 Method Security 了。如果要自己做 Authorization 的話,那程式碼可就會多很多了,讀者可以評估一下自己的需求。








