本章將介紹在 Spring Security 下,如何在前端做 Google Sign-In 登入後,取得 Authorization Code 並傳給後端。後端和 Google 驗證過後,再傳 JWT token 給前端。過程中,將從 Google Sign-In 取得的 Email 和從 JPA 讀取的帳號做比對。
Table of Contents
OAuth Login 的問題
Spring Security 的 OAuth Login 可以讓我們透過 Google Sign-In 做第三方登入。OAuth Login 的流程是,由後端對 OAuth Server 發出 Authorization Code 的請求。然後,OAuth Server 會將前端重新導向到登入畫面。前端登入後,OAuth Server 在傳 Authorization Code 給後端。最後,後端再對 OAuth Server 發出驗證 Authorization Code、Client ID、和 Client Secret 的請求。OAuth Server 驗證成功後,傳回 ID Token 和 Access Token 給後端。如果,不熟悉以上的流程的話,可以先閱讀以下這篇文章。
由上述的流程可知,OAuth Login 是完全符合網頁的運作模式,但是不完全適合手機前端。至少,重新導向這點在手機端就不是那麼地直覺。
OAuth2 + JWT-Token-Based Authentication
本章要介紹的方式是,結合 OAuth Login 和 JWT-Token-Based 的模式。大致流程會像是 Google Sign-In for server-side apps 所描述的一樣,如下圖。

前端直接向 Google API Server 發出 Authorization 請求,然後重新導向到 OAuth 2.0 登入畫面。使用者登入後,回傳 id_token 和 access_token 給前端。前端再將 id_token 和 access_token 發送給後端。後端會利用 id_token 和 access_token 向 Google API Server 做驗證。驗證成功的話,就告知前端登入成功,整個流程大致完成。
與上面的不同是的,我們在前端會跟 Google API Server 要 Authorization Code,再將它傳給後端。後端就會將 Authorization Code、Client ID、和 Client Secret 送到 Google API Server 做驗證。成功後,將收到的 OAuth 的資訊做成 JWT 傳給前端。之後,前端就用 JWT 做驗證,同下面文章所介紹的 JWT-Token-Based Authentication。
為何我們不用 id_token 和 access_token,而改用 Authorization Code 呢?這是因為 Spring Security 的 OAuth Login 是採用 Authorization Code 流程。所以和它用一樣的方式的話,我們可以借用 OAuth Login 的驗證流程,這樣一來我們可以少很多程式碼。如果用 id_token 和 access_token,那就連同驗證流程都要重寫了。
OAuth2 + JWT-Token-Based Authentication 流程
在開始寫程式之前,我們先來了解一下,我們要實作的流程,如下圖所示:

如果對圖中的 SecurityFilterChain、ExceptionTranslationFilter、FilterSecurityInterceptor 不熟悉的話,請先閱讀下面的文章。
構想是在 SecurityFilterChain 中加入兩個 Filter,分別是 RestOAuth2AuthorizationFilter 和 RestOAuth2AuthenticationFilter。登入時,進入 RestOAuth2AuthenticationFilter 的流程,最後回傳前端一個 JWT。之後,前端的請求會帶著這個 JWT,RestOAuth2AuthorizationFilter 就會負責驗證這個 JWT。
OAuth2 + JWT-Token-Based Authentication 架構
下圖是顯示在 OAuth Login 的架構下,我們如何去修改成我們的架構。紅色的部分就是我們會新增的,其餘都還是借用 OAuth Login 的。如果我們用 id_token 和 access_token 的話,那就會連同橘色的部分都要實作了。

OAuth2 + JWT-Token-Based Authentication 專案
在這專案中,透過 Google Sign-In 驗證後,再將取得的使用者 Email 作為帳號。再從 JPA 讀取使用者帳號並做比對,如果存在則進行比對,如果不存在則會創建新帳號。
建立 Google OAuth Credentials
首先,我們要先取得一組 Client ID 和 Client Secret。在 Google Console 上,創建 Credentials 就可以了,下面的文章有說明詳細的步驟。
不過在 Authorized redirect URIs 的地方,不需要填入任何 URI,如下圖。

建立專案
建立一個 Spring Boot 專案,並引入圖中的 Dependencies。

如果不知道要怎麼建立專案,或不了解上圖的話,可以先參考下面這篇文章。
因為我們還會用到 Jackson 來解析 JSON 字串,以及用 JJWT 來產生 JWT,所以還要再引入以下的 Dependencies。
dependencies {
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("io.jsonwebtoken:jjwt-api:0.11.2")
implementation("io.jsonwebtoken:jjwt-impl:0.11.2")
implementation("io.jsonwebtoken:jjwt-jackson:0.11.2")
}設定 Client DI & Client Secret
把剛剛產生的 Client ID 和 Client Secret 寫到 application.properties。
spring.security.oauth2.client.registration.google.client-id=63245096424-6nn16rjn23h8p11g8bkp2aogl4kqneuc.apps.googleusercontent.com spring.security.oauth2.client.registration.google.client-secret=JP-lvQxE8eo359zgQMKpvC5k
login.html
新增 login.html,其程式碼如下:
<html>
<head>
<meta charset="UTF-8">
<title>Title</title>
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
<script src="https://apis.google.com/js/client:platform.js?onload=renderButton" async defer></script>
<script>
function renderButton() {
gapi.load('auth2', function () {
auth2 = gapi.auth2.init({
client_id: '63245096424-6nn16rjn23h8p11g8bkp2aogl4kqneuc.apps.googleusercontent.com',
scope: 'profile email',
});
});
}
</script>
</head>
<body>
<button id="signInButton" onclick="onClickSignIn()">Sign in with Google</button>
<button onclick="onClickAdmin()">Admin Endpoint</button>
<button onclick="onClickUser()">User Endpoint</button>
<script>
let token;
function onSuccess(result) {
console.log(result);
authenticate(result.code)
.then(res => {
token = res.data.token;
console.log(res);
})
.catch(console.log);
}
function authenticate(code) {
return axios.post('http://localhost:8080/auth/google', JSON.stringify({code}));
}
function callEndpoint(uri, token) {
return axios.get(`http://localhost:8080/${uri}`, {headers: {Authorization: `Bearer ${token}`}});
}
function onFailure(error) {
console.log(error);
}
function onClickSignIn() {
auth2.grantOfflineAccess()
.then(onSuccess)
.catch(onFailure);
}
function onClickAdmin() {
callEndpoint('admin', token)
.then(console.log)
.catch(console.log)
}
function onClickUser() {
callEndpoint('user', token)
.then(console.log)
.catch(console.log)
}
</script>
</body>
</html>
login.html 會顯示三個按鈕,分別是 Sign In with Google、Admin Endpoint、和 User Endpoint。使用者會先點擊 Sign In with Google 做第三方登入,登入成功後,Google 會回傳 Authorization Code,再將它傳給後端,後端再利用它來做登入。登入後,使用者可以點擊 Admin Endpoint 或 User Endpoint 來取得使用者資料。不同的是,Admin Endpoint 是需要 Admin 權限。
RestOAuth2AuthenticationFilter
使用者取得 Authorization Code 後,會對後端請求 POST /auth/google 並帶入 Authorization Code 來做登入。RestOAuth2authenticationFilter 會來處理這個請求,其中的流程和 OAuth Login 中的 OAuth2LoginAuthenticationFilter 很像,建議先閱讀下面的文章。
新增 RestOAuth2authenticationFilter,其程式碼如下:
@Component
class RestOAuth2AuthenticationFilter(
private val clientRegistrationRepository: ClientRegistrationRepository,
private val authorizedClientRepository: OAuth2AuthorizedClientRepository,
private val tokenManager: TokenManager,
private val userDetailsService: MemberUserDetailsService,
private val memberRepository: MemberRepository
) : GenericFilterBean() {
companion object {
private const val contentTypeHeader = "Content-Type"
private const val baseUri = "/auth"
private const val registrationIdUriVariableName = "registrationId"
private const val redirectUri = "postmessage"
private const val nonceParameterName = "nonce"
}
private val authenticationManager: AuthenticationManager
private val authorizationRequestResolver = DefaultOAuth2AuthorizationRequestResolver(clientRegistrationRepository, baseUri)
private val requestMatcher = AntPathRequestMatcher("$baseUri/{$registrationIdUriVariableName}", HttpMethod.POST.name)
init {
val accessTokenResponseClient = DefaultAuthorizationCodeTokenResponseClient()
val userService = OidcUserService()
val authenticationProvider = OidcAuthorizationCodeAuthenticationProvider(accessTokenResponseClient, userService)
authenticationManager = ProviderManager(authenticationProvider)
authorizationRequestResolver.setAuthorizationRequestCustomizer {
it.redirectUri(redirectUri)
it.additionalParameters { additionalParameters -> additionalParameters.remove(nonceParameterName) }
it.attributes { attributes -> attributes.remove(nonceParameterName) }
}
}
override fun doFilter(req: ServletRequest, res: ServletResponse, chain: FilterChain) {
val request = req as HttpServletRequest
val response = res as HttpServletResponse
if (!requireAuthentication(request)) {
chain.doFilter(request, response)
return
}
try {
val authentication = authenticate(request, response)
successfulAuthentication(response, authentication)
} catch (e: Exception) {
unsuccessfulAuthentication(response, e)
}
}
private fun requireAuthentication(request: HttpServletRequest) = requestMatcher.matches(request)
private fun authenticate(request: HttpServletRequest, response: HttpServletResponse): OAuth2AuthenticationToken {
val code = readCode(request)
?: throw OAuth2AuthenticationException(OAuth2Error("authentication_code_missing"))
val registrationId = requestMatcher.matcher(request).variables[registrationIdUriVariableName]
?: throw OAuth2AuthenticationException(OAuth2Error("client_registration_not_found"))
val clientRegistration = clientRegistrationRepository.findByRegistrationId(registrationId)
?: throw OAuth2AuthenticationException(OAuth2Error("client_registration_not_found"))
val authorizationRequest = authorizationRequestResolver.resolve(request, registrationId)
val authorizationResponse = OAuth2AuthorizationResponse
.success(code)
.redirectUri(redirectUri)
.state(authorizationRequest.state)
.build()
val authenticationRequest = OAuth2LoginAuthenticationToken(
clientRegistration, OAuth2AuthorizationExchange(authorizationRequest, authorizationResponse))
val authenticationResult = authenticationManager.authenticate(authenticationRequest)
as OAuth2LoginAuthenticationToken
val username = authenticationResult.principal.attributes["email"] as String
val user = loadUser(username) ?: createUser(authenticationResult)
val authorities = mergeAuthorities(authenticationResult, user)
val oauth2Authentication = OAuth2AuthenticationToken(
authenticationResult.principal,
authorities,
authenticationResult.clientRegistration.registrationId)
val authorizedClient = OAuth2AuthorizedClient(
authenticationResult.clientRegistration,
oauth2Authentication.name,
authenticationResult.accessToken,
authenticationResult.refreshToken)
authorizedClientRepository.saveAuthorizedClient(authorizedClient, oauth2Authentication, request, response)
return oauth2Authentication
}
private fun readCode(request: HttpServletRequest): String? {
val authRequest: Map<String, Any> = jacksonObjectMapper().readValue(request.reader)
return authRequest["code"] as? String
}
private fun loadUser(username: String): UserDetails? {
return try {
userDetailsService.loadUserByUsername(username)
} catch (e: Exception) {
null
}
}
private fun createUser(authentication: OAuth2LoginAuthenticationToken): UserDetails {
val attributes = authentication.principal.attributes
val username = attributes["email"] as String
val member = Member(
username,
authentication.clientRegistration.registrationId,
attributes["name"] as String,
listOf("ROLE_USER")
)
memberRepository.save(member)
return userDetailsService.loadUserByUsername(username)
}
private fun mergeAuthorities(authentication: OAuth2LoginAuthenticationToken, user: UserDetails): Collection<GrantedAuthority> {
val authorities = HashSet<GrantedAuthority>()
authorities.addAll(authentication.authorities)
authorities.addAll(user.authorities)
return authorities
}
private fun successfulAuthentication(response: HttpServletResponse, authentication: OAuth2AuthenticationToken) {
SecurityContextHolder.getContext().authentication = authentication
val token = tokenManager.generate(authentication)
tokenManager[token] = authentication
response.addHeader(contentTypeHeader, MediaType.APPLICATION_JSON_VALUE)
response.writer.println(jacksonObjectMapper().writeValueAsString(TokenResponse(token)))
}
private fun unsuccessfulAuthentication(response: HttpServletResponse, exception: Exception) {
SecurityContextHolder.clearContext()
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, exception.message)
}
private data class TokenResponse(val token: String)
}doFilter() 先檢查請求的 URI 是不是 /auth/{registrationId},不是的話就會直接跳到下一個 Filter。在 login.html 裡,它對 POST /auth/google 發出登入的請求,所以在這裡 registrationId 就會是 google。這邊的邏輯是沿用 OAuth Login 的。
接下來,取得 Authorization Code、registrationId、和 ClientRegistration。然後,產生出 OAuth2LoginAuthenticationToken,並呼叫 authenticationManager.authenticate() 做驗證。
authenticationManager 中,我們加入了 OidcAuthorizationCodeAuthenticationProvider,因為 Google Sign-In 是使用 Oidc 協定。如果是用 OAuth 2.0 的話,如 GitHub,可以再加入 OAuth2AuthorizationCodeAuthenticationProvider。
驗證通過後,我們取得使用者的 Email。與 JPA 中的帳號做比對,如果不存在,我們就創建一個帳號。
最後,產生一個 OAuth2AuthenticationToken,其包含了使用者的基本資料和權限。successfulAuthentication() 會將這 Authentication 物件放入 SecurityContextHolder。然後,產生一個 JWT,將它和 Authentication 物件放入 Cache 裡。最後,回傳 JWT 給前端。
RestOAuth2AuthorizationFilter
前端取得 JWT 後,在請求任何的 REST APIs 時,在 Header 都要帶入 JWT,如下:
Authorization: Bearer <JWT>
RestOAuth2AuthorizationFilter 則會攔截每一個請求,並檢查是否有帶 JWT,其程式碼如下:
@Component
class RestOAuth2AuthorizationFilter(private val tokenManager: TokenManager) : GenericFilterBean() {
companion object {
private const val authenticationHeader = "Authorization"
private const val authenticationScheme = "Bearer"
}
override fun doFilter(request: ServletRequest, response: ServletResponse, chain: FilterChain) {
val token = extractToken(request as HttpServletRequest)
if (token != null && StringUtils.hasText(token)) {
val authentication = tokenManager[token]
if (authentication != null) {
SecurityContextHolder.getContext().authentication = authentication
}
}
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 null
}
}如果檢查到有 JWT 的話,它就會從 Cache 中取出 Authentication 物件,並放入 SecurityContextHolder,就這樣就等於認證成功!
建立資料庫
新增 Member Entity,包含 username、registrationId、和 authorities。
@Entity
data class Member(
@Column(unique = true) val username: String,
val registrationId: String,
val name: String,
@ElementCollection val authorities: Collection<String>,
@Id @GeneratedValue var id: Long? = null
)新增 MemberRepository 來讀取資料庫。
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 權限。
@Configuration
class MemberConfiguration {
@Bean
fun initMembers(memberRepository: MemberRepository) = ApplicationRunner {
memberRepository.saveAll(listOf(
Member("monika@gmail.com", "google", "Monika", listOf("ROLE_ADMIN", "ROLE_USER")),
Member("jack@gmail.com", "google", "Jack", listOf("ROLE_USER")),
Member("peter@gmail.com", "google", "Peter", listOf("ROLE_USER"))
))
}
}新增 MemberUserDetailsService,因為 RestOAuth2AuthenticationFilter 會利用它來讀取使用者資料。
@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, "", authority)
}
}TokenManager
新增 TokenManager 來管理 JWT 和 Authentication。而且,還提供產生和驗證 JWT 的 Method。
@Component
class TokenManager(cacheManager: CacheManager) {
companion object {
private const val claimAuthorities = "authorities"
private const val claimName = "name"
private const val claimEmail = "email"
private const val secret = "qsbWaaBHBN/I7FYOrev4yQFJm60sgZkWIEDlGtsRl7El/k+DbUmg8nmWiVvEfhZ91Y67Sc6Ifobi05b/XDwBy4kXUcKTitNqocy7rQ9Z3kMipYjbL3WZUJU2luigIRxhTVNw8FXdT5q56VfY0LcQv3mEp6iFm1JG43WyvGFV3hCkhLPBJV0TWnEi69CfqbUMAIjmymhGjcbqEK8Wt10bbfxkM5uar3tpyqzp3Q=="
private val key = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secret))
}
private val cache = cacheManager.getCache("tokenManager")!!
operator fun get(token: String): OAuth2AuthenticationToken? {
return if (validate(token)) {
val authentication = cache.get(token)?.get()
authentication as OAuth2AuthenticationToken
} else {
cache.evict(token)
null
}
}
operator fun set(token: String, authentication: OAuth2AuthenticationToken) {
cache.put(token, authentication)
}
fun generate(authentication: OAuth2AuthenticationToken): String {
val subject = authentication.name
val name = authentication.principal.attributes["name"]
val email = authentication.principal.attributes["email"]
val authorities = authentication.authorities?.joinToString { it.authority } ?: ""
val expiration = Date(System.currentTimeMillis() + (60 * 60 * 1000))
return Jwts.builder()
.setSubject(subject)
.claim(claimAuthorities, authorities)
.claim(claimName, name)
.claim(claimEmail, email)
.signWith(key, SignatureAlgorithm.HS512)
.setExpiration(expiration)
.compact()
}
private fun validate(token: String): Boolean {
return try {
val jwtParser = Jwts.parserBuilder().setSigningKey(key).build()
jwtParser.parse(token)
true
} catch (e: Exception) {
false
}
}
}另外,用下面的程式碼來啟動 Cache。
@Configuration
@EnableCaching
class CacheConfig {
}MemberController
新增 MemberController,其程式碼如下:
@RestController
class MemberController(private val authorizedClientRepository: OAuth2AuthorizedClientRepository) {
@GetMapping("/user")
@PreAuthorize("hasRole('ROLE_USER')")
fun user(principal: Principal): UserResponse {
val authentication = principal as OAuth2AuthenticationToken
return UserResponse(
authentication.principal.attributes["email"] as String,
authentication.principal.attributes["name"] as String
)
}
data class UserResponse(val email: String, val name: String)
@GetMapping("/admin")
@PreAuthorize("hasRole('ROLE_ADMIN')")
fun admin(principal: Principal, request: HttpServletRequest): AdminResponse {
val authentication = principal as OAuth2AuthenticationToken
val authorizedClient = authorizedClientRepository.loadAuthorizedClient<OAuth2AuthorizedClient>(
authentication.authorizedClientRegistrationId,
authentication,
request)
return AdminResponse(
authentication.principal.attributes["email"] as String,
authentication.principal.attributes["name"] as String,
authorizedClient.accessToken.tokenValue,
authorizedClient.refreshToken?.tokenValue ?: ""
)
}
data class AdminResponse(val email: String, val name: String, val accessToken: String, val refreshToken: String)
}MemberController 提供了 2 個 Endpoint,其中 admin() 需要 ROLE_ADMIN 的權限,也就是只有 Monika 才能成功發起。
設定 Spring Security
最後,就是要設定 Spring Security 了。新增 SecurityConfiguration,其程式碼如下:
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
class SecurityConfig(
private val restfulOAuth2AuthorizationFilter: RestOAuth2AuthorizationFilter,
private val restfulOAuth2AuthenticationFilter: RestOAuth2AuthenticationFilter
) : WebSecurityConfigurerAdapter() {
override fun configure(http: HttpSecurity) {
http
.csrf().disable()
.addFilterBefore(restfulOAuth2AuthorizationFilter, BasicAuthenticationFilter::class.java)
.addFilterBefore(restfulOAuth2AuthenticationFilter, BasicAuthenticationFilter::class.java)
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.exceptionHandling()
.authenticationEntryPoint(RestOAuth2AuthenticationEntryPoint())
.accessDeniedHandler(RestOAuth2AccessDeniedHandler())
.and()
.authorizeRequests()
.antMatchers("/login.html").permitAll()
.anyRequest().authenticated()
}
}我們將 RestOAuth2AuthorizationFilter 和 RestOAuth2AuthenticationFilter 插入到 SecurityFilterChain 中。並且,設定 AuthenticationEntryPoint 和AccessDeniedHandler。
測試
執行專案後,瀏覽 http://localhost:8080/login.html。先點擊 Sign In with Google 做第三方登入,成功會,會自動向後端請求登入。登入成功後,再點擊 User Endpoint 來測試是否可以成功存取。最後,再點擊 Admin Endpoint,如果沒有 ROLE_ADMIN 權限的話,應該要被禁止存取。
Springdoc
如果想到整合 Springdoc 的話,其模式會像以下的文章所用的方式一樣。將取得的 JWT 設定到 Springdoc。之後發起的任何請求,Springdoc 都會自動幫你帶入 JWT。
結語
Spring Security 的 OAuth Login 對 OAuth 2.0 已經提供很好的實作,但是對手機程式卻不是那麼地友善。因為它使用 Cookies 而且不是 RESTful APIs 的架構。當然,我們也可以完全不借用 OAuth Login 的程式碼,自己實作和 Google Sign-In 驗證的部分。但這樣的話,對於每一種 OAuth 2.0 Server 我們都要實作,這也是蠻麻煩的。









2 comments
Hello Wayne,
Great article as always. I have few queries. If I would like to implement this authentication at api gateway (mostly zuul) generate the jwt token. All of downstream services are microservices so can I implement the authorisation part in each of microservices (I cannot not leverage the localhost network provided security because these apps can be deployed in multiple cloud providers/some may be in on-prem).
I would like to make all of my downstream services as resource servers, each can validate the jwt token generated using same key. Any thoughts on this?
Thank you
Hi Sai,
I think so. Each micro-service can run on different machine, so each of them has to validate the JWT token with the same key.