SpringCloud微服務實戰——搭建企業級開發框架(四十):使用Spring Security OAuth2實現單點登入(SSO)系統

全棧程式猿 發表於 2022-05-12
框架 微服務 Spring

一、單點登入SSO介紹

  目前每家企業或者平臺都存在不止一套系統,由於歷史原因每套系統採購於不同廠商,所以系統間都是相互獨立的,都有自己的使用者鑑權認證體系,當使用者進行登入系統時,不得不記住每套系統的使用者名稱密碼,同時,管理員也需要為同一個使用者設定多套系統登入賬號,這對系統的使用者來說顯然是不方便的。我們期望的是如果存在多個系統,只需要登入一次就可以訪問多個系統,只需要在其中一個系統執行登出登入操作,則所有的系統都登出登入,無需重複操作,這就是單點登入(Single Sign On 簡稱SSO)系統實現的功能。
  單點登入是系統功能的定義,而實現單點登入功能,目前開源且流行的有CAS和OAuth2兩種方式,過去我們用的最多的是CAS,現在隨著SpringCloud的流行,更多人選擇使用SpringSecurity提供的OAuth2認證授權伺服器實現單點登入功能。
  OAuth2是一種授權協議的標準,任何人都可以基於這個標準開發Oauth2授權伺服器,現在百度開放平臺、騰訊開放平臺等大部分的開放平臺都是基於OAuth2協議實現, OAuth2.0定義了四種授權型別,最新版OAuth2.1協議定義了七種授權型別,其中有兩種因安全問題已不再建議使用

【OAuth2.1 建議使用的五種授權型別】
  • Authorization Code 【授權碼授權】:使用者通過授權伺服器重定向URL返回到客戶端後,應用程式從URL中獲取授權碼,並使用授權碼請求訪問令牌。
  • PKCE【Proof Key for Code Exchange 授權碼交換證明金鑰】:授權碼型別的擴充套件,用於防止CSRF和授權碼注入攻擊。
  • Client Credentials【客戶端憑證授權】:直接由客戶端使用客戶端 ID 和客戶端金鑰向授權伺服器請求訪問令牌,無需使用者授權,通常用與系統和系統之間的授權。
  • Device Code【裝置程式碼授權】:用於無瀏覽器或輸入受限的裝置,使用提前獲取好的裝置程式碼獲取訪問令牌。
  • Refresh Token【重新整理令牌授權】:當訪問令牌失效時,可以通過重新整理令牌獲取訪問令牌,不需要使用者進行互動。
【OAuth2.1 不建議/禁止使用的兩種授權型別】
  • Implicit Flow【隱式授權】:隱式授權是以前推薦用於本機應用程式和 JavaScript 應用程式的簡化 OAuth 流程,其中訪問令牌立即返回,無需額外的授權程式碼交換步驟。其通過HTTP重定向直接返回訪問令牌,存在很大的風險,不建議使用,有些授權伺服器直接禁止使用此授權型別。
  • Password Grant【密碼授權】:客戶端通過使用者名稱密碼向授權伺服器獲取訪問令牌。因客戶端需收集使用者名稱和密碼,所以不建議使用,最新的 OAuth 2 安全最佳實踐完全不允許密碼授權。
【SpringSecurity對OAuth2協議的支援】:

  通過SpringSecurity官網可知,通過長期的對OAuth2的支援,以及對實際業務的情景考慮,大多數的系統都不需要授權伺服器,所以,Spring官方不再推薦使用spring-security-oauth2,SpringSecurity逐漸將spring-security-oauth2中的OAuth2登入、客戶端、資源伺服器等功能抽取出來,整合在SpringSecurity中,並單獨新建spring-authorization-server專案實現授權伺服器功能。
  目前我們瞭解最多的是Spring Security OAuth對OAuth2協議的實現和支援,這裡需要區分Spring Security OAuth和Spring Security是兩個專案,過去OAth2相關功能都在Spring Security OAuth專案中實現,但是自SpringSecurity5.X開始,SpringSecurity專案開始逐漸增加Spring Security OAuth中的功能,自SpringSecurity5.2開始,新增了OAuth 2.0 登入, 客戶端, 資源伺服器的功能。但授權伺服器的功能,並不打算整合在SpringSecurity專案中,而是新建了spring-authorization-server專案作為單獨的授權伺服器:詳細介紹。spring-security實現的是OAuth2.1協議,spring-security-oauth2實現的是OAuth2.0協議。
  Spring未來的計劃是將 Spring Security OAuth 中當前的所有功能構建到 Spring Security 5.x 中。 在 Spring Security 達到與 Spring Security OAuth 的功能對等之後,他們將繼續支援錯誤和安全修復至少一年。

【GitEgg框架單點登入實現計劃】:

  因spring-authorization-server目前最新發布版本0.2.3,部分功能仍在不斷的修復和完善,還不足以應用到實際生產環境中,所以,我們目前使用spring-security-oauth2作為授權伺服器,待後續spring-authorization-server釋出穩定版本後,再進行遷移升級。

【spring-security-oauth2預設實現的授權型別】:
  • 隱式授權(Implicit Flow)【spring-authorization-server不再支援此型別】
  • 授權碼授權(Authorization Code)
  • 密碼授權(Password Grant)【spring-authorization-server不再支援此型別】
  • 客戶端憑證授權(Client Credentials)
  • 重新整理令牌授權 (Refresh Token)

  在GitEgg微服務框架中,gitegg-oauth已經引入了spring-security-oauth2,程式碼中使用了了Oauth2的密碼授權和重新整理令牌授權,並且自定義擴充套件了【簡訊驗證碼授權型別】和【圖形驗證碼授權】,這其實是密碼授權的擴充套件授權型別。
  目前,基本上所有的SpringCloud微服務授權方式都是使用的OAuth2密碼授權模式獲取token,可能你會有疑惑,為什麼上面最新的Oauth2協議已經不建議甚至是禁止使用密碼授權型別了,而我們GitEgg框架的系統管理介面還要使用密碼授權模式來獲取token?因為不建議使用密碼授權型別的原因是第三方客戶端會收集使用者名稱密碼,存在安全風險。而在我們這裡,我們的客戶端是自有系統管理介面,不是第三方客戶端,所有的使用者名稱密碼都是我們自有系統的使用者名稱密碼,只要做好系統安全防護,就可最大限度的避免使用者名稱密碼洩露給第三方的風險。

  在使用spring-security-oauth2實現單點登入之前,首先我們一定要搞清楚單點登入SSO、OAuth2、spring-security-oauth2的區別和聯絡:
  • 單點登入SSO是一種系統登入解決方案的定義,企業內部系統登入以及網際網路上第三方QQ、微信、GitHub登入等都是單點登入。
  • OAuth2是一種系統授權協議,它包含多種授權型別,我們可以使用授權碼授權和重新整理令牌授權兩種授權型別來實現單點登入功能。
  • spring-security-oauth2是對OAuth2協議中授權型別的具體實現,也是我們實現單點登入功能實際用到的程式碼。

二、SpringSecurity單點登入服務端和客戶端實現流程解析

單點登入業務流程時序圖:

spring-security-oauth2單點登入

A系統(單點登入客戶端)首次訪問受保護的資源觸發單點登入流程說明
  • 1、使用者通過瀏覽器訪問A系統被保護的資源連結
  • 2、A系統判斷當前會話是否登入,如果沒有登入則跳轉到A系統登入地址/login
  • 3、A系統首次接收到/login請求時沒有state和code引數,此時A系統拼接系統配置的單點登入伺服器授權url,並重定向至授權連結。
  • 4、單點登入伺服器判斷此會話是否登入,如果沒有登入,那麼返回單點登入伺服器的登入頁面。
  • 5、使用者在登入頁面填寫使用者名稱、密碼等資訊執行登入操作。
  • 6、單點登入伺服器校驗使用者名稱、密碼並將登入資訊設定到上下文會話中。
  • 7、單點登入伺服器重定向到A系統的/login連結,此時連結帶有code和state引數。
  • 8、A系統再次接收到/login請求,此請求攜帶state和code引數,系統A通過OAuth2RestTemplate請求單點登入服務端/oauth/token介面獲取token。
  • 9、A系統獲取到token後,首先會對token進行解析,並使用配置的公鑰對token進行校驗(非對稱加密),如果校驗通過,則將token設定到上下文,下次訪問請求時直接從上下文中獲取。
  • 10、A系統處理完上下問會話之後重定向到登入前請求的受保護資源連結。
B系統(單點登入客戶端)訪問受保護的資源流程說明
  • 1、使用者通過瀏覽器訪問B系統被保護的資源連結
  • 2、B系統判斷當前會話是否登入,如果沒有登入則跳轉到B系統登入地址/login
  • 3、B系統首次接收到/login請求時沒有state和code引數,此時B系統拼接系統配置的單點登入伺服器授權url,並重定向至授權連結。
  • 4、單點登入伺服器判斷此會話是否登入,因上面訪問A系統時登陸過,所以此時不會再返回登入介面。
  • 5、單點登入伺服器重定向到B系統的/login連結,此時連結帶有code和state引數。
  • 6、B系統再次接收到/login請求,此請求攜帶state和code引數,系統B通過OAuth2RestTemplate請求單點登入服務端/oauth/token介面獲取token。
  • 7、B系統獲取到token後,首先會對token進行解析,並使用配置的公鑰對token進行校驗(非對稱加密),如果校驗通過,則將token設定到上下文,下次訪問請求時直接從上下文中獲取。
  • 8、B系統處理完上下問會話之後重定向到登入前請求的受保護資源連結。
spring-security-oauth2 單點登入程式碼實現流程說明:
  • 1、使用者通過瀏覽器訪問單點登入被保護的資源連結
  • 2、SpringSecurity通過上下文判斷是否登入(SpringSecurity單點登入服務端和客戶端預設都是基於session的),如果沒有登入則跳轉到單點登入客戶端地址/login
  • 3、單點登入客戶端OAuth2ClientAuthenticationProcessingFilter攔截器通過上下文獲取token,因第一次訪問單點登入客戶端/login時,沒有code和state引數,所以丟擲UserRedirectRequiredException異常
  • 4、單點登入客戶端捕獲UserRedirectRequiredException異常,並根據配置檔案中的配置,組裝並跳轉到單點登入服務端的授權連結/oauth/authorize,連結及請求中會帶相關配置引數
  • 5、單點登入服務端收到授權請求,根據session判斷是否此會話是否登入,如果沒有登入則跳轉到單點登入伺服器的統一登入介面(單點登入服務端也是根據session判斷是否登入的,在這裡為了解決微服務的session叢集共享問題,引入了spring-session-data-redis)
  • 6、使用者完成登入操作後,單點登入服務端重定向到單點登入客戶端的/login連結,此時連結帶有code和state引數
  • 7、再次用到第三步的OAuth2ClientAuthenticationProcessingFilter攔截器通過上下文獲取token,此時上下文中肯定沒有token,所以會通過OAuth2RestTemplate請求單點登入服務端/oauth/token介面使用重定向獲得的code和state換取token
  • 8、單點登入客戶端獲取到token後,首先會對token進行解析,並使用配置的公鑰對token進行校驗(非對稱加密),如果校驗通過,則將token設定到上下文,下次訪問請求時直接從上下文中獲取。
  • 9、單點登入客戶端處理完上下問會話之後重定向到登入前請求的受保護資源連結。

三、使用【授權碼授權】和【重新整理令牌授權】來實現單點登入伺服器

1、自定義單點登入伺服器頁面

  當我們的gitegg-oauth作為授權伺服器使用時,我們希望定製自己的登入頁等資訊,下面我們自定義登入、主頁、錯誤提示頁、找回密碼頁。其他需要的頁面可以自己定義,比如授權確認頁,我們此處業務不需要使用者二次確認,所以這裡沒有自定義此頁面。

  • 在gitegg-oauth工程的pom.xml中新增Thymeleaf依賴,作為Spring官方推薦的模板引擎,我們使用Thymeleaf來實現前端頁面的渲染展示。
        <!--thymeleaf 模板引擎 渲染單點登入伺服器頁面-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-thymeleaf</artifactId>
        </dependency>
  • 在GitEggOAuthController中新增頁面跳轉路徑
    /**
     * 單點登入-登入頁
     * @return
     */
    @GetMapping("/login") public String login() {
        return "login";
    }

    /**
     * 單點登入-首頁:當直接訪問單點登入系統成功後進入的頁面。從客戶端系統進入的,直接返回到客戶端頁面
     * @return
     */
    @GetMapping("/index") public String index() {
        return "index";
    }

    /**
     * 單點登入-錯誤頁
     * @return
     */
    @GetMapping("/error") public String error() {
        return "error";
    }

    /**
     * 單點登入-找回密碼頁
     * @return
     */
    @GetMapping("/find/pwd") public String findPwd() {
        return "findpwd";
    }
  • 在resources目錄下新建static(靜態資源)目錄和templates(頁面程式碼)目錄,新增favicon.ico檔案
    單點登入頁面目錄
  • 自定義登入頁login.html程式碼
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
    <meta name="description" content="統一身份認證平臺">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>統一身份認證平臺</title>
    <link rel="shortcut icon" th:href="@{/gitegg-oauth/favicon.ico}"/>
    <link rel="bookmark" th:href="@{/gitegg-oauth/favicon.ico}"/>
    <link type="text/css" rel="stylesheet" th:href="@{/gitegg-oauth/assets/bootstrap-4.3.1-dist/css/bootstrap.min.css}">
    <link type="text/css" rel="stylesheet" th:href="@{/gitegg-oauth/assets/bootstrap-validator-0.5.3/css/bootstrapValidator.css}">
    <link type="text/css" rel="stylesheet" th:href="@{/gitegg-oauth/assets/css/font-awesome.min.css}">
    <link type="text/css" rel="stylesheet" th:href="@{/gitegg-oauth/assets/css/login.css}">
    <!--[if IE]>
        <script type="text/javascript" th:src="@{/gitegg-oauth/assets/js/html5shiv.min.js}"></script>
    <![endif]-->
</head>
<body>
    <div class="htmleaf-container">
        <div class="form-bg">
                <div class="container">
                    <div class="row login_wrap">
                        <div class="login_left">
                            <span class="circle">
                              <!-- <span></span>
                              <span></span> -->
                              <img th:src="@{/gitegg-oauth/assets/images/logo.svg}" class="logo" alt="logo">
                            </span>
                            <span class="star">
                              <span></span>
                              <span></span>
                              <span></span>
                              <span></span>
                              <span></span>
                              <span></span>
                              <span></span>
                              <span></span>
                            </span>
                            <span class="fly_star">
                              <span></span>
                              <span></span>
                            </span>
                            <p id="title">
                                GitEgg Cloud 統一身份認證平臺
                            </p>
                        </div>
                        <div class="login_right">
                            <div class="title cf">
                                <ul class="title-list fr cf ">
                                    <li class="on">賬號密碼登入</li>
                                    <li>驗證碼登入</li>
                                    <p></p>
                                </ul>
                            </div>
                            <div class="login-form-container account-login">
                                <form class="form-horizontal account-form" th:action="@{/gitegg-oauth/login}" method="post">
                                    <input type="hidden" class="form-control" name="client_id" value="gitegg-admin">
                                    <input id="user_type" type="hidden" class="form-control" name="type" value="user">
                                    <input id="user_mobileType" type="hidden" class="form-control" name="mobile" value="0">
                                    <div class="input-wrapper input-account-wrapper form-group">
                                        <div class="input-icon-wrapper">
                                            <i class="input-icon">
                                                <svg t="1646301169630" class="icon" viewBox="64 64 896 896" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8796" width="1.2em" height="1.2em" fill="currentColor"><path d="M858.5 763.6c-18.9-44.8-46.1-85-80.6-119.5-34.5-34.5-74.7-61.6-119.5-80.6-0.4-0.2-0.8-0.3-1.2-0.5C719.5 518 760 444.7 760 362c0-137-111-248-248-248S264 225 264 362c0 82.7 40.5 156 102.8 201.1-0.4 0.2-0.8 0.3-1.2 0.5-44.8 18.9-85 46-119.5 80.6-34.5 34.5-61.6 74.7-80.6 119.5C146.9 807.5 137 854 136 901.8c-0.1 4.5 3.5 8.2 8 8.2h60c4.4 0 7.9-3.5 8-7.8 2-77.2 33-149.5 87.8-204.3 56.7-56.7 132-87.9 212.2-87.9s155.5 31.2 212.2 87.9C779 752.7 810 825 812 902.2c0.1 4.4 3.6 7.8 8 7.8h60c4.5 0 8.1-3.7 8-8.2-1-47.8-10.9-94.3-29.5-138.2zM512 534c-45.9 0-89.1-17.9-121.6-50.4S340 407.9 340 362c0-45.9 17.9-89.1 50.4-121.6S466.1 190 512 190s89.1 17.9 121.6 50.4S684 316.1 684 362c0 45.9-17.9 89.1-50.4 121.6S557.9 534 512 534z" p-id="8797"></path></svg>
                                            </i>
                                        </div>
                                        <input type="text" class="input" name="username" placeholder="請輸入您的賬號">
                                    </div>
                                    <div class="input-wrapper input-psw-wrapper form-group">
                                        <div class="input-icon-wrapper">
                                            <i class="input-icon">
                                                <svg t="1646302713220" class="icon" viewBox="64 64 896 896" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8931" width="1.2em" height="1.2em" fill="currentColor"><path d="M832 464h-68V240c0-70.7-57.3-128-128-128H388c-70.7 0-128 57.3-128 128v224h-68c-17.7 0-32 14.3-32 32v384c0 17.7 14.3 32 32 32h640c17.7 0 32-14.3 32-32V496c0-17.7-14.3-32-32-32zM332 240c0-30.9 25.1-56 56-56h248c30.9 0 56 25.1 56 56v224H332V240z m460 600H232V536h560v304z" p-id="8932"></path><path d="M484 701v53c0 4.4 3.6 8 8 8h40c4.4 0 8-3.6 8-8v-53c12.1-8.7 20-22.9 20-39 0-26.5-21.5-48-48-48s-48 21.5-48 48c0 16.1 7.9 30.3 20 39z" p-id="8933"></path></svg>
                                            </i>
                                        </div>
                                        <input id="password" type="password" class="input" name="password" placeholder="請輸入您的密碼">
                                    </div>
                                    <div id="account-err" class="err-msg" style="width: 100%; text-align: center;"></div>
                                    <button type="submit" class="login-btn" id="loginSubmit">立即登入</button>
                                    <div class="forget" id="forget">忘記密碼?</div>
                                </form>
                            </div>
                            <div class="login-form-container mobile-login" style="display: none;">
                                <form class="form-horizontal mobile-form" th:action="@{/gitegg-oauth/phoneLogin}" method="post">
                                    <input id="tenantId" type="hidden" class="form-control" name="tenant_id" value="0">
                                    <input id="type" type="hidden" class="form-control" name="type" value="phone">
                                    <input id="mobileType" type="hidden" class="form-control" name="mobile" value="0">
                                    <input id="smsId" type="hidden" class="form-control" name="smsId">
                                    <div class="input-wrapper input-account-wrapper form-group input-phone-wrapper">
                                        <div class="input-icon-wrapper">
                                            <i class="input-icon">
                                                <svg t="1646302822533" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9067" width="1.2em" height="1.2em" fill="currentColor"><path d="M744 62H280c-35.3 0-64 28.7-64 64v768c0 35.3 28.7 64 64 64h464c35.3 0 64-28.7 64-64V126c0-35.3-28.7-64-64-64z m-8 824H288V134h448v752z" p-id="9068"></path><path d="M512 784m-40 0a40 40 0 1 0 80 0 40 40 0 1 0-80 0Z" p-id="9069"></path></svg>
                                            </i>
                                        </div>
                                        <input id="phone" type="text" class="input" name="phone" maxlength="11" placeholder="請輸入手機號">
                                    </div>
                                    <div class="code-form form-group sms-code-wrapper">
                                        <div class="input-wrapper input-sms-wrapper">
                                            <div class="input-icon-wrapper">
                                                <i class="input-icon">
                                                    <svg t="1646302879723" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9203" width="1.2em" height="1.2em" fill="currentColor"><path d="M928 160H96c-17.7 0-32 14.3-32 32v640c0 17.7 14.3 32 32 32h832c17.7 0 32-14.3 32-32V192c0-17.7-14.3-32-32-32z m-40 110.8V792H136V270.8l-27.6-21.5 39.3-50.5 42.8 33.3h643.1l42.8-33.3 39.3 50.5-27.7 21.5z" p-id="9204"></path><path d="M833.6 232L512 482 190.4 232l-42.8-33.3-39.3 50.5 27.6 21.5 341.6 265.6c20.2 15.7 48.5 15.7 68.7 0L888 270.8l27.6-21.5-39.3-50.5-42.7 33.2z" p-id="9205"></path></svg>
                                                </i>
                                            </div>
                                            <input id="code" type="text" class="input-code" name="code" maxlength="6" placeholder="請輸入驗證碼">
                                        </div>
                                        <div class="input-code-wrapper">
                                            <a id="sendBtn" href="javascript:sendCode();">獲取驗證碼</a>
                                        </div>
                                    </div>
                                    <div id="mobile-err" class="err-msg" style="width: 100%; text-align: center;"></div>
                                    <button type="submit" class="login-btn" id="loginSubmitByCode">立即登入</button>
                                </form>
                            </div>
                        </div>
                    </div>
                </div>
            </div>
        <div class="related">
            Copyrights © 2021 GitEgg All Rights Reserved. 
        </div>
    </div>
    <script type="text/javascript" th:src="@{/gitegg-oauth/assets/js/jquery-2.1.4.min.js}"></script>
    <script type="text/javascript" th:src="@{/gitegg-oauth/assets/bootstrap-4.3.1-dist/js/bootstrap.min.js}"></script>
    <script type="text/javascript" th:src="@{/gitegg-oauth/assets/bootstrap-validator-0.5.3/js/bootstrapValidator.js}"></script>
    <script type="text/javascript" th:src="@{/gitegg-oauth/assets/js/md5.js}"></script>
    <script type="text/javascript" th:src="@{/gitegg-oauth/assets/js/jquery.form.js}"></script>
    <script type="text/javascript" th:src="@{/gitegg-oauth/assets/js/login.js}"></script>
</body>
</html>

  • 自定義登入login.js程式碼
var countdown=60;
jQuery(function ($) {
    countdown = 60;

    $('.account-form').bootstrapValidator({
        message: '輸入錯誤',
        feedbackIcons: {
            valid: 'glyphicon glyphicon-ok',
            invalid: 'glyphicon glyphicon-remove',
            validating: 'glyphicon glyphicon-refresh'
        },
        fields: {
            username: {
                container: '.input-account-wrapper',
                message: '輸入錯誤',
                validators: {
                    notEmpty: {
                        message: '使用者賬號不能為空'
                    },
                    stringLength: {
                        min: 2,
                        max: 32,
                        message: '賬號長度範圍2-32個字元。'
                    },
                    regexp: {
                        regexp: /^[a-zA-Z0-9_\.]+$/,
                        message: '使用者名稱只能由字母、數字、點和下劃線組成'
                    }
                }
            },
            password: {
                container: '.input-psw-wrapper',
                validators: {
                    notEmpty: {
                        message: '密碼不能為空'
                    },
                    stringLength: {
                        min: 5,
                        max: 32,
                        message: '密碼長度範圍6-32個字元。'
                    }
                }
            }
        }
    });

    $('.mobile-form').bootstrapValidator({
        message: '輸入錯誤',
        feedbackIcons: {
            valid: 'glyphicon glyphicon-ok',
            invalid: 'glyphicon glyphicon-remove',
            validating: 'glyphicon glyphicon-refresh'
        },
        fields: {
            phone: {
                message: '輸入錯誤',
                container: '.input-phone-wrapper',
                validators: {
                    notEmpty: {
                        message: '手機號不能為空'
                    },
                    regexp: {
                        regexp: /^1\d{10}$/,
                        message: '手機號格式錯誤'
                    }
                }
            },
            code: {
                container: '.input-sms-wrapper',
                validators: {
                    notEmpty: {
                        message: '驗證碼不能為空'
                    },
                    stringLength: {
                        min: 6,
                        max: 6,
                        message: '驗證碼長度為6位。'
                    }
                }
            }
        }
    });

    var options={
        beforeSerialize: beforeFormSerialize,
        success: formSuccess,//提交成功後執行的回掉函式
        error: formError,//提交失敗後執行的回掉函式
        headers : {"TenantId" : 0},
        clearForm: true,//提交成功後是否清空表單中的欄位值
        restForm: true,//提交成功後是否充值表單中的欄位值,即恢復到頁面載入是的狀態
        timeout: 6000//設定請求時間,超過時間後,自動退出請求,單位(毫秒)
    }

    var mobileOptions={
        success: mobileFormSuccess,//提交成功後執行的回掉函式
        error: mobileFormError,//提交失敗後執行的回掉函式
        headers : {"TenantId" : 0},
        clearForm: true,//提交成功後是否清空表單中的欄位值
        restForm: true,//提交成功後是否充值表單中的欄位值,即恢復到頁面載入是的狀態
        timeout: 6000//設定請求時間,超過時間後,自動退出請求,單位(毫秒)
    }

    function beforeFormSerialize(){
        $("#account-err").html("");
        $("#username").val($.trim($("#username").val()));
        $("#password").val($.md5($.trim($("#password").val())));
    }

    function formSuccess(response){
        $(".account-form").data('bootstrapValidator').resetForm();
        if (response.success)
        {
            window.location.href = response.targetUrl;
        }
        else
        {
            $("#account-err").html(response.message);
        }
    }


    function formError(response){
        $("#account-err").html(response);
    }

    function mobileFormSuccess(response){
        $(".mobile-form").data('bootstrapValidator').resetForm();
        if (response.success)
        {
            window.location.href = response.targetUrl;
        }
        else
        {
            $("#mobile-err").html(response.message);
        }
    }


    function mobileFormError(response){
        $("#mobile-err").html(response);
    }

    $(".account-form").ajaxForm(options);

    $(".mobile-form").ajaxForm(mobileOptions);

    $(".nav-left a").click(function(e){
        $(".account-login").show();
        $(".mobile-login").hide();
    });

    $(".nav-right a").click(function(e){
        $(".account-login").hide();
        $(".mobile-login").show();
    });

    $("#forget").click(function(e){
        window.location.href = "/find/pwd";
    });

    $('.title-list li').click(function(){
        var liindex = $('.title-list li').index(this);
        $(this).addClass('on').siblings().removeClass('on');
        $('.login_right div.login-form-container').eq(liindex).fadeIn(150).siblings('div.login-form-container').hide();
        var liWidth = $('.title-list li').width();

        if (liindex == 0)
        {
            $('.login_right .title-list p').css("transform","translate3d(0px, 0px, 0px)");
        }
        else {
            $('.login_right .title-list p').css("transform","translate3d("+liWidth+"px, 0px, 0px)");
        }

    });

});

function sendCode(){
    $(".mobile-form").data('bootstrapValidator').validateField('phone');
    if(!$(".mobile-form").data('bootstrapValidator').isValidField("phone"))
    {
        return;
    }

    if(countdown != 60)
    {
        return;
    }
    sendmsg();
    var phone = $.trim($("#phone").val());
    var tenantId = $("#tenantId").val();
    $.ajax({
        //請求方式
        type : "POST",
        //請求的媒體型別
        contentType: "application/x-www-form-urlencoded;charset=UTF-8",
        dataType: 'json',
        //請求地址
        url : "/code/sms/login",
        //資料,json字串
        data : {
            tenantId: tenantId,
            phoneNumber: phone,
            code: "aliValidateLogin"
        },
        //請求成功
        success : function(result) {
            $("#smsId").val(result.data);
        },
        //請求失敗,包含具體的錯誤資訊
        error : function(e){
            console.log(e);
        }
    });
};

function sendmsg(){
    if(countdown==0){
        $("#sendBtn").css("color","#181818");
        $("#sendBtn").html("獲取驗證碼");
        countdown=60;
        return false;
    }
    else{
        $("#sendBtn").css("color","#74777b");
        $("#sendBtn").html("重新傳送("+countdown+")");
        countdown--;
    }
    setTimeout(function(){
        sendmsg();
    },1000);
}

2、授權伺服器配置
  • 修改web安全配置WebSecurityConfig,將靜態檔案新增到不需要授權就能訪問
    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring().antMatchers("/assets/**", "/css/**", "/images/**");
    }
  • 修改Nacos配置,將新增頁面訪問路徑新增到訪問白名單,使資源伺服器配置ResourceServerConfig中的配置不進行鑑權就能夠訪問,同時增加tokenUrls配置,此配置在閘道器不進行鑑權,但是需要OAuth2進行Basic鑑權,授權碼模式必須要用到此鑑權。
# 以下配置為新增
  whiteUrls:
    - "/gitegg-oauth/oauth/login"
    - "/gitegg-oauth/oauth/find/pwd"
    - "/gitegg-oauth/oauth/error"
  authUrls:
    - "/gitegg-oauth/oauth/index"
  whiteUrls:
    - "/*/v2/api-docs"
    - "/gitegg-oauth/oauth/public_key"
    - "/gitegg-oauth/oauth/token_key"
    - "/gitegg-oauth/find/pwd"
    - "/gitegg-oauth/code/sms/login"
    - "/gitegg-oauth/change/password"
    - "/gitegg-oauth/error"
    - "/gitegg-oauth/oauth/sms/captcha/send"
  # 新增OAuth2認證介面,此處閘道器放行,由認證中心進行認證
  tokenUrls:
    - "/gitegg-oauth/oauth/token"
  • 因GitEgg框架使用使用者名稱+密碼再加密儲存的密碼,所以這裡需要自定義登入過濾器來做相應處理,也可以用同樣的方式新增手機驗證碼登入、掃碼登入等功能。
package com.gitegg.oauth.filter;

import cn.hutool.core.bean.BeanUtil;
import com.gitegg.oauth.token.PhoneAuthenticationToken;
import com.gitegg.platform.base.constant.AuthConstant;
import com.gitegg.platform.base.domain.GitEggUser;
import com.gitegg.platform.base.result.Result;
import com.gitegg.service.system.client.feign.IUserFeign;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.authentication.AuthenticationServiceException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.util.StringUtils;


import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

/**
 * 自定義登陸
 * @author GitEgg
 */
public class GitEggLoginAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

    public static final String SPRING_SECURITY_RESTFUL_TYPE_PHONE = "phone";

    public static final String SPRING_SECURITY_RESTFUL_TYPE_QR = "qr";

    public static final String SPRING_SECURITY_RESTFUL_TYPE_DEFAULT = "user";

    //  登陸型別:user:使用者密碼登陸;phone:手機驗證碼登陸;qr:二維碼掃碼登陸
    private static final String SPRING_SECURITY_RESTFUL_TYPE_KEY = "type";

    //  登陸終端:1:移動端登陸,包括微信公眾號、小程式等;0:PC後臺登陸
    private static final String SPRING_SECURITY_RESTFUL_MOBILE_KEY = "mobile";

    private static final String SPRING_SECURITY_RESTFUL_USERNAME_KEY = "username";

    private static final String SPRING_SECURITY_RESTFUL_PASSWORD_KEY = "password";

    private static final String SPRING_SECURITY_RESTFUL_PHONE_KEY = "phone";

    private static final String SPRING_SECURITY_RESTFUL_VERIFY_CODE_KEY = "code";

    private static final String SPRING_SECURITY_RESTFUL_QR_CODE_KEY = "qrCode";

    @Autowired
    private IUserFeign userFeign;

    private boolean postOnly = true;

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {

        if (postOnly && !"POST".equals(request.getMethod())) {
            throw new AuthenticationServiceException(
                    "Authentication method not supported: " + request.getMethod());
        }

        String type = obtainParameter(request, SPRING_SECURITY_RESTFUL_TYPE_KEY);
        String mobile = obtainParameter(request, SPRING_SECURITY_RESTFUL_MOBILE_KEY);
        AbstractAuthenticationToken authRequest;
        String principal;
        String credentials;

        // 手機驗證碼登陸
        if(SPRING_SECURITY_RESTFUL_TYPE_PHONE.equals(type)){
            principal = obtainParameter(request, SPRING_SECURITY_RESTFUL_PHONE_KEY);
            credentials = obtainParameter(request, SPRING_SECURITY_RESTFUL_VERIFY_CODE_KEY);

            principal = principal.trim();
            authRequest = new PhoneAuthenticationToken(principal, credentials);
        }
        // 賬號密碼登陸
        else {
            principal = obtainParameter(request, SPRING_SECURITY_RESTFUL_USERNAME_KEY);
            credentials = obtainParameter(request, SPRING_SECURITY_RESTFUL_PASSWORD_KEY);

            Result<Object> result = userFeign.queryUserByAccount(principal);
            if (null != result && result.isSuccess()) {
                GitEggUser gitEggUser = new GitEggUser();
                BeanUtil.copyProperties(result.getData(), gitEggUser, false);
                if (!StringUtils.isEmpty(gitEggUser.getAccount())) {
                    principal = gitEggUser.getAccount();
                    credentials = AuthConstant.BCRYPT + gitEggUser.getAccount() + credentials;
                }
            }
            authRequest = new UsernamePasswordAuthenticationToken(principal, credentials);
        }

        // Allow subclasses to set the "details" property
        setDetails(request, authRequest);
        return this.getAuthenticationManager().authenticate(authRequest);
    }

    private void setDetails(HttpServletRequest request,
                            AbstractAuthenticationToken authRequest) {
        authRequest.setDetails(authenticationDetailsSource.buildDetails(request));
    }

    private String obtainParameter(HttpServletRequest request, String parameter) {
        String result =  request.getParameter(parameter);
        return result == null ? "" : result;
    }
}

四、實現單點登入客戶端

   spring-security-oauth2提供OAuth2授權伺服器的同時也提供了單點登入客戶端的實現,通用通過幾行註解即可實現單點登入功能。
1、新建單點登入客戶端工程,引入oauth2客戶端相關jar包

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-oauth2-client</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.security.oauth.boot</groupId>
            <artifactId>spring-security-oauth2-autoconfigure</artifactId>
        </dependency>

2、新建WebSecurityConfig類,新增@EnableOAuth2Sso註解

@EnableOAuth2Sso
@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
            http.authorizeRequests()
                    .anyRequest().authenticated()
                    .and()
                    .csrf().disable();
    }
}

3、配置單點登入服務端相關資訊

server:
  port: 8080
  servlet:
    context-path: /ssoclient1
security:
  oauth2:
    client:
      # 配置在授權伺服器配置的客戶端id和secret
      client-id: ssoclient
      client-secret: 123456
      # 獲取token的url
      access-token-uri: http://127.0.0.1/gitegg-oauth/oauth/token
      # 授權伺服器的授權地址
      user-authorization-uri: http://127.0.0.1/gitegg-oauth/oauth/authorize
    resource:
      jwt:
        # 獲取公鑰的地址,驗證token需使用,系統啟動時會初始化,不會每次驗證都請求
        key-uri: http://127.0.0.1/gitegg-oauth/oauth/token_key

備註:

1、GitEgg框架中自定義了token返回格式,SpringSecurity獲取token的/oauth/token預設返回的是ResponseEntity,自有系統登入和單點登入時需要做轉換處理。

2、Gateway閘道器鑑權需要的公鑰地址是gitegg-oauth/oauth/public_key,單點登入客戶端需要公鑰地址
/oauth/token_key,兩者返回的格式不一樣,需注意區分。

3、請求/oauth/tonen和/oauth/token_key時,預設都需要使用Basic認證,也就是請求時需新增client_id和client_security引數。

原始碼地址:

Gitee: https://gitee.com/wmz1930/GitEgg

GitHub: https://github.com/wmz1930/GitEgg