Spring Security + OAuth Login + Google Sign-In 架構與實例解析

Photo by Nick Karvounis on Unsplash
Photo by Nick Karvounis on Unsplash
本章將深入淺出解析 Spring Security 的 OAuth Login。並且,以 Google Sign-In 為例,顯示如何用 Spring Security 的 OAuth Login 來整合 Google Sign-In。

本章將深入淺出解析 Spring SecurityOAuth Login。並且,以 Google Sign-In 為例,顯示如何用 Spring Security 的 OAuth Login 來整合 Google Sign-In。希望在閱讀後,可以對 OAuth Login 有深入的了解。

完整程式碼可以在 下載。

OAuth 2 Authentication

Spring Security 的 OAuth Login 的功能提供了第三方登入的驗證方式。在 OAuth 2.0 Authentication Framework 中, Authorization Grant 有 4 種模式,分別是:

Spring Security 的 OAuth Login 只有支援 Authorization Code Grant 模式,如 GitHub。此外,它還支援 OpenID Connect 的 Authorization Code Grant 模式,如 Google。

下圖顯示 Authorization Code Grant 模式的流程。你可以透過使用 Google Sign-In 時的流程,會更容易地了解此圖。

Authorization Code Flow
From Authorization Code Flow

Authorization Code 的流程為:

  1. 使用者向 Web App 點擊登入連結。
  2. Web App 向第三方 OAuth Server 請求 Authorization Code。
  3. 第三方 OAuth Server 將使用者重新導向到登入畫面。
  4. 使用者用第三方的帳號,登入第三方 OAuth Server。
  5. 第三方 OAuth Server 驗證使用者的帳號密碼,驗證成功後,重新導向到 Web App,並帶給它 Authorization Code。
  6. Web App 請求 OAuth Server 的 /oauth/token,並帶入 Authorization Code、Client ID、Client Secret。
  7. OAuth Server 驗證 Authorization Code、Client ID、Client Secret。
  8. 驗證成功的話,返回到 Web App,並帶給它 ID Token 和 Access Token。有可能還會帶 Refresh Token,不過這是可選的。
  9. 整體流程到上一個步驟就已經完成。此步驟只是表示,Web App 此時已經可以用 Access Token 去存取 OAuth Server 的資源了,如使用者的資料。

上述的流程中,Google 是 OAuth Server 的角色,而 Web App 則可以是後端程式(如 Spring Boot),也可以是前端程式(如 Angular)。

Spring Security 還整合了 Google、Facebook、和 GitHub 等第三方 OAuth Server。只需要設定 Client ID、Client Secret、以及平台 ID,就可以整合到你的 Spring Boot 專案。

接下來,我們會用 Google Sign-In 為例,深入了解 Spring Security 的 OAuth Login 的流程與架構。

OAuth Login + Google Sign-In 流程

假設已經設定好 Spring Security + OAuth Login + Google Sign-In 的專案後,那運作起來整個流程會如下圖:

Google Sign-In Flow
Google Sign-In Flow

Spring Security 會自動生成一個登入頁面,預設就是 /login。這個登入頁面會有一個 Google Sign In 的連結,而它會連到 /oauth2/authorization/{registrationId}。以目前的例子來說,registrationId 就會是 google,它還有 facebook 和 github 等。Spring Security 會解析出這個 registrationId,並根據它來使用不同的設定和流程。

在 registrationId 是 google 時,會使用 Google 相關的設定。根據這組設定,它會重新導向到 Google Sign-In 的登入畫面。使用者使用 Google Account 登入後,它會重新導向回到 /login/oauth2/code/{registrationId}。而,這個 URI 也稱為 Redirect URI。

這個重新導向回 Spring Security 時,會帶來 Authorization Code。然後,Spring Security 就會開始 Authorization Code Grant 的驗證 Authorization Code 流程。最後,會取得 ID Token、Access Token、和 Refresh Token。

驗證完成後,就會重新導向到預設的成功 URI,也就是 /。

Spring Security OAuth Login 流程

可以看出整個處理流程分成三段:

  • /oauth2/authorization/{registrationId} – OAuth2AuthorizationRequestRedirectFilter
  • Google
  • /login/oauth2/code/{registrationId} – OAuth2LoginAuthenticationFilter

列表中,第一段和第三段是由 Spring Security 的 2 個 Filter 來處理的。如果對 Spring Security 的 Filter 還不太熟悉的話,請先閱讀以下的文章。

OAuth2AuthorizationRequestRedirectFilter

下圖顯示 OAuth2AuthorizationRequestRedirectFilter 的處理流程。

OAuth2AuthorizationRequestRedirectFilter Flow
OAuth2AuthorizationRequestRedirectFilter Flow

首先,如果使用者還未登入就想要存取資源時,authenticationEntryPoint.commence() 就會重新導向到 /login 的登入畫面。這個流程是用 Spring Security 的預設流程,如果不熟悉的話,可以先閱讀以下文章。

當使用者存取 /oauth2/authorization/{registrationId} 時,就會被 OAuth2AuthorizationRequestRedirectFilter 攔截處理。它會先根據 URI 判斷出 registrationId,依之前的例子,它會是 google。然後,用這 registrationId 取得 ClientRegistration,它儲存著 Google 相關的設定。

接下來,根據 ClientRegistration,產生出第三方驗證所有資料,包含其 URL,以及 URL 後面所有的 Query Parameters。然後,先把這個資料暫存起來,之後就重新導向到這個 URL 做第三方驗證。

OAuth2LoginAuthenticationFilter

當第三方重新導向到 Redirect URI /login/oauth2/code/{registrationId} 時,OAuth2LoginAuthenticationFilter 就會攔截處理,其流程如下圖:

OAuth2LoginAuthenticationFilter Flow
OAuth2LoginAuthenticationFilter Flow

首先,它會根據 HTTP Request 來取得在 OAuth2AuthorizationRequestRedirectFilter 那邊暫存的第三方驗證資料。這個從第三方來的 HTTP Request 是有挾帶 Authorization Code 的。然後,呼叫 AuthenticationManager.authenticate() 來對這個第三方驗證資料和 HTTP Request 做 OAuth 2.0 Authorization Code Flow 的驗證處理。複雜的邏輯全部都在這驗證的 Method 裡。

接下來,驗證成功就會重新導向到預設的 URI。因為 OAuth2LoginAuthenticationFilter 也是繼承 AbstractAuthenticationProcessingFilter,所以後半的流程和 Form Login 一樣,細節可以參考以下的文章。

Spring Security OAuth 2.0 架構

上一小節是以概觀的角度,來看 Spring Security OAuth Login 的流程。了解整個流程後,有助於了解細節。

OAuth2AuthorizationRequestRedirectFilter

首先,先來看一下 OAuth2AuthorizationRequestRedirectFilter 的架構,如下圖:

OAuth2AuthorizationRequestRedirectFilter Architecture
OAuth2AuthorizationRequestRedirectFilter Architecture

OAuth2AuthorizationRequestRedirectFilter 的邏輯其實相當簡單。它就是根據 HTTP Request URI 判斷出 registrationId。然後,根據這 registrationId 取得 ClientRegistration。

ClientRegistration 是相當重要的資料結構。仔細看,其裡面包含了所有 OAuth 2.0 需要的參數與設定,如 Secret、AuthenticationGrantType、Redirect URL、Scopes、Authorization URL、Token URL、UserInfoEndpoint URL 等等。也就是說,如果你想要客製化連到某個 OAuth Server,最重要的就是提供一份 ClientRegistration 給 Spring Security。當然,你的 OAuth Server 必須要是 Authorization Code Grant 模式。

OAuth2AuthorizationRequestRedirectFilter 取得 ClientRegistration 之後,再將 ClientRegistration 帶給 OAuth2AuthorizationRequest。OAuth2AuthorizationRequest 就可以根據這份資料產生出要請求 Authorization Code 的 HTTP Request。

其程式碼大致可以精簡如下:

public class OAuth2AuthorizationRequestRedirectFilter {
    void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) {
        String registrationId = resolveRegistrationId(request);
        OAuth2AuthorizationRequest authorizationRequest = authorizationRequestResolver.resolve(request);
        if (authorizationRequest != null) {
            authorizationRequestRepository.saveAuthorizationRequest(authorizationRequest, request, response);
            authorizationRedirectStrategy.sendRedirect(request, response, authorizationRequest.getAuthorizationRequestUri());
        } else {
            filterChain.doFilter(request, response);
        }
    }
}

public DefaultOAuth2AuthorizationRequestResolver implements OAuth2AuthorizationRequestResolver {
    OAuth2AuthorizationRequest resolve(HttpServletRequest request, String registrationId) {
        ClientRegistration clientRegistration = clientRegistrationRepository.findByRegistrationId(registrationId);
        OAuth2AuthorizationRequest.Builder builder = OAuth2AuthorizationRequest.authorizationCode();
        builder
            .clientId(clientRegistration.getClientId())
            .authorizationUri(clientRegistration.getProviderDetails().getAuthorizationUri())
            .redirectUri(redirectUriStr)
            .scopes(clientRegistration.getScopes())
            .state(this.stateGenerator.generateKey())
            .attributes(attributes);
        return builder.build();
    }
}

DefaultOAuth2AuthorizationRequestResolver.resolve() 做的事情就是,取出 ClientRegistration,然後根據它創建出 OAuth2AuthorizationRequest。

OAuth2LoginAuthenticationFilter

深入去看 OAuth2LoginAuthenticationFilter,會覺得它相當地複雜。一大堆程式碼加上一大堆資料結構,令人眼花撩亂。下圖是 OAuth2LoginAuthenticationFilter 的架構,當然,它沒有包含全部的資料結構,但包含了大部分了。

OAuth2LoginAuthenticationFilter Architecture
OAuth2LoginAuthenticationFilter Architecture

OAuth2LoginAuthenticationFilter 複雜的邏輯大多集中在 OAuth 2.0 Authorization Code Grant 驗證的部分,而其餘的相當地簡單。也就是說,想要了解 OAuth2LoginAuthenticationFilter,那你必須要先了解 OAuth 2.0 Authorization Code Grant 的整個流程。本章上半部已經有解釋這部分了,建議可以搭配著看。

OAuth2LoginAuthenticationFilter 首先取出暫存在 AuthorizationRequestRepository 的 OAuth2AuthorizationRequest。而,OAuth2AuthorizationRequest 裡面存有 registrationId,根據它就可以取得 ClientRegistration。並將 OAuth Server 帶過來的 HTTP Request 中的 Parameters 轉成 OAuth2AuthorizationResponse。然後,用 OAuth2AuthorizationRequest、OAuth2AuthorizationResponse、和 ClientRegistration,建立 OAuth2LoginAuthenticationToken。

OAuth2LoginAuthenticationToken Architecture
OAuth2LoginAuthenticationToken Architecture

再來就是用 OidcAuthorizationCodeAuthenticationProvider 來驗證剛剛的 OAuth2LoginAuthenticationToken 叫 authenticationRequest。Oidc 就是 OpenID Connect 的縮寫,所以它是就是用來做 OpenID Connect Authorization Code Grant 模式的驗證處理。所以,若是驗證成功後,OidcAuthorizationCodeAuthenticationProvider.authenticate() 會回來一個 OAuth2LoginAuthenticationToken 叫 authenticationResult。authenticationResult 會含有 ID Token、Access Token、和 Refresh Token。

OidcAuthorizationCodeAuthenticationProvider Architecture
OidcAuthorizationCodeAuthenticationProvider Architecture

最後,利用 authenticationResult 來建立 OAuth2AuthenticationToken 和 OAuth2AuthorizedClient,並回傳 OAuth2AuthenticationToken。

整個流程的程式碼簡化如下:

public class OAuth2LoginAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) {
        OAuth2AuthorizationRequest authorizationRequest = authorizationRequestRepository.removeAuthorizationRequest(request, response);
        String registrationId = authorizationRequest.getAttribute(OAuth2ParameterNames.REGISTRATION_ID);
        ClientRegistration clientRegistration = this.clientRegistrationRepository.findByRegistrationId(registrationId);
        OAuth2AuthorizationResponse authorizationResponse = OAuth2AuthorizationResponseUtils.convert(params, redirectUri);
        OAuth2LoginAuthenticationToken authenticationRequest = 
            new OAuth2LoginAuthenticationToken(clientRegistration, new OAuth2AuthorizationExchange(authorizationRequest, authorizationResponse));

        OAuth2LoginAuthenticationToken authenticationResult = (OAuth2LoginAuthenticationToken) this.getAuthenticationManager().authenticate(authenticationRequest);

        OAuth2AuthenticationToken oauth2Authentication = new OAuth2AuthenticationToken(
            authenticationResult.getPrincipal(),
            authenticationResult.getAuthorities(),
            authenticationResult.getClientRegistration().getRegistrationId());

        OAuth2AuthorizedClient authorizedClient = new OAuth2AuthorizedClient(
            authenticationResult.getClientRegistration(),
            oauth2Authentication.getName(),
            authenticationResult.getAccessToken(),
            authenticationResult.getRefreshToken());

        authorizedClientRepository.saveAuthorizedClient(authorizedClient, oauth2Authentication, request, response);

        return oauth2Authentication;
    }
}

最後回傳的 OAuth2AuthenticationToken,就會進入到 successfulAuthentication()。在裡面,會被設定到 SecurityContextHolder。這部分的流程,可以參考以下的文章。

OAuth Login + Google Sign-In 專案

了解 Spring Security OAuth Login 的流程後,來建立一個 Spring Security OAuth Login 專案,並且整合 Google Sign-In。

建立 Google OAuth Credentials

首先,我們要先取得一組 Client ID 和 Client Secret。

Google Console,點選左邊的 Credentials。切換到 Credentials 頁面後,點選上方的 CREATE CREDENTIALS,並選擇 OAuth Client ID。

Create Credentials
Create Credentials

在 Application type 欄位,請選擇 Web application。在 Name 欄位上輸入你專案的名稱。

Authorized JavaScript origins 是你前端發出登入請求的 Origins。因為我們的 Spring Boot 只跑在本機上,所以就用 http://localhost:8080

Authorized redirect URIs 是使用者登入 Google 後,Google 會重新導向回我們的 Spring Boot 時,所用的 URL。在本章有提到,Redirect URI 是 /login/oauth2/code/{registrationId}。所以,URL 就會是 http://localhost:8080/login/oauth2/code/google

Create OAuth Client ID
Create OAuth Client ID

最後,Google Console 會產生好一組 Client ID 和 Client Secret。

OAuth Client ID & Client Secret
OAuth Client ID & Client Secret

建立專案

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

OAuth 2.0 Login 專案
OAuth 2.0 Login 專案

如果不知道要怎麼建立專案,或不了解上圖的話,可以先參考下面這篇文章。

設定 Spring Security

把剛剛產生的 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

新增 SecurityConfig,其程式碼如下:

package com.waynestalk.oauthloginexample.config

import org.springframework.context.annotation.Configuration
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter

@Configuration
class SecurityConfig : WebSecurityConfigurerAdapter() {
    override fun configure(http: HttpSecurity) {
        http
                .authorizeRequests()
                .anyRequest()
                .authenticated()

                .and()
                .oauth2Login()
    }
}

UserController

新增 UserController,裡面有一個 GET /user。

package com.waynestalk.oauthloginexample.controller

import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RestController
import java.security.Principal

@RestController
class UserController {
    @GetMapping("/user")
    fun user(principal: Principal): Principal {
        return principal
    }
}

測試

瀏覽 http://localhost:8080/login,你可以看到 Spring Security 已經自動幫你產生好一個登入頁面。點擊 Google,你就會看到 Sign-in with Google 的登入畫面。登入成功後,你就會被重新導向到 http://localhost:8080/#

因為 / 沒有任何頁面,所以會顯示 404。不過已經登入成功了,現在再直接存取 http://localhost:8080/user,就可以看到 GET /user 的輸出了!

設定登入頁

我們也可以設定登入頁。在下面的程式碼中,我們將登入頁改成 /login.html,原本的是 /login。

@Configuration
class SecurityConfig : WebSecurityConfigurerAdapter() {
    override fun configure(http: HttpSecurity) {
        http
                .authorizeRequests()
                .antMatchers("/login.html").permitAll()
                .anyRequest()
                .authenticated()

                .and()
                .oauth2Login()
                .loginPage("/login.html")
    }
}

在 static/ 下新增 index.html。裡面的登入連結必須是 /oauth2/authorization/google。還記得 /oauth2/authorization/{registrationId} 吧!

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Login</title>
</head>
<body>
<a href="/oauth2/authorization/google">Sign-in Google</a>
</body>
</html>

瀏覽 http://localhost:8080/login.html,就可以看到我們的登入頁了。

設定登入成功頁

成功登入後,預設是會重新導向到 /。我們也可以變更這個 URI。下面的程式碼,設定成功頁為 /login-success。

@Configuration
class SecurityConfig : WebSecurityConfigurerAdapter() {
    override fun configure(http: HttpSecurity) {
        http
                .authorizeRequests()
                .antMatchers("/login.html").permitAll()
                .anyRequest()
                .authenticated()

                .and()
                .oauth2Login()
                .loginPage("/login.html")
                .defaultSuccessUrl("/success.html")
    }
}

在 static/ 下新增 success.html。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>LoginSuccess</title>
</head>
<body>
<a href="/user">Get Me</a>
</body>
</html>

再試著登入一次,登入成功後,會被重新導向到 /success.html。在 /success.html 裡,點擊 Get Me 就可以連到 GET /user。

Method Security

Spring Security 的 OAuth Login 也有支援 Method Security。如果不了解 Method Security 的話,可以先閱讀下面這篇文章。

所有登入成功後的 User,預設都有 ROLE_USER 的權限。所以,我們先在 GET /user 加上 hasRole(‘ROLE_ADMIN‘) 權限。

@RestController
class UserController {
    @GetMapping("/user")
    @PreAuthorize("hasRole('ROLE_ADMIN')")
    fun user(principal: Principal): Principal {
        return principal
    }
}

然後,試著登入,登入成功後,再存取 GET /user。這時候你會得到 403 錯誤。這表示我們剛剛加的 Method Security 有作用了!

現在,我們來幫我們的登入帳號加上 ROLE_ADMIN 的權限。在 SecurityConfig 加上一個 Bean,程式碼如下:

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
class SecurityConfig : WebSecurityConfigurerAdapter() {
    // ..

    @Bean
    fun userAuthoritiesMapper(): GrantedAuthoritiesMapper {
        return GrantedAuthoritiesMapper { authorities ->
            val mappedAuthorities = HashSet<GrantedAuthority>()
            authorities.forEach { authority ->
                mappedAuthorities.add(authority)
                if (OidcUserAuthority::class.java.isInstance(authority)) {
                    val oidcUserAuthority = authority as OidcUserAuthority
                    val email = oidcUserAuthority.attributes["email"]

                    if (email == "your.email@waynestalk.com") {
                        mappedAuthorities.add(OidcUserAuthority("ROLE_ADMIN", oidcUserAuthority.idToken, oidcUserAuthority.userInfo))
                    }
                }
            }

            mappedAuthorities
        }
    }
}

在這裡面,我們檢查 email,如果 email 是 your.email@waynestalk.com 的話,我們就給它加上 ROLE_ADMIN 的權限。這邊會用 email 是因為,我們把這 email 當作是 Username。記得把your.email@waynestalk.com 換成你自己的 email。

重新執行一下專案,再登入一次,然後存取 GET /user。這時候應該可以成功地存取了。

結語

Spring Security 的 OAuth Login 的確幫我們節省了很多時間。不然,還要自己去實作 Authorization Code Grant 的驗證流程,這會相當地費時!雖然說,由本章來看,OAuth Login 的邏輯是還蠻複雜的。不過,複雜是複雜在 Authorization Code Grant 的流程。如果,自己實作的話,也許最後也是會差不多地複雜吧!

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *

You May Also Like
Photo by Charles Jackson on Unsplash
Read More

Springdoc-OpenAPI 教學

Springdoc 是一個整合 OpenAPI Specification 和 Spring Boot 的套件。和 SpringFox 套件一樣,它產出 Swagger 文件。兩者不同在於,Springdoc 是用 Swagger 3,而 SpringFox 是用 Swagger 2。
Read More