基於SpringBoot搭建應用開發框架(二) —— 登入認證

零、前言

本文基於《基於SpringBoot搭建應用開發框架(一)——基礎架構》,通過該文,熟悉了SpringBoot的用法,完成了應用框架底層的搭建。

在開始本文之前,底層這塊已經有了很大的調整,主要是SpringBoot由之前的 1.5.9.RELEASE 升級至 2.1.0.RELEASE 版本,其它依賴的三方包基本也都升級到目前最新版了。

其次是整體架構上也做了調整:

  sunny-parent:sunny 專案的頂級父類,sunny-parent 又繼承自 spring-boot-starter-parent ,為所有專案統一 spring 及 springboot 版本。同時,管理專案中將用到的大部分的第三方包,統一管理版本號。

  sunny-starter:專案中開發的元件以 starter 的方式進行整合,按需引入 starter 即可。sunny-starter 下以 module 的形式組織,便於管理、批量打包部署。

    sunny-starter-core:核心包,定義基礎的操作類、異常封裝、工具類等,集成了 mybatis-mapper、druid 資料來源、redis 等。

    sunny-starter-captcha:驗證碼封裝。

  sunny-cloud:spring-cloud 系列服務,微服務基礎框架,本篇文章主要集中在 sunny-cloud-security上,其它的以後再說。

    sunny-cloud-security:認證服務和授權服務。

  sunny-admin:管理端服務,業務中心。

  

 

本篇將會一步步完成系統的登入認證,包括常規的使用者名稱+密碼登入、以及社交方式登入,如QQ、微信授權登入等,一步步分析 spring-security 及 oauth 相關的原始碼。

一、SpringSecurity 簡介

SpringSecurity 是專門針對基於Spring專案的安全框架,充分利用了AOP和Filter來實現安全功能。它提供全面的安全性解決方案,同時在 Web 請求級和方法呼叫級處理身份確認和授權。他提供了強大的企業安全服務,如:認證授權機制、Web資源訪問控制、業務方法呼叫訪問控制、領域物件訪問控制Access Control List(ACL)、單點登入(SSO)等等。

核心功能:認證(你是誰)、授權(你能幹什麼)、攻擊防護(防止偽造身份)。

基本原理:SpringSecurity的核心實質是一個過濾器鏈,即一組Filter,所有的請求都會經過這些過濾器,然後響應返回。每個過濾器都有特定的職責,可通過配置新增、刪除過濾器。過濾器的排序很重要,因為它們之間有依賴關係。有些過濾器也不能刪除,如處在過濾器鏈最後幾環的ExceptionTranslationFilter(處理後者丟擲的異常),FilterSecurityInterceptor(最後一環,根據配置決定請求能不能訪問服務)。

二、標準登入

使用 使用者名稱+密碼 的方式來登入,使用者名稱、密碼儲存在資料庫,並且支援密碼輸入錯誤三次後開啟驗證碼,通過這樣一個過程來熟悉 spring security 的認證流程,掌握 spring security 的原理。

1、基礎環境

① 建立 sunny-cloud-security 模組,埠號設定為 8010,在sunny-cloud-security模組引入security支援以及sunny-starter-core:

② 開發一個TestController

 ③ 不做任何配置,啟動系統,然後訪問 localhost:8010/test 時,會自動跳轉到SpringSecurity預設的登入頁面去進行認證。那這登入的使用者名稱和密碼從哪來呢?

啟動專案時,從控制檯輸出中可以找到生成的 security 密碼,從 UserDetailsServiceAutoConfiguration 可以得知,使用的是基於記憶體的使用者管理器,預設的使用者名稱為 user,密碼是隨機生成的UUID。

我們也可以修改預設的使用者名稱和密碼。

④ 使用 user 和生成的UUID密碼登入成功後即可訪問 /test 資源,最簡單的一個認證就完成了。

在不做任何配置的情況下,security會把服務內所有資源的訪問都保護起來,需要先進行身份證認證才可訪問, 使用預設的表單登入或http basic認證方式。

不過這種預設方式肯定無法滿足我們的需求,我們的使用者名稱和密碼都是存在資料庫的。下面我們就來看看在 spring boot 中我們如何去配置自己的登入頁面以及從資料庫獲取使用者資料來完成使用者登入。

2、自定義登入頁面

① 首先開發一個登入頁面,由於頁面中會使用到一些動態資料,決定使用 thymeleaf 模板引擎,只需在 pom 中引入如下依賴,使用預設配置即可,具體有哪些配置可從 ThymeleafProperties 中瞭解到。

② 同時,在 resources 目錄下,建 static 和 templates 兩個目錄,static 目錄用於存放靜態資源,templates 用於存放 thymeleaf 模板頁面,同時配置MVC的靜態資源對映。

   

③ 開發後臺首頁、登入頁面的跳轉地址,/login 介面用於向登入頁面傳遞登入相關的資料,如使用者名稱、是否啟用驗證碼、錯誤訊息等。

 1 package com.lyyzoo.sunny.security.controller;
 2 
 3 import javax.servlet.http.HttpServletResponse;
 4 import javax.servlet.http.HttpSession;
 5 
 6 import org.apache.commons.lang3.StringUtils;
 7 import org.springframework.beans.factory.annotation.Autowired;
 8 import org.springframework.security.web.WebAttributes;
 9 import org.springframework.stereotype.Controller;
10 import org.springframework.ui.Model;
11 import org.springframework.web.bind.annotation.GetMapping;
12 import org.springframework.web.bind.annotation.RequestMapping;
13 import org.springframework.web.bind.annotation.ResponseBody;
14 
15 import com.lyyzoo.sunny.captcha.CaptchaImageHelper;
16 import com.lyyzoo.sunny.core.base.Result;
17 import com.lyyzoo.sunny.core.message.MessageAccessor;
18 import com.lyyzoo.sunny.core.userdetails.CustomUserDetails;
19 import com.lyyzoo.sunny.core.userdetails.DetailsHelper;
20 import com.lyyzoo.sunny.core.util.Results;
21 import com.lyyzoo.sunny.security.constant.SecurityConstants;
22 import com.lyyzoo.sunny.security.domain.entity.User;
23 import com.lyyzoo.sunny.security.domain.service.ConfigService;
24 import com.lyyzoo.sunny.security.domain.service.UserService;
25 
26 /**
27  *
28  * @author bojiangzhou 2018/03/28
29  */
30 @Controller
31 public class SecurityController {
32 
33     private static final String LOGIN_PAGE = "login";
34 
35     private static final String INDEX_PAGE = "index";
36 
37     private static final String FIELD_ERROR_MSG = "errorMsg";
38     private static final String FIELD_ENABLE_CAPTCHA = "enableCaptcha";
39 
40     @Autowired
41     private CaptchaImageHelper captchaImageHelper;
42     @Autowired
43     private UserService userService;
44     @Autowired
45     private ConfigService configService;
46 
47     @RequestMapping("/index")
48     public String index() {
49         return INDEX_PAGE;
50     }
51 
52     @GetMapping("/login")
53     public String login(HttpSession session, Model model) {
54         String errorMsg = (String) session.getAttribute(WebAttributes.AUTHENTICATION_EXCEPTION);
55         String username = (String) session.getAttribute(User.FIELD_USERNAME);
56         if (StringUtils.isNotBlank(errorMsg)) {
57             model.addAttribute(FIELD_ERROR_MSG, errorMsg);
58         }
59         if (StringUtils.isNotBlank(username)) {
60             model.addAttribute(User.FIELD_USERNAME, username);
61             User user = userService.getUserByUsername(username);
62             if (user == null) {
63                 model.addAttribute(FIELD_ERROR_MSG, MessageAccessor.getMessage("login.username-or-password.error"));
64             } else {
65                 if (configService.isEnableCaptcha(user.getPasswordErrorTime())) {
66                     model.addAttribute(FIELD_ENABLE_CAPTCHA, true);
67                 }
68             }
69         }
70         session.removeAttribute(WebAttributes.AUTHENTICATION_EXCEPTION);
71 
72         return LOGIN_PAGE;
73     }
74 
75     @GetMapping("/public/captcha.jpg")
76     public void captcha(HttpServletResponse response) {
77         captchaImageHelper.generateAndWriteCaptchaImage(response, SecurityConstants.SECURITY_KEY);
78     }
79 
80     @GetMapping("/user/self")
81     @ResponseBody
82     public Result test() {
83         CustomUserDetails details = DetailsHelper.getUserDetails();
84 
85         return Results.successWithData(details);
86     }
87 
88 }
View Code

 ④  從 spring boot 官方文件可以得知,spring security 的核心配置都在 WebSecurityConfigurerAdapter 裡,我們只需繼承該介面卡覆蓋預設配置即可。首先來看看預設的登入頁面以及如何配置登入頁面。

通過 HttpSecurity 配置安全策略,首先開放了允許匿名訪問的地址,除此之外都需要認證,通過 formLogin() 來啟用表單登入,並配置了預設的登入頁面,以及登入成功後的首頁地址。

啟動系統,訪問資源跳轉到自定義的登入頁面了:

⑤ 那麼預設的登入頁面是怎麼來的呢,以及做了哪些預設配置?

從 formLogin() 可以看出,啟用表單登入即啟用了表單登入的配置 FormLoginConfigurer:

從 FormLoginConfigurer 的建構函式中可以看出,表單登入使用者名稱和密碼的引數預設配置為 username 和 password,所以,我們的登入頁面中需和這兩個引數配置成一樣,當然了,我們也可以在 formLogin() 後自定義這兩個引數。

同時,可以看出開啟了 UsernamePasswordAuthenticationFilter 過濾器,用於 使用者名稱+密碼 登入方式的認證,這個之後再說明。

從初始化配置中可以看出,預設建立了 DefaultLoginPageGeneratingFilter 過濾器用於生成預設的登入頁面,從該過濾器的初始化方法中我們也可以瞭解到一些預設的配置。這個過濾器只有在未配置自定義登入頁面時才會生效。

3、SpringSecurity基本原理

在進行後面的開發前,先來了解下 spring security 的基本原理。

spring security 的核心是過濾器鏈,即一組 Filter。所有服務資源的請求都會經過 spring security 的過濾器鏈,並響應返回。

我們從控制檯中可以找到輸出過濾器鏈的類 DefaultSecurityFilterChain,在現有的配置上,可以看到當前過濾器鏈共有13個過濾器。

每個過濾器主要做什麼可以參考:Spring Security 核心過濾器鏈分析

過濾器鏈的建立是通過 HttpSecurity 的配置而來,實際上,每個 HttpSecurity 的配置都會建立相應的過濾器鏈來處理對應的請求,每個請求都會進入 FilterChainProxy 過濾器,根據請求選擇一個合適的過濾器鏈來處理該請求。

過濾器的順序我們可以從 FilterComparator 中得知,並且可以看出 spring security 預設有25個過濾器(自行檢視):

 不難發現,幾乎所有的過濾器都直接或間接繼承自 GenericFilterBean,通過這個基礎過濾器可以看到都有哪些過濾器,通過每個過濾器的名稱我們能大概瞭解到 spring security 為我們提供了哪些功能,要啟用這些功能,只需通過配置加入相應的過濾器即可,比如 oauth 認證。

過濾器鏈中,綠色框出的這類過濾器主要用於使用者認證,這些過濾器會根據當前的請求檢查是否有這個過濾器所需的資訊,如果有則進入該過濾器,沒有則不會進入下一個過濾器。

比如這裡,如果是表單登入,要求必須是[POST /login],則進入 UsernamePasswordAuthenticationFilter 過濾器,使用使用者名稱和密碼進行認證,不會再進入BasicAuthenticationFilter;

如果使用 http basic 的方式進行認證,要求請求頭必須包含 Authorization,且值以 basic 打頭,則進入 BasicAuthenticationFilter 進行認證。

經過前面的過濾器後,最後會進入到 FilterSecurityInterceptor,這是整個 spring security 過濾器鏈的最後一環,在它身後就是服務的API。

這個過濾器會去根據配置決定當前的請求能不能訪問真正的資源,主要一些實現功能在其父類AbstractSecurityInterceptor中。

[1] 拿到的是許可權配置,會根據這些配置決定訪問的API能否通過。

[2] 當前上下文必須有使用者認證資訊 Authentication,就算是匿名訪問也會有相應的過濾器來生成 Authentication。不難發現,不同型別的認證過濾器對應了不同的 Authentication。使用使用者名稱和密碼登入時,就會生成 UsernamePasswordAuthenticationToken。

[3] 使用者認證,首先判斷使用者是否已認證通過,認證通過則直接返回 Authentication,否則呼叫認證器進行認證。認證通過之後將 Authentication 放到 Security 的上下文,這就是為何我們能從 SecurityContextHolder 中取到 Authentication 的源頭。

認證管理器是預設配置的 ProviderManager,ProviderManager 則管理者多個 AuthenticationProvider 認證器 ,認證的時候,只要其中一個認證器認證通過,則標識認證通過。

認證器:表單登入預設使用 DaoAuthenticationProvider,我們想要實現從資料庫獲取使用者名稱和密碼就得從這裡入手。

[4] 認證通過後,使用許可權決定管理器 AccessDecisionManager 判斷是否有許可權,管理器則管理者多個 許可權投票器 AccessDecisionVoter,通過投票器來決定是否有許可權訪問資源。因此,我們也可以自定義投票器來判斷使用者是否有許可權訪問某個API。

 

最後,如果未認證通過或沒有許可權,FilterSecurityInterceptor 則丟擲相應的異常,異常會被 ExceptionTranslationFilter 捕捉到,進行統一的異常處理分流,比如未登入時,重定向到登入頁面;沒有許可權的時候丟擲403異常等。

4、使用者認證流程

從 spring security 基本原理的分析中不難發現,使用者的認證過程涉及到三個主要的元件:

AbstractAuthenticationProcessingFilter:它在基於web的認證請求中用於處理包含認證資訊的請求,建立一個部分完整的Authentication物件以在鏈中傳遞憑證資訊。

AuthenticationManager:它用來校驗使用者的憑證資訊,或者會丟擲一個特定的異常(校驗失敗的情況)或者完整填充Authentication物件,將會包含了許可權資訊。

AuthenticationProvider:它為AuthenticationManager提供憑證校驗。一些AuthenticationProvider的實現基於憑證資訊的儲存,如資料庫,來判定憑證資訊是否可以被認可。

我們從核心的 AbstractAuthenticationProcessingFilter 入手,來分析下使用者認證的流程。

[1] 可以看到,首先會呼叫 attemptAuthentication 來獲取認證後的 Authentication。attemptAuthentication 是一個抽象方法,在其子類中實現。

 前面提到過,啟用表單登入時,就會建立 UsernamePasswordAuthenticationFilter 用於處理表單登入。後面開發 oauth2 認證的時候則會用到 OAuth2 相關的過濾器。

從 attemptAuthentication 的實現中可以看出,主要是將 username 和 password 封裝到 UsernamePasswordAuthenticationToken。

從當前 UsernamePasswordAuthenticationToken 的構造方法中可以看出,此時的 Authentication 設定了未認證狀態。

 【#】通過 setDetails 可以向 UsernamePasswordAuthenticationToken  中加入 Details 用於後續流程的處理,稍後我會實現AuthenticationDetailsSource 將驗證碼放進去用於後面的認證。

之後,通過 AuthenticationManager 進行認證,實際是 ProviderManager 管理著一些認證器,這些配置都可以通過 setter 方法找到相應配置的位置,這裡就不贅述了。

不難發現,使用者認證器使用的是 AbstractUserDetailsAuthenticationProvider,流程主要涉及到 retrieveUser  和 additionalAuthenticationChecks 兩個抽象方法。

【#】AbstractUserDetailsAuthenticationProvider 預設只有一個實現類 DaoAuthenticationProvider,獲取使用者資訊、使用者密碼校驗都是在這個實現類裡,因此我們也可以實現自己的 AbstractUserDetailsAuthenticationProvider 來處理相關業務。

【#】從 retrieveUser 中可以發現,主要使用 UserDetailsService 來獲取使用者資訊,該介面只有一個方法 loadUserByUsername,我們也會實現該介面來從資料庫獲取使用者資訊。如果有複雜的業務邏輯,比如鎖定使用者等,還可以覆蓋 retrieveUser 方法。

 使用者返回成功後,就會通過 PasswordEncoder 來校驗使用者輸入的密碼和資料庫密碼是否匹配。注意資料庫存入的密碼是加密後的密碼,且不可逆。

 使用者、密碼都校驗通過後,就會建立已認證的 Authentication,從此時 UsernamePasswordAuthenticationToken 的構造方法可以看出,構造的是一個已認證的 Authentication。

[2] 如果使用者認證失敗,會呼叫 AuthenticationFailureHandler 的 onAuthenticationFailure 方法進行認證失敗後的處理,我們也會實現這個介面來做一些失敗後邏輯處理。

[3] 使用者認證成功,將 Authentication 放入 security 上下文,呼叫 AuthenticationSuccessHandler 做認證成功的一些後續邏輯處理,我們也會實現這個介面。

5、使用者認證程式碼實現

通過 spring security 基本原理分析和使用者認證流程分析,我們已經能夠梳理出完成認證需要做哪些工作了。

① 首先設計並建立系統使用者表:

② CustomUserDetails

自定義 UserDetails,根據自己的需求將一些常用的使用者資訊封裝到 UserDetails 中,便於快速獲取使用者資訊,比如使用者ID、暱稱等。

 1 package com.lyyzoo.sunny.core.userdetails;
 2 
 3 import java.util.Collection;
 4 import java.util.Objects;
 5 
 6 import org.springframework.security.core.GrantedAuthority;
 7 import org.springframework.security.core.userdetails.User;
 8 
 9 
10 /**
11  * 定製的UserDetail物件
12  *
13  * @author bojiangzhou 2018/09/02
14  */
15 public class CustomUserDetails extends User {
16     private static final long serialVersionUID = -4461471539260584625L;
17 
18     private Long userId;
19 
20     private String nickname;
21 
22     private String language;
23 
24     public CustomUserDetails(String username, String password, Long userId, String nickname, String language,
25                              Collection<? extends GrantedAuthority> authorities) {
26         super(username, password, authorities);
27         this.userId = userId;
28         this.nickname = nickname;
29         this.language = language;
30     }
31 
32     public Long getUserId() {
33         return userId;
34     }
35 
36     public void setUserId(Long userId) {
37         this.userId = userId;
38     }
39 
40     public String getNickname() {
41         return nickname;
42     }
43 
44     public void setNickname(String nickname) {
45         this.nickname = nickname;
46     }
47 
48     public String getLanguage() {
49         return language;
50     }
51 
52     public void setLanguage(String language) {
53         this.language = language;
54     }
55 
56     @Override
57     public boolean equals(Object o) {
58         if (this == o) {
59             return true;
60         }
61         if (!(o instanceof CustomUserDetails)) {
62             return false;
63         }
64         if (!super.equals(o)) {
65             return false;
66         }
67 
68         CustomUserDetails that = (CustomUserDetails) o;
69 
70         if (!Objects.equals(userId, that.userId)) {
71             return false;
72         }
73         return false;
74     }
75 
76     @Override
77     public int hashCode() {
78         int result = super.hashCode();
79         result = 31 * result + userId.hashCode();
80         result = 31 * result + nickname.hashCode();
81         result = 31 * result + language.hashCode();
82         return result;
83     }
84 
85 }
View Code

③ CustomUserDetailsService

自定義 UserDetailsService 來從資料庫獲取使用者資訊,並將使用者資訊封裝到 CustomUserDetails

 1 package com.lyyzoo.sunny.security.core;
 2 
 3 import java.util.ArrayList;
 4 import java.util.Collection;
 5 
 6 import org.springframework.beans.factory.annotation.Autowired;
 7 import org.springframework.security.core.GrantedAuthority;
 8 import org.springframework.security.core.authority.SimpleGrantedAuthority;
 9 import org.springframework.security.core.userdetails.UserDetails;
10 import org.springframework.security.core.userdetails.UserDetailsService;
11 import org.springframework.security.core.userdetails.UsernameNotFoundException;
12 import org.springframework.stereotype.Component;
13 
14 import com.lyyzoo.sunny.core.message.MessageAccessor;
15 import com.lyyzoo.sunny.core.userdetails.CustomUserDetails;
16 import com.lyyzoo.sunny.security.domain.entity.User;
17 import com.lyyzoo.sunny.security.domain.service.UserService;
18 
19 /**
20  * 載入使用者資訊實現類
21  *
22  * @author bojiangzhou 2018/03/25
23  */
24 @Component
25 public class CustomUserDetailsService implements UserDetailsService {
26 
27     @Autowired
28     private UserService userService;
29 
30     @Override
31     public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
32         User user = userService.getUserByUsername(username);
33         if (user == null) {
34             throw new UsernameNotFoundException(MessageAccessor.getMessage("login.username-or-password.error"));
35         }
36 
37         Collection<GrantedAuthority> authorities = new ArrayList<>();
38         authorities.add(new SimpleGrantedAuthority("ROLE_USER"));
39 
40         return new CustomUserDetails(username, user.getPassword(), user.getId(),
41                 user.getNickname(), user.getLanguage(), authorities);
42     }
43 
44 }
View Code

④ CustomWebAuthenticationDetails

自定義 WebAuthenticationDetails 用於封裝傳入的驗證碼以及快取的驗證碼,用於後續校驗。

  1 package com.lyyzoo.sunny.security.core;
  2 
  3 import javax.servlet.http.HttpServletRequest;
  4 
  5 import com.lyyzoo.sunny.captcha.CaptchaResult;
  6 import org.springframework.security.web.authentication.WebAuthenticationDetails;
  7 
  8 /**
  9  * 封裝驗證碼
 10  *
 11  * @author bojiangzhou 2018/09/18
 12  */
 13 public class CustomWebAuthenticationDetails extends WebAuthenticationDetails {
 14 
 15     public static final String FIELD_CACHE_CAPTCHA = "cacheCaptcha";
 16 
 17     private String inputCaptcha;
 18     private String cacheCaptcha;
 19 
 20     public CustomWebAuthenticationDetails(HttpServletRequest request) {
 21         super(request);
 22         cacheCaptcha = (String) request.getAttribute(FIELD_CACHE_CAPTCHA);
 23         inputCaptcha = request.getParameter(CaptchaResult.FIELD_CAPTCHA);
 24     }
 25 
 26     public String getInputCaptcha() {
 27         return inputCaptcha;
 28     }
 29 
 30     public String getCacheCaptcha() {
 31         return cacheCaptcha;
 32     }
 33 
 34     @Override
 35     public boolean equals(Object object) {
 36         if (this == object) {
 37             return true;
 38         }
 39         if (object == null || getClass() != object.getClass()) {
 40             return false;
 41         }
 42         if (!super.equals(object)) {
 43             return false;
 44         }
 45 
 46         CustomWebAuthenticationDetails that = (CustomWebAuthenticationDetails) object;
 47 
 48         return inputCaptcha != null ? inputCaptcha.equals(that.inputCaptcha) : that.inputCaptcha == null;
 49     }
 50 
 51     @Override
 52     public int hashCode() {
 53         int result = super.hashCode();
 54         result = 31 * result + (inputCaptcha != null ? inputCaptcha.hashCode() : 0);
 55         return result;
 56     }
 57 }
 58 package com.lyyzoo.sunny.security.core;
 59 
 60 import javax.servlet.http.HttpServletRequest;
 61 
 62 import com.lyyzoo.sunny.captcha.CaptchaResult;
 63 import org.springframework.security.web.authentication.WebAuthenticationDetails;
 64 
 65 /**
 66  * 封裝驗證碼
 67  *
 68  * @author bojiangzhou 2018/09/18
 69  */
 70 public class CustomWebAuthenticationDetails extends WebAuthenticationDetails {
 71 
 72     public static final String FIELD_CACHE_CAPTCHA = "cacheCaptcha";
 73 
 74     private String inputCaptcha;
 75     private String cacheCaptcha;
 76 
 77     public CustomWebAuthenticationDetails(HttpServletRequest request) {
 78         super(request);
 79         cacheCaptcha = (String) request.getAttribute(FIELD_CACHE_CAPTCHA);
 80         inputCaptcha = request.getParameter(CaptchaResult.FIELD_CAPTCHA);
 81     }
 82 
 83     public String getInputCaptcha() {
 84         return inputCaptcha;
 85     }
 86 
 87     public String getCacheCaptcha() {
 88         return cacheCaptcha;
 89     }
 90 
 91     @Override
 92     public boolean equals(Object object) {
 93         if (this == object) {
 94             return true;
 95         }
 96         if (object == null || getClass() != object.getClass()) {
 97             return false;
 98         }
 99         if (!super.equals(object)) {
100             return false;
101         }
102 
103         CustomWebAuthenticationDetails that = (CustomWebAuthenticationDetails) object;
104 
105         return inputCaptcha != null ? inputCaptcha.equals(that.inputCaptcha) : that.inputCaptcha == null;
106     }
107 
108     @Override
109     public int hashCode() {
110         int result = super.hashCode();
111         result = 31 * result + (inputCaptcha != null ? inputCaptcha.hashCode() : 0);
112         return result;
113     }
114 }
View Code

⑤ CustomAuthenticationDetailsSource

當然了,還需要一個構造驗證碼的 AuthenticationDetailsSource

 1 package com.lyyzoo.sunny.security.core;
 2 
 3 import javax.servlet.http.HttpServletRequest;
 4 
 5 import org.springframework.beans.factory.annotation.Autowired;
 6 import org.springframework.security.authentication.AuthenticationDetailsSource;
 7 import org.springframework.security.web.authentication.WebAuthenticationDetails;
 8 import org.springframework.stereotype.Component;
 9 
10 import com.lyyzoo.sunny.captcha.CaptchaImageHelper;
11 import com.lyyzoo.sunny.security.constant.SecurityConstants;
12 
13 /**
14  * 自定義獲取AuthenticationDetails 用於封裝傳進來的驗證碼
15  *
16  * @author bojiangzhou 2018/09/18
17  */
18 @Component
19 public class CustomAuthenticationDetailsSource implements AuthenticationDetailsSource<HttpServletRequest, WebAuthenticationDetails> {
20 
21     @Autowired
22     private CaptchaImageHelper captchaImageHelper;
23 
24     @Override
25     public WebAuthenticationDetails buildDetails(HttpServletRequest request) {
26         String cacheCaptcha = captchaImageHelper.getCaptcha(request, SecurityConstants.SECURITY_KEY);
27         request.setAttribute(CustomWebAuthenticationDetails.FIELD_CACHE_CAPTCHA, cacheCaptcha);
28         return new CustomWebAuthenticationDetails(request);
29     }
30 
31 }
View Code

⑥ CustomAuthenticationProvider

自定義認證處理器,主要加入了驗證碼的檢查,如果使用者密碼輸入錯誤三次以上,則需要驗證碼。

 1 package com.lyyzoo.sunny.security.core;
 2 
 3 import org.apache.commons.lang3.StringUtils;
 4 import org.springframework.beans.factory.annotation.Autowired;
 5 import org.springframework.security.authentication.AuthenticationServiceException;
 6 import org.springframework.security.authentication.BadCredentialsException;
 7 import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
 8 import org.springframework.security.authentication.dao.AbstractUserDetailsAuthenticationProvider;
 9 import org.springframework.security.core.AuthenticationException;
10 import org.springframework.security.core.userdetails.UserDetails;
11 import org.springframework.security.crypto.password.PasswordEncoder;
12 import org.springframework.stereotype.Component;
13 
14 import com.lyyzoo.sunny.security.domain.entity.User;
15 import com.lyyzoo.sunny.security.domain.service.ConfigService;
16 import com.lyyzoo.sunny.security.domain.service.UserService;
17 
18 /**
19  * 自定義認證器
20  *
21  * @author bojiangzhou 2018/09/09
22  */
23 @Component
24 public class CustomAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider {
25 
26     @Autowired
27     private UserService userService;
28     @Autowired
29     private CustomUserDetailsService detailsService;
30     @Autowired
31     private PasswordEncoder passwordEncoder;
32     @Autowired
33     private ConfigService configService;
34 
35 
36     @Override
37     protected UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
38         // 如有其它邏輯處理,可在此處進行邏輯處理...
39         return detailsService.loadUserByUsername(username);
40     }
41 
42     @Override
43     protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
44         String username = userDetails.getUsername();
45         User user = userService.getUserByUsername(username);
46 
47         // 檢查驗證碼
48         if (authentication.getDetails() instanceof CustomWebAuthenticationDetails) {
49             if (configService.isEnableCaptcha(user.getPasswordErrorTime())) {
50                 CustomWebAuthenticationDetails details = (CustomWebAuthenticationDetails) authentication.getDetails();
51                 String inputCaptcha = details.getInputCaptcha();
52                 String cacheCaptcha = details.getCacheCaptcha();
53                 if (StringUtils.isEmpty(inputCaptcha) || !StringUtils.equalsIgnoreCase(inputCaptcha, cacheCaptcha)) {
54                     throw new AuthenticationServiceException("login.captcha.error");
55                 }
56                 authentication.setDetails(null);
57             }
58         }
59 
60         // 檢查密碼是否正確
61         String password = userDetails.getPassword();
62         String rawPassword = authentication.getCredentials().toString();
63 
64         boolean match = passwordEncoder.matches(rawPassword, password);
65         if (!match) {
66             throw new BadCredentialsException("login.username-or-password.error");
67         }
68     }
69 }
View Code

⑦ CustomAuthenticationSuccessHandler

自定義認證成功處理器,使用者認證成功,將密碼錯誤次數置零。

 1 package com.lyyzoo.sunny.security.core;
 2 
 3 import java.io.IOException;
 4 
 5 import javax.servlet.ServletException;
 6 import javax.servlet.http.HttpServletRequest;
 7 import javax.servlet.http.HttpServletResponse;
 8 
 9 import org.springframework.beans.factory.annotation.Autowired;
10 import org.springframework.security.core.Authentication;
11 import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler;
12 import org.springframework.stereotype.Component;
13 
14 import com.lyyzoo.sunny.security.domain.entity.User;
15 import com.lyyzoo.sunny.security.domain.service.UserService;
16 
17 /**
18  * 登入認證成功處理器
19  * 
20  * @author bojiangzhou 2018/03/29
21  */
22 @Component
23 public class CustomAuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler {
24 
25     @Autowired
26     private UserService userService;
27 
28     @Override
29     public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
30                     Authentication authentication) throws IOException, ServletException {
31         String username = request.getParameter("username");
32         User user = userService.getUserByUsername(username);
33         userService.loginSuccess(user.getId());
34         super.onAuthenticationSuccess(request, response, authentication);
35     }
36 }
View Code

⑧ CustomAuthenticationFailureHandler

使用者認證失敗,記錄密碼錯誤次數,並重定向到登入頁面。

 1 package com.lyyzoo.sunny.security.core;
 2 
 3 import java.io.IOException;
 4 
 5 import javax.servlet.ServletException;
 6 import javax.servlet.http.HttpServletRequest;
 7 import javax.servlet.http.HttpServletResponse;
 8 import javax.servlet.http.HttpSession;
 9 
10 import org.springframework.beans.factory.annotation.Autowired;
11 import org.springframework.security.authentication.BadCredentialsException;
12 import org.springframework.security.core.AuthenticationException;
13 import org.springframework.security.web.DefaultRedirectStrategy;
14 import org.springframework.security.web.RedirectStrategy;
15 import org.springframework.security.web.WebAttributes;
16 import org.springframework.security.web.authentication.AuthenticationFailureHandler;
17 import org.springframework.stereotype.Component;
18 
19 import com.lyyzoo.sunny.core.message.MessageAccessor;
20 import com.lyyzoo.sunny.security.config.SecurityProperties;
21 import com.lyyzoo.sunny.security.domain.entity.User;
22 import com.lyyzoo.sunny.security.domain.service.UserService;
23 
24 /**
25  * 登入失敗處理器
26  * 
27  * @author bojiangzhou 2018/03/29
28  */
29 @Component
30 public class CustomAuthenticationFailureHandler implements AuthenticationFailureHandler {
31 
32     @Autowired
33     private SecurityProperties securityProperties;
34     @Autowired
35     private UserService userService;
36 
37     private RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();
38 
39     @Override
40     public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
41                     AuthenticationException exception) throws IOException, ServletException {
42         String username = request.getParameter("username");
43         HttpSession session = request.getSession(false);
44 
45         if (session != null) {
46             session.setAttribute("username", username);
47             session.setAttribute(WebAttributes.AUTHENTICATION_EXCEPTION,
48                             MessageAccessor.getMessage(exception.getMessage(), exception.getMessage()));
49         }
50         if (exception instanceof BadCredentialsException) {
51             User user = userService.getUserByUsername(username);
52             userService.loginFail(user.getId());
53         }
54 
55         redirectStrategy.sendRedirect(request, response, securityProperties.getLoginPage() + "?username=" + username);
56     }
57 }
View Code

⑨ 配置

前面的開發完成當然還需做配置,通過 formLogin() 來配置認證成功/失敗處理器等。

通過 AuthenticationManagerBuilder 配置自定義的認證器。

SpringSecurity提供了一個 PasswordEncoder 介面用於處理加密解密。該介面有兩個方法 encode 和 matches 。encode 對密碼加密,matches 判斷使用者輸入的密碼和加密的密碼(資料庫密碼)是否匹配。

 1 package com.lyyzoo.sunny.security.config;
 2 
 3 import com.lyyzoo.sunny.security.core.*;
 4 import org.springframework.beans.factory.annotation.Autowired;
 5 import org.springframework.boot.context.properties.EnableConfigurationProperties;
 6 import org.springframework.context.annotation.Bean;
 7 import org.springframework.context.annotation.Configuration;
 8 import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
 9 import org.springframework.security.config.annotation.web.builders.HttpSecurity;
10 import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
11 import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
12 import org.springframework.security.crypto.password.PasswordEncoder;
13 
14 /**
15  * Security 主配置器
16  *
17  * @author bojiangzhou
18  */
19 @Configuration
20 @EnableConfigurationProperties(SecurityProperties.class)
21 public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
22 
23     @Autowired
24     private SecurityProperties properties;
25     @Autowired
26     private CustomAuthenticationDetailsSource authenticationDetailsSource;
27     @Autowired
28     private CustomAuthenticationProvider authenticationProvider;
29     @Autowired
30     private CustomAuthenticationSuccessHandler authenticationSuccessHandler;
31     @Autowired
32     private CustomAuthenticationFailureHandler authenticationFailureHandler;
33 
34     @Override
35     protected void configure(HttpSecurity http) throws Exception {
36         http
37             .authorizeRequests()
38             .antMatchers("/static/**", "/webjars/**", "/public/**", "/login", "/favicon.ico")
39             .permitAll() // 允許匿名訪問的地址
40             .and() // 使用and()方法相當於XML標籤的關閉,這樣允許我們繼續配置父類節點。
41             .authorizeRequests()
42             .anyRequest()
43             .authenticated() // 其它地址都需進行認證
44             .and()
45             .formLogin() // 啟用表單登入
46             .loginPage(properties.getLoginPage()) // 登入頁面
47             .defaultSuccessUrl("/index") // 預設的登入成功後的跳轉地址
48             .authenticationDetailsSource(authenticationDetailsSource)
49             .successHandler(authenticationSuccessHandler)
50             .failureHandler(authenticationFailureHandler)
51             .and()
52             .csrf()
53             .disable()
54         ;
55 
56     }
57 
58     /**
59      * 設定認證處理器
60      */
61     @Override
62     protected void configure(AuthenticationManagerBuilder auth) throws Exception {
63         auth.authenticationProvider(authenticationProvider);
64         super.configure(auth);
65     }
66 
67     /**
68      * 密碼處理器
69      */
70     @Bean
71     public PasswordEncoder passwordEncoder() {
72         return new BCryptPasswordEncoder();
73     }
74 
75 }
View Code

⑩ 登入頁面

三、手機簡訊登入

經過前面使用者名稱+密碼的登入流程分析後,現在再來開發手機號+簡訊驗證碼的方式登入。手機簡訊登入無法直接使用標準登入的流程,所以需要模擬標準登入流程開發。

1、流程分析

類比標準登入流程:

① 登入請求 [POST /login] 在 UsernamePasswordAuthenticationFilter 過濾器中封裝未認證的 UsernamePasswordAuthenticationToken;

  簡訊登入時,請求 [POST /authentication/mobile] 進行登入認證,自定義 SmsAuthenticationFilter 簡訊認證過濾器,生成未認證的 SmsAuthenticationToken;

② 呼叫 AuthenticationManager 進行認證;

③ 認證時,使用自定義的 CustomAuthenticationProvider 進行使用者資訊認證;簡訊登入則自定義簡訊認證器 SmsAuthenticationProvider ;

④ 認證器使用自定義的 CustomUserDetailsService 來獲取使用者資訊;

⑤ 認證成功後,生成已認證的 UsernamePasswordAuthenticationToken;簡訊登入時則生成已認證的 SmsAuthenticationToken;

2、程式碼實現

① 簡訊登入專用 Authentication

參照 UsernamePasswordAuthenticationToken,兩個構造方法,認證前,放入手機號;認證成功之後,放入使用者資訊。

 1 package com.lyyzoo.sunny.security.sms;
 2 
 3 import java.util.Collection;
 4 
 5 import org.springframework.security.authentication.AbstractAuthenticationToken;
 6 import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
 7 import org.springframework.security.core.GrantedAuthority;
 8 
 9 /**
10  * 簡訊認證用到的 Authentication,封裝登入資訊。 認證前,放入手機號;認證成功之後,放入使用者資訊。
11  * <p>
12  * 參考 {@link UsernamePasswordAuthenticationToken}
13  * 
14  * @author bojiangzhou 2018/09/22
15  */
16 public class SmsAuthenticationToken extends AbstractAuthenticationToken {
17 
18     // 手機號
19     private final Object principal;
20 
21     public SmsAuthenticationToken(Object principal) {
22         super(null);
23         this.principal = principal;
24         setAuthenticated(false);
25     }
26 
27     public SmsAuthenticationToken(Object principal, Collection<? extends GrantedAuthority> authorities) {
28         super(authorities);
29         this.principal = principal;
30         super.setAuthenticated(true);
31     }
32 
33     @Override
34     public Object getCredentials() {
35         return null;
36     }
37 
38     public Object getPrincipal() {
39         return this.principal;
40     }
41 
42     public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException {
43         if (isAuthenticated) {
44             throw new IllegalArgumentException(
45                             "Cannot set this token to trusted - use constructor which takes a GrantedAuthority list instead");
46         }
47         super.setAuthenticated(false);
48     }
49 
50     @Override
51     public void eraseCredentials() {
52         super.eraseCredentials();
53     }
54 }
View Code

② 簡訊登入認證過濾器

參照 UsernamePasswordAuthenticationFilter,注意在構造方法中配置簡訊登入的地址 [POST /authentication/mobile],只有與這個地址匹配的才會進入這個過濾器。

同時,定義 SmsAuthenticationDetails 封裝使用者輸入的手機驗證碼,在認證器裡校驗驗證碼正確性。

 1 package com.lyyzoo.sunny.security.sms;
 2 
 3 import javax.servlet.http.HttpServletRequest;
 4 import javax.servlet.http.HttpServletResponse;
 5 
 6 import org.springframework.security.authentication.AuthenticationServiceException;
 7 import org.springframework.security.core.Authentication;
 8 import org.springframework.security.core.AuthenticationException;
 9 import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter;
10 import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
11 import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
12 import org.springframework.util.Assert;
13 
14 /**
15  * 簡訊登入認證過濾器
16  * <p>
17  * 參考 {@link UsernamePasswordAuthenticationFilter}
18  *
19  * @author bojiangzhou 2018/09/22
20  */
21 public class SmsAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
22 
23     public static final String SUNNY_SMS_MOBILE_KEY = "mobile";
24 
25     private String mobileParameter = SUNNY_SMS_MOBILE_KEY;
26     private boolean postOnly = true;
27 
28     /**
29      * 僅匹配 [POST /authentication/mobile]
30      */
31     public SmsAuthenticationFilter() {
32         super(new AntPathRequestMatcher("/authentication/mobile", "POST"));
33     }
34 
35     public Authentication attemptAuthentication(HttpServletRequest request,
36                                                 HttpServletResponse response) throws AuthenticationException {
37         if (postOnly && !request.getMethod().equals("POST")) {
38             throw new AuthenticationServiceException(
39                     "Authentication method not supported: " + request.getMethod());
40         }
41         String mobile = obtainMobile(request);
42 
43         if (mobile == null) {
44             mobile = "";
45         }
46 
47         mobile = mobile.trim();
48 
49         SmsAuthenticationToken authRequest = new SmsAuthenticationToken(mobile);
50 
51         // Allow subclasses to set the "details" property
52         setDetails(request, authRequest);
53 
54         return this.getAuthenticationManager().authenticate(authRequest);
55     }
56 
57     protected String obtainMobile(HttpServletRequest request) {
58         return request.getParameter(mobileParameter);
59     }
60 
61     protected void setDetails(HttpServletRequest request, SmsAuthenticationToken authRequest) {
62         authRequest.setDetails(authenticationDetailsSource.buildDetails(request));
63     }
64 
65     public void setMobileParameter(String mobileParameter) {
66         Assert.hasText(mobileParameter, "Mobile parameter must not be empty or null");
67         this.mobileParameter = mobileParameter;
68     }
69 
70     public void setPostOnly(boolean postOnly) {
71         this.postOnly = postOnly;
72     }
73 
74     public final String getMobileParameter() {
75         return mobileParameter;
76     }
77 
78 }
View Code

③ 簡訊登入認證器

參考 DaoAuthenticationProvider,覆蓋父類的 authenticate 方法,根據手機號獲取使用者資訊,校驗使用者輸入的驗證碼是否正確。

覆蓋 supports 方法,只有 {@link SmsAuthenticationToken} 型別才使用該認證器,ProviderManager 裡將會呼叫該方法尋找合適的認證器來認證。

  1 package com.lyyzoo.sunny.security.sms;
  2 
  3 import com.lyyzoo.sunny.captcha.CaptchaMessageHelper;
  4 import com.lyyzoo.sunny.captcha.CaptchaResult;
  5 import com.lyyzoo.sunny.security.constant.SecurityConstants;
  6 import com.lyyzoo.sunny.security.exception.CaptchaException;
  7 import org.apache.commons.lang3.StringUtils;
  8 import org.slf4j.Logger;
  9 import org.slf4j.LoggerFactory;
 10 import org.springframework.security.authentication.AuthenticationProvider;
 11 import org.springframework.security.authentication.dao.AbstractUserDetailsAuthenticationProvider;
 12 import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
 13 import org.springframework.security.core.Authentication;
 14 import org.springframework.security.core.AuthenticationException;
 15 import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper;
 16 import org.springframework.security.core.authority.mapping.NullAuthoritiesMapper;
 17 import org.springframework.security.core.userdetails.UserDetails;
 18 import org.springframework.security.core.userdetails.UserDetailsService;
 19 import org.springframework.util.Assert;
 20 
 21 /**
 22  * 簡訊登入認證器
 23  * <p>
 24  * 參考 {@link AbstractUserDetailsAuthenticationProvider},{@link DaoAuthenticationProvider}
 25  *
 26  * @author bojiangzhou 2018/09/22
 27  */
 28 public class SmsAuthenticationProvider implements AuthenticationProvider {
 29     private static final Logger LOGGER = LoggerFactory.getLogger(SmsAuthenticationProvider.class);
 30 
 31     private UserDetailsService userDetailsService;
 32 
 33     private GrantedAuthoritiesMapper authoritiesMapper = new NullAuthoritiesMapper();
 34 
 35     private CaptchaMessageHelper captchaMessageHelper;
 36 
 37     public SmsAuthenticationProvider(UserDetailsService userDetailsService, CaptchaMessageHelper captchaMessageHelper) {
 38         this.userDetailsService = userDetailsService;
 39         this.captchaMessageHelper = captchaMessageHelper;
 40     }
 41 
 42     @Override
 43     public Authentication authenticate(Authentication authentication) throws AuthenticationException {
 44         Assert.isInstanceOf(SmsAuthenticationToken.class, authentication,
 45                         "Only SmsAuthenticationToken is supported");
 46 
 47         String mobile = (authentication.getPrincipal() == null) ? "NONE_PROVIDED" : authentication.getName();
 48 
 49         UserDetails user = retrieveUser(mobile, (SmsAuthenticationToken) authentication);
 50         Assert.notNull(user, "retrieveUser returned null - a violation of the interface contract");
 51 
 52         additionalAuthenticationChecks(user, (SmsAuthenticationToken) authentication);
 53 
 54         return createSuccessAuthentication(user, authentication, user);
 55     }
 56 
 57     protected UserDetails retrieveUser(String mobile, SmsAuthenticationToken authentication)
 58                     throws AuthenticationException {
 59 
 60         return getUserDetailsService().loadUserByUsername(mobile);
 61     }
 62 
 63     protected void additionalAuthenticationChecks(UserDetails userDetails, SmsAuthenticationToken authentication)
 64                     throws AuthenticationException {
 65         Assert.isInstanceOf(SmsAuthenticationDetails.class, authentication.getDetails());
 66         SmsAuthenticationDetails details = (SmsAuthenticationDetails) authentication.getDetails();
 67         String mobile = (authentication.getPrincipal() == null) ? "NONE_PROVIDED" : authentication.getName();
 68         // 檢查驗證碼
 69         String inputCaptcha = details.getInputCaptcha();
 70         String captchaKey = details.getCaptchaKey();
 71         if (StringUtils.isAnyEmpty(inputCaptcha, captchaKey)) {
 72             throw new CaptchaException("login.mobile-captcha.null");
 73         }
 74         CaptchaResult captchaResult = captchaMessageHelper.checkCaptcha(captchaKey, inputCaptcha, mobile,
 75                         SecurityConstants.SECURITY_KEY, false);
 76         authentication.setDetails(null);
 77 
 78         if (!captchaResult.isSuccess()) {
 79             throw new CaptchaException(captchaResult.getMessage());
 80         }
 81     }
 82 
 83     protected Authentication createSuccessAuthentication(Object principal, Authentication authentication,
 84                     UserDetails user) {
 85         SmsAuthenticationToken result =
 86                         new SmsAuthenticationToken(principal, authoritiesMapper.mapAuthorities(user.getAuthorities()));
 87         result.setDetails(authentication.getDetails());
 88 
 89         return result;
 90     }
 91 
 92     /**
 93      * 只有 {@link SmsAuthenticationToken} 型別才使用該認證器
 94      */
 95     @Override
 96     public boolean supports(Class<?> authentication) {
 97         return (SmsAuthenticationToken.class.isAssignableFrom(authentication));
 98     }
 99 
100     public UserDetailsService getUserDetailsService() {
101         return userDetailsService;
102     }
103 
104     public void setUserDetailsService(UserDetailsService userDetailsService) {
105         this.userDetailsService = userDetailsService;
106     }
107 
108     public CaptchaMessageHelper getCaptchaMessageHelper() {
109         return captchaMessageHelper;
110     }
111 
112     public void setCaptchaMessageHelper(CaptchaMessageHelper captchaMessageHelper) {
113         this.captchaMessageHelper = captchaMessageHelper;
114     }
115 
116 }
View Code

3、簡訊登入配置

簡訊登入的配置可以參考表單登入的配置 FormLoginConfigurer,在使用 formLogin() 時就會啟用該配置。

定義 SmsLoginConfigurer,建立簡訊登入配置時,建立簡訊認證過濾器,在 configure 中配置該過濾器的認證成功/失敗處理器。最重要的一點,將簡訊認證過濾器加到 UsernamePasswordAuthenticationFilter 之後。

 1 package com.lyyzoo.sunny.security.sms;
 2 
 3 import javax.servlet.http.HttpServletRequest;
 4 
 5 import org.springframework.security.authentication.AuthenticationDetailsSource;
 6 import org.springframework.security.authentication.AuthenticationManager;
 7 import org.springframework.security.config.annotation.SecurityConfigurerAdapter;
 8 import org.springframework.security.config.annotation.web.builders.HttpSecurity;
 9 import org.springframework.security.web.DefaultSecurityFilterChain;
10 import org.springframework.security.web.authentication.AuthenticationFailureHandler;
11 import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
12 import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler;
13 import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
14 import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
15 import org.springframework.security.web.util.matcher.RequestMatcher;
16 import org.springframework.util.Assert;
17 
18 /**
19  * 簡訊登入配置
20  *
21  * @author bojiangzhou 2018/09/23
22  */
23 public class SmsLoginConfigurer
24         extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
25 
26     private static final String SMS_DEFAULT_LOGIN_PROCESS_URL = "/authentication/mobile";
27 
28     private SmsAuthenticationFilter authFilter;
29 
30     private AuthenticationDetailsSource<HttpServletRequest, ?> authenticationDetailsSource;
31 
32     private AuthenticationSuccessHandler successHandler = new SavedRequestAwareAuthenticationSuccessHandler();
33 
34     private AuthenticationFailureHandler failureHandler;
35 
36     /**
37      * 預設手機+簡訊驗證碼 登入處理地址 [POST "/authentication/mobile"]. 預設手機引數 - mobile
38      */
39     public SmsLoginConfigurer() {
40         authFilter = new SmsAuthenticationFilter();
41         loginProcessingUrl(SMS_DEFAULT_LOGIN_PROCESS_URL);
42         mobileParameter("mobile");
43     }
44 
45     public SmsLoginConfigurer mobileParameter(String mobileParameter) {
46         authFilter.setMobileParameter(mobileParameter);
47         return this;
48     }
49 
50     public SmsLoginConfigurer loginProcessingUrl(String loginProcessingUrl) {
51         authFilter.setRequiresAuthenticationRequestMatcher(createLoginProcessingUrlMatcher(loginProcessingUrl));
52         return this;
53     }
54 
55     public SmsLoginConfigurer authenticationDetailsSource(
56                     AuthenticationDetailsSource<HttpServletRequest, ?> authenticationDetailsSource) {
57         this.authenticationDetailsSource = authenticationDetailsSource;
58         return this;
59     }
60 
61     public SmsLoginConfigurer successHandler(AuthenticationSuccessHandler successHandler) {
62         this.successHandler = successHandler;
63         return this;
64     }
65 
66     public SmsLoginConfigurer failureHandler(AuthenticationFailureHandler failureHandler) {
67         this.failureHandler = failureHandler;
68         return this;
69     }
70 
71     protected RequestMatcher createLoginProcessingUrlMatcher(String loginProcessingUrl) {
72         return new AntPathRequestMatcher(loginProcessingUrl, "POST");
73     }
74 
75     @Override
76     public void configure(HttpSecurity http) throws Exception {
77         Assert.notNull(successHandler, "successHandler should not be null.");
78         Assert.notNull(failureHandler, "failureHandler should not be null.");
79         authFilter.setAuthenticationManager(http.getSharedObject(AuthenticationManager.class));
80         authFilter.setAuthenticationSuccessHandler(successHandler);
81         authFilter.setAuthenticationFailureHandler(failureHandler);
82         if (authenticationDetailsSource != null) {
83             authFilter.setAuthenticationDetailsSource(authenticationDetailsSource);
84         }
85         // 將簡訊認證過濾器加到 UsernamePasswordAuthenticationFilter 之後
86         http.addFilterAfter(authFilter, UsernamePasswordAuthenticationFilter.class);
87     }
88 
89 }
View Code

之後,需要在 WebSecurityConfigurerAdapter 中呼叫 HttpSecurity.apply() 應用該配置。

  1 package com.lyyzoo.sunny.security.config;
  2 
  3 import org.springframework.beans.factory.annotation.Autowired;
  4 import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
  5 import org.springframework.boot.context.properties.EnableConfigurationProperties;
  6 import org.springframework.context.annotation.Bean;
  7 import org.springframework.context.annotation.Configuration;
  8 import org.springframework.security.config.annotation.web.builders.HttpSecurity;
  9 import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
 10 import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
 11 import org.springframework.security.crypto.password.PasswordEncoder;
 12 
 13 import com.lyyzoo.sunny.captcha.CaptchaMessageHelper;
 14 import com.lyyzoo.sunny.security.core.*;
 15 import com.lyyzoo.sunny.security.sms.SmsAuthenticationDetailsSource;
 16 import com.lyyzoo.sunny.security.sms.SmsAuthenticationFailureHandler;
 17 import com.lyyzoo.sunny.security.sms.SmsAuthenticationProvider;
 18 import com.lyyzoo.sunny.security.sms.SmsLoginConfigurer;
 19 
 20 /**
 21  * Security 主配置器
 22  *
 23  * @author bojiangzhou
 24  */
 25 @Configuration
 26 @EnableConfigurationProperties(SecurityProperties.class)
 27 public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
 28 
 29     @Autowired
 30     private SecurityProperties properties;
 31     @Autowired
 32     private CustomAuthenticationDetailsSource authenticationDetailsSource;
 33     @Autowired
 34     private CustomAuthenticationProvider authenticationProvider;
 35     @Autowired
 36     private CustomAuthenticationSuccessHandler authenticationSuccessHandler;
 37     @Autowired
 38     private CustomAuthenticationFailureHandler authenticationFailureHandler;
 39     @Autowired
 40     private CustomUserDetailsService userDetailsService;
 41     @Autowired
 42     private CaptchaMessageHelper captchaMessageHelper;
 43 
 44     @Override
 45     @SuppressWarnings("unchecked")
 46     protected void configure(HttpSecurity http) throws Exception {
 47         http
 48             .authorizeRequests()
 49             .antMatchers("/static/**", "/webjars/**", "/public/**", "/favicon.ico", "/login", "/authentication/**", "/*.html")
 50             .permitAll() // 允許匿名訪問的地址
 51             .and() // 使用and()方法相當於XML標籤的關閉,這樣允許我們繼續配置父類節點。
 52             .authorizeRequests()
 53             .anyRequest()
 54             .authenticated() // 其它地址都需進行認證
 55             .and()
 56             .formLogin() // 啟用表單登入
 57             .loginPage(properties.getLoginPage()) // 登入頁面
 58             .defaultSuccessUrl("/index") // 預設的登入成功後的跳轉地址
 59             .authenticationDetailsSource(authenticationDetailsSource)
 60             .successHandler(authenticationSuccessHandler)
 61             .failureHandler(authenticationFailureHandler)
 62             .and()
 63             .authenticationProvider(authenticationProvider)
 64             .csrf()
 65             .disable()
 66         ;
 67 
 68         if (properties.isEnableSmsLogin()) {
 69             // 配置簡訊登入
 70             SmsLoginConfigurer smsLoginConfigurer = new SmsLoginConfigurer();
 71             smsLoginConfigurer
 72                     .authenticationDetailsSource(smsAuthenticationDetailsSource())
 73                     .successHandler(authenticationSuccessHandler)
 74                     .failureHandler(smsAuthenticationFailureHandler())
 75             ;
 76             http.apply(smsLoginConfigurer);
 77             http.authenticationProvider(smsAuthenticationProvider());
 78         }
 79     }
 80 
 81     /**
 82      * 密碼處理器
 83      */
 84     @Bean
 85     public PasswordEncoder passwordEncoder() {
 86         return new BCryptPasswordEncoder();
 87     }
 88 
 89     @Bean
 90     @ConditionalOnProperty(prefix = SecurityProperties.PREFIX, name = "enable-sms-login", havingValue = "true",
 91                 matchIfMissing = true)
 92     public SmsAuthenticationFailureHandler smsAuthenticationFailureHandler() {
 93         return new SmsAuthenticationFailureHandler();
 94     }
 95 
 96     @Bean
 97     @ConditionalOnProperty(prefix = SecurityProperties.PREFIX, name = "enable-sms-login", havingValue = "true",
 98             matchIfMissing = true)
 99     public SmsAuthenticationDetailsSource smsAuthenticationDetailsSource() {
100         return new SmsAuthenticationDetailsSource();
101     }
102 
103     @Bean
104     @ConditionalOnProperty(prefix = SecurityProperties.PREFIX, name = "enable-sms-login", havingValue = "true",
105             matchIfMissing = true)
106     public SmsAuthenticationProvider smsAuthenticationProvider() {
107         return new SmsAuthenticationProvider(userDetailsService, captchaMessageHelper);
108     }
109 
110 }
View Code

簡訊登入頁面:

四、三方QQ登入

1、OAuth協議

OAuth 是一個授權協議,它的目的是讓使用者不用給客戶端應用提供服務提供商(如QQ、微信)的賬號和密碼的情況下,讓客戶端應用可以有許可權去訪問使用者在服務提供商的資源。

關於 OAuth 介紹建議直接看《阮一峰 - 理解OAuth 2.0》,深入淺出,容易理解,這裡就不贅述了。我這裡主要看下原始碼及流程實現。

OAuth協議中的各種角色:

服務提供商(Provider):誰提供令牌誰就是服務提供商,比如微信、QQ。

資源所有者(Resource Owner):即使用者,我們要獲取的即使用者的資源。

第三方應用(Client):指獲取授權的應用,一般就是我們自己開發的應用。

認證伺服器(Authorization Server):即服務提供商專門用來處理認證的伺服器,認證使用者的身份併產生令牌。

資源伺服器(Resource Server):即服務提供商存放使用者生成的資源的伺服器。認證伺服器和資源伺服器雖然是兩個角色,但他們一般也可以在同一個應用,同一臺機器上。

各種角色聯絡在一起構成 OAuth 的認證流程(授權碼模式):

2、Spring Social

spring social 將 OAuth 認證的整個流程封裝並實現,它已經提供了對主流社交網站的支援,只需要簡單配置即可。針對上面的流程,來看下spring social 相關原始碼。

在 pom 中引入 spring-social 的依賴,版本使用 2.0.0.M4:

 1 <dependency>
 2     <groupId>org.springframework.social</groupId>
 3     <artifactId>spring-social-core</artifactId>
 4 </dependency>
 5 <dependency>
 6     <groupId>org.springframework.social</groupId>
 7     <artifactId>spring-social-config</artifactId>
 8 </dependency>
 9 <dependency>
10     <groupId>org.springframework.social</groupId>
11     <artifactId>spring-social-security</artifactId>
12 </dependency>
13 <dependency>
14     <groupId>org.springframework.social</groupId>
15     <artifactId>spring-social-web</artifactId>
16 </dependency>
View Code

① 首先是服務提供商,對應 ServiceProvider ,這是一個頂層的介面定義。預設使用 AbstractOAuth2ServiceProvider。

② 從 AbstractOAuth2ServiceProvider 不難看出,需要提供 OAuth2Operations,OAuth2Operations 介面封裝了 OAuth2 認證的整個標準流程,預設實現為 OAuth2Template。

③ AbstractOAuth2ServiceProvider 還需要提供一個 Api 介面,因為每個服務提供商返回的使用者資訊都是有差別的,這需要我們自己定義相關介面來獲取使用者資訊。

spring social 提供了一個預設的抽象類 AbstractOAuth2ApiBinding,從其定義可以看出我們可以使用第6步中獲取的服務提供商的令牌,使用 RestTemplate 傳送請求來獲取資料。

④ 使用 Api 獲取到使用者資訊後,就需要使用 Connection 來封裝使用者資訊,預設實現為 OAuth2Connection。

⑤ Connection 又是由 ConnectionFactory 創建出來的,預設使用 OAuth2ConnectionFactory。

⑥ ConnectionFactory 又需要 ServiceProvider 和 ApiAdapter:ServiceProvider 用來走認證流程,獲取使用者資訊;ApiAdapter 則用來適配不同服務提供商返回來的使用者資料,將其轉換成標準的 Connection。最終,ConnectionFactory 就可以構建出 Connection。

⑦ 獲取到三方應用的使用者資訊後,就需要和客戶端應用的使用者進行關聯,獲取客戶端應用中使用者的介面即為 UsersConnectionRepository。

3、流程分析

Social 認證是通過向 spring security 過濾器鏈加入 SocialAuthenticationFilter 過濾器來完成的,通過這個過濾器來了解下 spring-social 的認證流程。

① 通過判斷是否需要認證的方法 requiresAuthentication 可以看出,認證的地址必須是 **/{filterProcessesUrl}/{providerId} 的形式,比如 www.lyyzoo.com/auth/qq。這裡的 qq 即為 providerId,auth 為過濾器處理地址 filterProcessesUrl,這個值預設為 auth。

② 再看看認證的方法 attemptAuthentication,首先會檢測使用者是否拒絕授權,如果使用者拒絕授權則直接丟擲異常。然後獲取 providerId 及對應的認證服務類,用於處理認證。認證失敗,則重定向到一個地址去。

通過 detectRejection 可以看出,我們在請求登入時,不要隨意設定引數,否則會被錯誤認為是使用者拒絕授權的。

③ 認證方法中,從註釋也可以瞭解到,第一次請求時,會丟擲 AuthenticationRedirectException 異常,重定向到服務提供商的認證地址去。使用者確認授權後,重定向回來時,就是第二次請求,就會拿著授權碼去服務提供商那獲取令牌。

在獲取 SocialAuthenticationToken 的方法中可以看到,如果請求的引數中沒有 code(授權碼),則重定向到服務提供商那。通過 buildReturnToUrl 和 buildAuthenticateUrl 可以看出,會自動幫我們構造回撥地址以及重定向到認證伺服器的地址。

buildReturnToUrl 會構造回撥地址,所以本地測試要使用域名訪問,可以在 hosts 中配置域名對映。否則你訪問 localhost 是重定向不回來的,而且域名必須與QQ互聯上配置的域名保持一致。

buildAuthenticateUrl 會構造服務提供商的認證地址,會自動幫我們把 redirect_uri、state 等引數拼接上,在建立 OAuth2Template 時我們提供一個基礎地址即可。

④ 第二次請求時,有了授權碼,則會用授權碼去獲取令牌 AccessGrant 用於構造 Connection,最終構造 SocialAuthenticationToken(注意此時的 SocialAuthenticationToken 是未認證的) 。

通過 exchangeForAccess 方法,可以發現,會自動幫我們帶上獲取令牌的引數,如果要帶上 client_id、client_secret 需配置 useParametersForClientAuthentication=true。

獲取到令牌後會自動幫我們將令牌封裝到 AccessGrant 裡,預設返回的資料結構為 Map,所以如果服務提供商返回令牌資訊時不是 Map 結構的還需定製化處理。

⑤ 建立好 AccessGrant 後,通過 OAuth2ConnectionFactory 建立 Connection,實際是建立 OAuth2Connection 物件。initApi() 方法會獲取 ServiceProvider 中配置的Api。

initKey() 用於生成服務提供商使用者唯一的 key,根據 providerId 和 providerUserId(服務提供商的使用者ID,即openId) 建立。而 providerUserId 則是通過 ApiAdapter 介面卡來獲取,這需要我們自行設定。

⑥ 獲取到 SocialAuthenticationToken 後,相當於服務提供商那邊認證完成,接著就會呼叫 doAuthentication 進行客戶端使用者認證。

與標準登入流程類似,同樣可以自定義 AuthenticationDetailsSource;接著呼叫認證器進行認證,spring social 的認證器預設使用 SocialAuthenticationProvider 。

從其認證方法可以看出,將通過之前得到的 providerId 和 providerUserId 來獲取 userId (客戶端使用者ID),這裡 spring social 預設有一張表來儲存 userId、providerId、providerUserId 之間的關係,可配置 JdbcUsersConnectionRepository 來維護對應的關係。

如果沒有獲取到對應的 userId,將丟擲 BadCredentialsException,在 doAuthentication 裡攔截到這個異常後,預設將重定向到 signupUrl 這個註冊頁面的地址,讓使用者先註冊或繫結三方賬號。signupUrl 預設為 "/signup"。

獲取到對應的 userId後,就根據 userId 查詢使用者資訊,這需要我們自定義 SocialUserDetailsService 及 SocialUserDetails。獲取到使用者後,就會建立已認證的 SocialAuthenticationToken。

⑦ 通過 toUserId() 可以發現,根據 Connection 查詢系統 userId 時,JdbcUsersConnectionRepository 預設的處理方式是:如果未查詢到關聯的 userId,可以自定義一個 ConnectionSignUp 用於註冊使用者並返回一個 userId,並且會呼叫 addConnection 新增關聯。所以對於使用者如果未註冊,使用三方賬號掃碼自動註冊使用者的需求,就可以使用這種方式實現。

⑧ 客戶端這邊認證成功後,就會通過 updateConnections 或 addConnection 將使用者的 access_token、refresh_token、secret、使用者和服務商的關聯 等更新到資料庫。

4、QQ登入準備工作

① 社交登入必須要有一個外網能訪問的域名,所以首先需要自己申請一個域名,然後備案,再將域名指向一臺可訪問的伺服器,將服務部署到這臺伺服器上。推薦在阿里雲上完成這一整套的配置,就不在這裡細說了。

② 到 [QQ互聯] 上申請成為開發者,然後通過建立應用獲取QQ的appId和appKey。

 在建立應用時,網站地址 填寫公網可訪問的域名即可;網站回撥域 即請求QQ後回撥的地址,這個後面再做詳細說明。

③ 獲取授權碼地址

參考QQ互聯 使用Authorization_Code獲取Access_Token 可以得知獲取授權碼的地址:[https://graph.qq.com/oauth2.0/authorize],注意請求的引數有 response_type、client_id、redirect_uri、state 等。

client_id 即你申請的 appId,redirect_uri 即網站回撥域。

認證的時候,使用者成功授權,則會跳轉到指定的回撥地址,即引數 <redirect_uri>,也即建立應用時填寫的 <網站回撥域>,這二者必須保持一致,否則會提示重定向地址非法。

④ 獲取令牌地址

可以得到授權碼地址 [https://graph.qq.com/oauth2.0/token] ,注意 grant_type、client_id、client_secret、code、redirect_uri 這些必須引數。

client_id 即 appId,client_secret 即 appKey,code 為獲取的授權碼。

⑤ QQ訪問使用者資料API

QQ互聯上提供瞭如下的一些API,其中訪問使用者資料的API是不需要申請的。[QQ互聯API列表]

從文件中可以得到訪問使用者資料的地址:[ https://graph.qq.com/user/get_user_info ]

而要呼叫這個介面則必須帶上獲取的令牌(access_token),客戶端應用申請的 appId,以及 openId,即使用者的QQ號,可以使用 [ https://graph.qq.com/oauth2.0/me?access_token=YOUR_ACCESS_TOKEN ] 地址來獲取QQ號。

使用 [ https://graph.qq.com/user/get_user_info?access_token=YOUR_ACCESS_TOKEN&oauth_consumer_key=YOUR_APP_ID&openid=YOUR_OPENID ] 地址來獲取使用者資料。

返回引數,這些引數將封裝到特定的 UserInfo 中。

最後,通過返回碼來判斷是成功還是失敗。

5、QQ登入實現

從 SpringSocial 的原始碼分析中可以得知,我們主要目的就是獲取服務提供商的使用者資訊,使用者資訊則封裝到 Connection 中,想要獲得 Connection 就需要 ConnectionFactory,想要構造一個 ConnectionFactory 就需要 ServiceProvider 和 ApiAdapter,ServiceProvider 又需要 OAuth2Operations 和 Api。下面來一步步實現獲取QQ使用者資料從而登入的流程。

① 構建 Api

首先根據獲取QQ使用者資訊的介面封裝QQ使用者資訊以及QQApi介面。

 1 package com.lyyzoo.sunny.security.social.qq.api;
 2 
 3 import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
 4 
 5 /**
 6  * QQ 使用者資訊
 7  *
 8  * @author bojiangzhou 2018/10/16
 9  */
10 @JsonIgnoreProperties(ignoreUnknown = true)
11 public class QQUser {
12 
13     private String ret;
14 
15     private String msg;
16 
17     private String openId;
18 
19     private String nickname;
20 
21     private String figureurl;
22 
23     private String gender;
24 
25     //getter setter
26 }
View Code
 1 package com.lyyzoo.sunny.security.social.qq.api;
 2 
 3 /**
 4  * QQ API
 5  *
 6  * @author bojiangzhou 2018/10/16
 7  */
 8 public interface QQApi {
 9 
10     /**
11      * 獲取QQ使用者資訊
12      */
13     QQUser getQQUser();
14 
15 }
View Code

提供 Api 預設實現,繼承 AbstractOAuth2ApiBinding,使用者資訊api需要引數 appId 及 openId,而想要獲取 openId 就要使用 access_token 獲取使用者 openId。

 1 package com.lyyzoo.sunny.security.social.qq.api;
 2 
 3 import java.io.IOException;
 4 
 5 import org.apache.commons.lang3.StringUtils;
 6 import org.slf4j.Logger;
 7 import org.slf4j.LoggerFactory;
 8 import org.springframework.social.oauth2.AbstractOAuth2ApiBinding;
 9 import org.springframework.social.oauth2.TokenStrategy;
10 
11 import com.fasterxml.jackson.databind.ObjectMapper;
12 import com.lyyzoo.sunny.core.exception.CommonException;
13 import com.lyyzoo.sunny.security.social.exception.ProviderUserNotFoundException;
14 
15 /**
16  * QQ API 預設實現,繼承 {@link AbstractOAuth2ApiBinding}。
17  * 由於 Api 會使用得到的令牌來獲取資訊,每個使用者的令牌是不同的,所以該類不是一個單例物件,每次訪問 Api 都需要新建例項。
18  *
19  * @author bojiangzhou 2018/10/16
20  */
21 public class DefaultQQApi extends AbstractOAuth2ApiBinding implements QQApi {
22 
23     private static final Logger LOGGER = LoggerFactory.getLogger(DefaultQQApi.class);
24 
25     /**
26      * QQ 獲取 openId 的地址
27      */
28     private static final String URL_GET_OPEN_ID = "https://graph.qq.com/oauth2.0/me?access_token={accessToken}";
29     /**
30      * QQ 獲取使用者資訊的地址
31      */
32     private static final String URL_GET_USER_INFO = "https://graph.qq.com/user/get_user_info?oauth_consumer_key={appId}&openid={openId}";
33 
34     /**
35      * 客戶端 appId
36      */
37     private String appId;
38     /**
39      * openId
40      */
41     private String openId;
42 
43     private ObjectMapper mapper = new ObjectMapper();
44 
45     public DefaultQQApi(String accessToken, String appId) {
46         super(accessToken, TokenStrategy.ACCESS_TOKEN_PARAMETER);
47         this.appId = appId;
48         this.openId = getOpenId(accessToken);
49     }
50 
51     @Override
52     public QQUser getQQUser() {
53         String result = getRestTemplate().getForObject(URL_GET_USER_INFO, String.class, appId, openId);
54 
55         QQUser user = null;
56         try {
57             user = mapper.readValue(result, QQUser.class);
58         } catch (IOException e) {
59             LOGGER.error("parse qq UserInfo error.");
60         }
61         if (user == null) {
62             throw new ProviderUserNotFoundException("login.provider.user.not-found");
63         }
64         user.setOpenId(openId);
65         return user;
66     }
67 
68     /**
69      * 獲取使用者 OpenId
70      */
71     private String getOpenId(String accessToken) {
72         // 返回結構:callback( {"client_id":"YOUR_APPID","openid":"YOUR_OPENID"} );
73         String openIdResult = getRestTemplate().getForObject(URL_GET_OPEN_ID, String.class, accessToken);
74         if (StringUtils.isBlank(openIdResult) || openIdResult.contains("code")) {
75             throw new CommonException("獲取QQ賬號錯誤");
76         }
77         // 解析 openId
78         String[] arr = StringUtils.substringBetween(openIdResult, "{", "}").replace("\"", "").split(",");
79         String openid = null;
80         for (String s : arr) {
81             if (s.contains("openid")) {
82                 openid = s.split(":")[1];
83             }
84         }
85         return openid;
86     }
87 }
View Code

② 構建QQApiAdapter 介面卡,在QQApi 與 Connection之間做適配。

 1 package com.lyyzoo.sunny.security.social.qq.connection;
 2 
 3 import com.lyyzoo.sunny.security.social.qq.api.QQApi;
 4 import com.lyyzoo.sunny.security.social.qq.api.QQUser;
 5 import org.springframework.social.connect.ApiAdapter;
 6 import org.springframework.social.connect.ConnectionValues;
 7 import org.springframework.social.connect.UserProfile;
 8 
 9 /**
10  * QQApi 介面卡
11  *
12  * @author bojiangzhou 2018/10/17
13  */
14 public class QQApiAdapter implements ApiAdapter<QQApi> {
15 
16     /**
17      * 測試Api連線是否可用
18      * 
19      * @param api QQApi
20      */
21     @Override
22     public boolean test(QQApi api) {
23         return true;
24     }
25 
26     /**
27      * QQApi 與 Connection 做適配
28      * @param api QQApi
29      * @param values Connection
30      */
31     @Override
32     public void setConnectionValues(QQApi api, ConnectionValues values) {
33         QQUser user = api.getQQUser();
34 
35         values.setDisplayName(user.getNickname());
36         values.setImageUrl(user.getFigureurl());
37         values.setProviderUserId(user.getOpenId());
38     }
39 
40     @Override
41     public UserProfile fetchUserProfile(QQApi api) {
42         return null;
43     }
44 
45     @Override
46     public void updateStatus(QQApi api, String message) {
47 
48     }
49 }
View Code

③ 定製化 QQOAuth2Template,因為標準的 OAuth2Template 處理令牌時,要求返回的資料結構為 Map,而QQ返回的令牌是一個字串,因此需要定製處理。

 1 package com.lyyzoo.sunny.security.social.qq.connection;
 2 
 3 import org.apache.commons.lang3.StringUtils;
 4 import org.slf4j.Logger;
 5 import org.slf4j.LoggerFactory;
 6 import org.springframework.http.converter.StringHttpMessageConverter;
 7 import org.springframework.social.oauth2.AccessGrant;
 8 import org.springframework.social.oauth2.OAuth2Template;
 9 import org.springframework.util.MultiValueMap;
10 import org.springframework.web.client.RestClientException;
11 import org.springframework.web.client.RestTemplate;
12 
13 import com.google.common.base.Charsets;
14 
15 /**
16  * 定製 OAuth2Template
17  *
18  * @author bojiangzhou 2018/10/26
19  */
20 public class QQOauth2Template extends OAuth2Template {
21 
22     private static final Logger LOGGER = LoggerFactory.getLogger(QQOauth2Template.class);
23 
24     public QQOauth2Template(String clientId, String clientSecret, String authorizeUrl, String accessTokenUrl) {
25         super(clientId, clientSecret, authorizeUrl, accessTokenUrl);
26         // 設定帶上 client_id、client_secret
27         setUseParametersForClientAuthentication(true);
28     }
29 
30     /**
31      * 解析 QQ 返回的令牌
32      */
33     @Override
34     protected AccessGrant postForAccessGrant(String accessTokenUrl, MultiValueMap<String, String> parameters) {
35         // 返回格式:access_token=FE04********CCE2&expires_in=7776000&refresh_token=88E4***********BE14
36         String result = getRestTemplate().postForObject(accessTokenUrl, parameters, String.class);
37         if (StringUtils.isBlank(result)) {
38             throw new RestClientException("access token endpoint returned empty result");
39         }
40         LOGGER.debug("==> get qq access_token: " + result);
41         String[] arr = StringUtils.split(result, "&");
42         String accessToken = "", expireIn = "", refreshToken = "";
43         for (String s : arr) {
44             if (s.contains("access_token")) {
45                 accessToken = s.split("=")[1];
46             } else if (s.contains("expires_in")) {
47                 expireIn = s.split("=")[1];
48             } else if (s.contains("refresh_token")) {
49                 refreshToken = s.split("=")[1];
50             }
51         }
52         return createAccessGrant(accessToken, null, refreshToken, Long.valueOf(expireIn), null);
53     }
54 
55     /**
56      * QQ 響應 ContentType=text/html;因此需要加入 text/html; 的處理器
57      */
58     @Override
59     protected RestTemplate createRestTemplate() {
60         RestTemplate restTemplate = super.createRestTemplate();
61         restTemplate.getMessageConverters().add(new StringHttpMessageConverter(Charsets.UTF_8));
62         return restTemplate;
63     }
64 }
View Code

④ 通過 QQOAuth2Template 和 QQApi 構造 QQServiceProvider,建立 OAuth2Template 時,需傳入獲取授權碼的地址和獲取令牌的地址。

 1 package com.lyyzoo.sunny.security.social.qq.connection;
 2 
 3 import com.lyyzoo.sunny.security.social.qq.api.DefaultQQApi;
 4 import com.lyyzoo.sunny.security.social.qq.api.QQApi;
 5 import org.springframework.social.oauth2.AbstractOAuth2ServiceProvider;
 6 
 7 /**
 8  * QQ 服務提供商
 9  *
10  * @author bojiangzhou 2018/10/17
11  */
12 public class QQServiceProvider extends AbstractOAuth2ServiceProvider<QQApi> {
13     /**
14      * 獲取授權碼地址(引導使用者跳轉到這個地址上去授權)
15      */
16     private static final String URL_AUTHORIZE = "https://graph.qq.com/oauth2.0/authorize";
17     /**
18      * 獲取令牌地址
19      */
20     private static final String URL_GET_ACCESS_TOKEN = "https://graph.qq.com/oauth2.0/token";
21 
22     private String appId;
23 
24     public QQServiceProvider(String appId, String appSecret) {
25         super(new QQOauth2Template(appId, appSecret, URL_AUTHORIZE, URL_GET_ACCESS_TOKEN));
26         this.appId = appId;
27     }
28 
29     @Override
30     public QQApi getApi(String accessToken) {
31         return new DefaultQQApi(accessToken, appId);
32     }
33 }
View Code

⑤ 通過QQServiceProvider和QQApiAdapter構造 QQConnectionFactory。

 1 package com.lyyzoo.sunny.security.social.qq.connection;
 2 
 3 import com.lyyzoo.sunny.security.social.qq.api.QQApi;
 4 import org.springframework.social.connect.support.OAuth2ConnectionFactory;
 5 
 6 /**
 7  * QQ Connection 工廠
 8  *
 9  * @author bojiangzhou 2018/10/17
10  */
11 public class QQConnectionFactory extends OAuth2ConnectionFactory<QQApi> {
12 
13 
14     public QQConnectionFactory(String providerId, String appId, String appSecret) {
15         super(providerId, new QQServiceProvider(appId, appSecret), new QQApiAdapter());
16     }
17 }
View Code

⑥ 自定義 CustomSocialUserDetails 及 CustomSocialUserDetailsService,封裝 Social 專用的 UserDetails 物件。與 CustomUserDetails 和 CustomUserDetailsService 類似。

 1 package com.lyyzoo.sunny.security.social.common;
 2 
 3 import java.util.Collection;
 4 
 5 import org.springframework.security.core.GrantedAuthority;
 6 import org.springframework.security.core.userdetails.User;
 7 import org.springframework.social.security.SocialUserDetails;
 8 
 9 /**
10  * 定製 SocialUserDetails 封裝 Social 登入使用者資訊
11  *
12  * @author bojiangzhou 2018/10/17
13  */
14 public class CustomSocialUserDetails extends User implements SocialUserDetails {
15 
16     private String userId;
17 
18     private String nickname;
19 
20     private String language;
21 
22     public CustomSocialUserDetails(String username, String password, String userId, String nickname, String language,
23                              Collection<? extends GrantedAuthority> authorities) {
24         super(username, password, authorities);
25         this.userId = userId;
26         this.nickname = nickname;
27         this.language = language;
28     }
29 
30     @Override
31     public String getUserId() {
32         return userId;
33     }
34 
35     public String getNickname() {
36         return nickname;
37     }
38 
39     public String getLanguage() {
40         return language;
41     }
42 }
View Code
 1 package com.lyyzoo.sunny.security.social.common;
 2 
 3 import java.util.ArrayList;
 4 import java.util.Collection;
 5 
 6 import org.springframework.beans.factory.annotation.Autowired;
 7 import org.springframework.security.core.GrantedAuthority;
 8 import org.springframework.security.core.authority.SimpleGrantedAuthority;
 9 import org.springframework.security.core.userdetails.UsernameNotFoundException;
10 import org.springframework.social.security.SocialUserDetails;
11 import org.springframework.social.security.SocialUserDetailsService;
12 
13 import com.lyyzoo.sunny.security.domain.entity.User;
14 import com.lyyzoo.sunny.security.domain.service.UserService;
15 import com.lyyzoo.sunny.security.exception.AccountNotExistsException;
16 
17 /**
18  * 定製 Social UserDetailsService 用於獲取系統使用者資訊
19  *
20  * @author bojiangzhou 2018/10/17
21  */
22 public class CustomSocialUserDetailsService implements SocialUserDetailsService {
23 
24     @Autowired
25     private UserService userService;
26 
27     @Override
28     public SocialUserDetails loadUserByUserId(String userId) throws UsernameNotFoundException {
29         User user = userService.select(Long.valueOf(userId));
30 
31         if (user == null) {
32             throw new AccountNotExistsException("login.username-or-password.error");
33         }
34 
35         Collection<GrantedAuthority> authorities = new ArrayList<>();
36         authorities.add(new SimpleGrantedAuthority("ROLE_USER"));
37 
38         return new CustomSocialUserDetails(user.getUsername(), user.getPassword(), userId, user.getNickname(),
39                         user.getLanguage(), authorities);
40     }
41 }
View Code

⑥ 自定義 social 配置器,支援設定Social過濾器處理地址

 1 package com.lyyzoo.sunny.security.social.config;
 2 
 3 import org.springframework.context.annotation.Configuration;
 4 import org.springframework.social.security.SocialAuthenticationFilter;
 5 import org.springframework.social.security.SpringSocialConfigurer;
 6 import org.springframework.util.Assert;
 7 
 8 /**
 9  * social 配置器,支援設定Social過濾器處理地址.
10  *
11  * <pre>
12  *  SpringSocialConfigurer socialConfigurer = new CustomSocialConfigurer();
13  *  http.apply(socialConfigurer);
14  * </pre>
15  * @author bojiangzhou 2018/10/19
16  */
17 @Configuration
18 public class CustomSocialConfigurer extends SpringSocialConfigurer {
19 
20     private static final String DEFAULT_FILTER_PROCESSES_URL = "/openid";
21 
22     private String filterProcessesUrl = DEFAULT_FILTER_PROCESSES_URL;
23 
24     public CustomSocialConfigurer() { }
25 
26     public CustomSocialConfigurer(String filterProcessesUrl) {
27         Assert.notNull(filterProcessesUrl, "social filterProcessesUrl should not be null.");
28         this.filterProcessesUrl = filterProcessesUrl;
29     }
30 
31     @Override
32     @SuppressWarnings("unchecked")
33     protected <T> T postProcess(T object) {
34         SocialAuthenticationFilter filter =  (SocialAuthenticationFilter) super.postProcess(object);
35         filter.setFilterProcessesUrl(filterProcessesUrl);
36         return (T) filter;
37     }
38 }
View Code

⑦ social 配置,加入 QQConnectionFactory。

配置增刪改查使用者三方關係的 UsersConnectionRepository,使用 JdbcUsersConnectionRepository,並設定表字首,可在原始碼包裡找到初始化指令碼,會自動幫我們增刪改查使用者與第三方賬號的關聯。

 1 package com.lyyzoo.sunny.security.social.config;
 2 
 3 import javax.sql.DataSource;
 4 
 5 import com.lyyzoo.sunny.security.social.core.CustomSocialAuthenticationSuccessHandler;
 6 import com.lyyzoo.sunny.security.social.core.CustomSocialUserDetailsService;
 7 import com.lyyzoo.sunny.security.social.qq.connection.QQConnectionFactory;
 8 import com.lyyzoo.sunny.security.social.wechat.connection.WechatConnectionFactory;
 9 import org.apache.commons.lang3.StringUtils;
10 import org.springframework.beans.factory.annotation.Autowired;
11 import org.springframework.boot.context.properties.EnableConfigurationProperties;
12 import org.springframework.context.annotation.Bean;
13 import org.springframework.context.annotation.Configuration;
14 import org.springframework.core.env.Environment;
15 import org.springframework.security.crypto.encrypt.Encryptors;
16 import org.springframework.social.UserIdSource;
17 import org.springframework.social.config.annotation.ConnectionFactoryConfigurer;
18 import org.springframework.social.config.annotation.EnableSocial;
19 import org.springframework.social.config.annotation.SocialConfigurerAdapter;
20 import org.springframework.social.connect.ConnectionFactoryLocator;
21 import org.springframework.social.connect.ConnectionSignUp;
22 import org.springframework.social.connect.UsersConnectionRepository;
23 import org.springframework.social.connect.jdbc.JdbcUsersConnectionRepository;
24 import org.springframework.social.connect.web.ProviderSignInUtils;
25 import org.springframework.social.security.AuthenticationNameUserIdSource;
26 import org.springframework.social.security.SocialUserDetailsService;
27 
28 /**
29  * social 配置
30  *
31  * @author bojiangzhou 2018/10/17
32  */
33 @Configuration
34 @EnableSocial
35 @EnableConfigurationProperties(SocialProperties.class)
36 public class SocialConfiguration extends SocialConfigurerAdapter {
37 
38     @Autowired
39     private SocialProperties properties;
40     @Autowired
41     private DataSource dataSource;
42 
43     @Autowired(required = false)
44     private ConnectionSignUp connectionSignUp;
45 
46     @Override
47     public void addConnectionFactories(ConnectionFactoryConfigurer connectionFactoryConfigurer, Environment environment) {
48         // QQ
49         SocialProperties.Qq qq = properties.getQq();
50         if (StringUtils.isNoneBlank(qq.getAppId(), qq.getAppSecret())) {
51             connectionFactoryConfigurer.addConnectionFactory(
52                             new QQConnectionFactory(qq.getProviderId(), qq.getAppId(), qq.getAppSecret()));
53         }
54     }
55 
56     @Override
57     public UsersConnectionRepository getUsersConnectionRepository(ConnectionFactoryLocator connectionFactoryLocator) {
58         JdbcUsersConnectionRepository usersConnectionRepository =
59                 new JdbcUsersConnectionRepository(dataSource, connectionFactoryLocator, Encryptors.noOpText());
60         // 設定表字首
61         usersConnectionRepository.setTablePrefix("sys_");
62         // ConnectionSignUp 需自定義
63         usersConnectionRepository.setConnectionSignUp(connectionSignUp);
64         return usersConnectionRepository;
65     }
66 
67     @Override
68     public UserIdSource getUserIdSource() {
69         return new AuthenticationNameUserIdSource();
70     }
71 
72     @Bean
73     public SocialUserDetailsService socialUserDetailsService() {
74         return new CustomSocialUserDetailsService();
75     }
76 
77     @Bean
78     public CustomSocialAuthenticationSuccessHandler socialAuthenticationSuccessHandler() {
79         return new CustomSocialAuthenticationSuccessHandler();
80     }
81 
82     //@Bean
83     //public CustomSocialAuthenticationFailureHandler customSocialAuthenticationFailureHandler() {
84     //    return new CustomSocialAuthenticationFailureHandler();
85     //}
86 
87     @Bean
88     public ProviderSignInUtils providerSignInUtils(ConnectionFactoryLocator connectionFactoryLocator,
89                                                    UsersConnectionRepository connectionRepository) {
90         return new ProviderSignInUtils(connectionFactoryLocator, connectionRepository);
91     }
92 
93 }
View Code

⑧ 如果使用者未繫結QQ賬號,則會預設跳轉到 /signup 進行新使用者註冊或者賬號繫結,賬號繫結會用到 Social 提供的一個工具類 ProviderSignInUtils,會自動幫我們建立關聯關係,並且在繫結後繼續認證使用者資訊。

 1 @Service
 2 public class UserServiceImpl extends BaseService<User> implements UserService {
 3 
 4     @Autowired
 5     private PasswordEncoder passwordEncoder;
 6 
 7     @Autowired
 8     private ProviderSignInUtils providerSignInUtils;
 9 
10     @Override
11     public void bindProvider(String username, String password, HttpServletRequest request) {
12         // login
13         User user = select(User.FIELD_USERNAME, username);
14         if (user == null || !passwordEncoder.matches(password, user.getPassword())) {
15             throw new CommonException("user.error.login.username-or-password.error");
16         }
17 
18         providerSignInUtils.doPostSignUp(user.getId().toString(), new ServletWebRequest(request));
19     }
20 
21 }
View Code

6、實現效果

① 在登入頁面點選QQ登入,實際就是訪問 /openid/qq。

② 跳轉到QQ授權頁面進行授權

③ 使用者授權之後,跳轉回來,將根據 providerId (qq) 和 providerUserId (openid) 查詢系統使用者ID,然而 sys_userconnection 表中並沒有對應的關係,於是自動跳轉到註冊頁面,使用者可以選擇註冊新使用者並繫結,或者直接繫結已有賬號。

④使用者繫結系統賬號後,sys_userconnection 表中就會新增一條關聯資料,代表系統使用者和QQ使用者已繫結,下次再登入時就不會再要求進行綁定了。還可以在使用者個人中心提供繫結第三方賬號的功能,這裡就不在演示了,原理是類似的。

五、Session 管理

1、Session 超時處理

可以通過設定 server.servlet.session.timeout 來設定 Session 超時時間,預設為30分鐘

當你設定超時時間小於60秒的時候,實際預設最小為 1 分鐘。

可以在 HttpSecurity 的配置中設定Session失效後跳轉的地址,這裡配置直接跳轉到登入頁。

2、Session 併發控制

使用者登入時,如果只想讓使用者在一處登入,可設定 Session 併發數量來控制,並且可以設定當後一次登入擠掉前一次登入時的處理策略。

如果使用者已經登入,在其它地方登入時則不允許登入,可設定 maxSessionsPreventsLogin=true 即可。

注意:如果發現設定不生效,請檢查 UserDetails ,要重寫 hashCode、equals、toString 方法,因為判斷是否屬於同一個使用者是通過這幾個方法來判斷的。

3、叢集Session管理

在服務叢集中,已經在 serverA 上登入了,登入後的Session是在 serverA 上,再訪問 serverB 時,則會要求再次登入,因為沒有Session。因此在叢集中,可以將Session放到服務之外進行管理,讓 Session 在叢集中可以共享。

在 SpringBoot 中可以很容易做到這件事,目前可以支援以下幾種型別的 Session 儲存,我這裡使用 Redis 進行 Session 儲存。

只需在 pom 中加入 spring-session 依賴,然後在配置中啟用某種型別的 session 儲存即可,最終會啟用相關配置類。

1 <!-- spring-session Session叢集共享 -->
2 <dependency>
3     <groupId>org.springframework.session</groupId>
4     <artifactId>spring-session-core</artifactId>
5 </dependency>
6 <dependency>
7     <groupId>org.springframework.session</groupId>
8     <artifactId>spring-session-data-redis</artifactId>
9 </dependency>

再次登入時就會發現 Session 已經儲存到 redis 中了。

4、退出登入

預設退出地址為 /logout,退出後會跳轉到登入地址+?logout,這些就不介紹了,看原始碼很容易發現這些配置。

我們可以通過 HttpSecurity的logout()來自定義登出的配置,實際會啟用 LogoutConfigurer 的配置,注意登出成功地址和登出成功處理器不能同時配置,同時配置了則以後一個生效。可以在登出成功處理器返回 JSON,也可以做一些自定義的邏輯處理等。

六、OAuth登入

前面實現的登入認證方式,登入成功後,登入資訊是儲存在伺服器的 Session 裡的,每次瀏覽器訪問服務時,實際是在 Cookie 中帶著 JSESSIONID 去訪問服務,服務根據 JSESSIONID 來獲取使用者 Session,這種方式是基於伺服器 Session 來儲存使用者資訊。但在前後端分離或開發移動APP的時候,前端是單獨部署在一臺伺服器上,使用者實際訪問的是 WebServer,所有的服務API請求再間接由 Web Server 發起。使用者不再通過瀏覽器直接訪問我們的後端應用,而是通過第三方的應用來訪問。這種時候就不便於使用 Cookie + Session 的方式來儲存使用者資訊,Cookie 存在跨域的問題,使用這種開發方式繁瑣,安全性差。

於是就有了OAuth,類似於 QQ、微信認證那樣,我們自己也可以作為服務提供商,前端應用或APP則作為第三方客戶端,通過給客戶端發放令牌,客戶端在http引數中帶著令牌來訪問服務,服務端則通過令牌得到使用者資訊。Spring Social 封裝了第三方客戶端訪問服務提供商時要做的大部分操作,而 Spring Security OAuth 則封裝了服務提供商為第三方客戶端提供令牌所要做的絕大部分操作,使用 Spring Security OAuth 我們可以快速搭建起一個服務提供商程序。

要實現服務提供商程序,實際就是實現 認證伺服器和資源伺服器,作為認證伺服器,可以使用 OAuth 的四種授權模式,來生成令牌並存儲、發放。作為資源伺服器,OAuth2 通過向 SpringSecurity 過濾器鏈上加入 OAuth2AuthenticationProcessingFilter 來對資源進行認證,解析令牌,根據令牌獲取使用者資訊等。

在開始本章之前,建議先熟悉 OAuth2 的認證流程及授權模式等:理解OAuth 2.0

1、OAuth 認證伺服器

① 只需在配置中加上 @EnableAuthorizationServer 就可啟用簡單的 OAuth2 認證伺服器功能。

實際上,該註解引入的 AuthorizationServerSecurityConfiguration 做了一個 oauth 的 HttpSecurity 配置,建立了一條專用於處理獲取令牌(/oauth)相關請求的過濾器鏈,這個可自行檢視。

② 通過其匯入的配置可以發現,主要啟用了兩個端點:授權端點(AuthorizationEndpoint)和令牌端點(TokenEndpoint)。授權端點用於使用者授權給第三方客戶端,就像我們在QQ授權頁面登入授權一樣。令牌端點則用於給使用者發放令牌。

2、OAuth 授權流程

下面通過授權碼模式來了解OAuth的授權流程。

① 在程序啟動時,已生成預設的 client-id 和 client-secret(基於記憶體的方式),第三方客戶端將使用者重定向到認證伺服器上(/oauth/authorize?client_id=xxx&response_type=code..... ) 獲取使用者授權。

此時預設會跳轉到我們之前配置的登入頁去進行登入,因為該請求匹配標準登入的過濾器鏈,發現使用者沒有認證,則跳轉到登入頁進行登入。使用者確認登入即是向客戶端授權,登入成功後就會進入 authorize 端點。

② 可以看出:response_type 引數必須設定為 token 或者 code,可見該端點只用於 授權碼模式(authorization code) 和 簡化模式(implicit grant type);且必須傳入 client_id,客戶端ID一般由服務提供商提供給客戶端應用;同時要求使用者必須已經登入且已認證通過。

③ 之後,通過 client_id 獲取 ClientDetails,這裡我們就需要做客製化了,我們需要新增自己的客戶端應用庫,從資料庫獲取客戶端資訊。

之後會從引數中獲取重定向回客戶端的 redirect_uri,然後處理重定向地址,客戶端(client)是可以配置授權型別的,預設就有這五種型別:authorization_code、password、client_credentials、implicit、refresh_token。

可以看出,能進行重定向回客戶端的只支援 授權碼模式(authorization code) 和 簡化模式(implicit grant type)。

確認可以重定向之後,就會獲取 client 配置的重定向地址,如果 client 的重定向地址不為空,就會跟客戶端傳入的 redirect_uri 進行比對,如果 redirect_uri 為空,則直接返回 client 配置的重定向地址;如果不為空,則要求二者必須保持一致,這也是需要注意的地方。

④ 設定完重定向地址後,接著就檢查 scope,即客戶端申請訪問的許可權範圍,如果檢查發現不需要使用者授權,則重定向回去,否則會跳轉到一個預設的授權頁面讓使用者授權。

如果 client 中有與請求的 scope 對應的授權範圍或者使用者允許授權(Approve),則會生成授權碼並存儲起來,然後重定向到之前設定的地址上去,並返回授權碼,以及原樣返回 state 引數。之後客戶端就可以帶著授權碼去獲取令牌。

3、發放令牌

① 客戶端得到授權碼後,就可以帶上授權碼去獲取令牌(/oauth/token?grant_type=authorization_code&code=xxx&redirect_uri=xxx&client_id=xxx),這裡用 Postman 來測試。

注意發起表單請求時,要配置客戶端允許表單認證,將向 oauth 過濾器鏈中加入 ClientCredentialsTokenEndpointFilter 客戶端過濾器來攔截使用者請求,根據 client_id 和 client_secret 建立 Authentication 。跟標準的使用者名稱密碼登入流程一樣,只不過這裡是校驗 client_id 和 client_secret。

② client_id 和 client_secret 認證通過後,就會進入獲取令牌的端點,首先根據 client_id 獲取 Client ,然後建立 TokenRequest。

可以看出,獲取令牌端點是不支援簡化模式的,簡化模式是訪問 /authorize 端點時直接發放令牌的,這個稍後再說。

③ 之後就會呼叫 TokenGranter 進行授權,授權成功將建立 OAuth2AccessToken,最後返回到客戶端。

授權時,實際就是呼叫五種授權型別的 TokenGranter,使用匹配的授權器來建立 AccessToken。

④ 建立 AccessToken 時,首先是根據授權碼獲取使用者資訊(建立授權碼的時候會把授權的使用者資訊序列化儲存起來)。

從儲存中獲取 AccessToken,先判斷該使用者是否已經存在 AccessToken,如果存在且沒有過期,則重新整理再返回。tokenStore 我們可以配置成資料庫儲存、Redis 儲存等。

如果不存在,則建立 refreshToken 和 accessToken,並存儲起來。

⑤ 之後就可以看到返回給客戶端的令牌,之後我們就可以帶著令牌訪問服務的資源了。

4、資源伺服器

獲取到令牌後,還無法直接通過令牌獲訪問資源服務,還需啟用資源服務功能才能解析令牌。

① 啟用資源伺服器,只需在配置類上加上 @EnableResourceServer 即可,同樣會建立一條 oauth 過濾器鏈,並向該過濾器鏈中加入 OAuth2AuthenticationProcessingFilter 過濾器來處理令牌。

這裡配置該過濾器鏈僅對 [/open/**] 的請求做處理,其它請求還是走標準的過濾器鏈。你也可以配置所有請求都通過令牌來訪問。

② 在這個過濾器中,將從請求中根據令牌解析 Authentication ,預設的令牌解析器使用 BearerTokenExtractor。

解析令牌時,首先檢查請求頭是否包含 [Authorization: Bearer token.....],沒有的話就判斷請求的引數是否包含 access_token,因此我們可以使用這兩種方式攜帶 access_token 去訪問資源。

③ 得到 Authentication 後,就對 Authentication 進行認證,在認證過程中,會呼叫 DefaultTokenServices 獲取使用者資訊,首先讀取 AccessToken,並判斷令牌是否過期,最後根據令牌得到使用者資訊。最終放入到 SecurityContextHolder 上下文中表示認證通過。

5、重新整理令牌

令牌是存在過期時間的,一般會設定一個小時或兩個小時過期。在使用者使用過程中,如果令牌過期,則又需要使用者重新登入,使用者體驗不好。因此可以使用得到的更新令牌去重新獲取訪問令牌而不需要重新登入。

6、簡化模式

一般來說,我們自己內部的系統並不需要使用兩步的授權碼模式來獲取授權,我們可以使用簡化模式(implicit grant type)來獲取授權。

只需將response_type改為token即可: host/oauth/authorize?client_id=client&response_type=token&scope=default&state=test。使用者確認授權後,就會在地址中將令牌帶回。

 

7、程式碼實現

① 自定義客戶端服務類,從資料庫獲取 Client

 1 package com.lyyzoo.sunny.security.oauth;
 2 
 3 import java.util.Collections;
 4 import java.util.Map;
 5 import java.util.Optional;
 6 
 7 import com.fasterxml.jackson.databind.ObjectMapper;
 8 import com.lyyzoo.sunny.security.domain.entity.Client;
 9 import com.lyyzoo.sunny.security.domain.service.ClientService;
10 import org.slf4j.Logger;
11 import org.slf4j.LoggerFactory;
12 import org.springframework.security.oauth2.provider.ClientDetails;
13 import org.springframework.security.oauth2.provider.ClientDetailsService;
14 import org.springframework.security.oauth2.provider.ClientRegistrationException;
15 import org.springframework.security.oauth2.provider.NoSuchClientException;
16 import org.springframework.util.StringUtils;
17 
18 /**
19  * 自定義 ClientDetailsService
20  *
21  * @author bojiangzhou 2018/11/03
22  */
23 public class CustomClientDetailsService implements ClientDetailsService {
24     private static final Logger LOGGER = LoggerFactory.getLogger(CustomClientDetailsService.class);
25 
26     private ClientService clientService;
27     private OAuthProperties properties;
28 
29     public CustomClientDetailsService(ClientService clientService, OAuthProperties properties) {
30         this.clientService = clientService;
31         this.properties = properties;
32     }
33 
34     private ObjectMapper mapper = new ObjectMapper();
35 
36     @Override
37     @SuppressWarnings("unchecked")
38     public ClientDetails loadClientByClientId(String clientId) throws ClientRegistrationException {
39         Client client = clientService.selectByClientId(clientId);
40         if (client == null) {
41             throw new NoSuchClientException("No client with requested id: " + clientId);
42         }
43         CustomClientDetails clientDetails = new CustomClientDetails();
44         clientDetails.setClientId(client.getClientId());
45         clientDetails.setClientSecret(client.getClientSecret());
46         clientDetails.setAuthorizedGrantTypes(StringUtils.commaDelimitedListToSet(client.getGrantTypes()));
47         clientDetails.setResourceIds(StringUtils.commaDelimitedListToSet(client.getResourceIds()));
48         clientDetails.setScope(StringUtils.commaDelimitedListToSet(client.getScope()));
49         clientDetails.setRegisteredRedirectUri(StringUtils.commaDelimitedListToSet(client.getRedirectUris()));
50         clientDetails.setAuthorities(Collections.emptyList());
51         int accessTokenValiditySeconds = Optional
52                 .ofNullable(client.getAccessTokenValidity())
53                 .orElse(properties.getAccessTokenValiditySeconds());
54         clientDetails.setAccessTokenValiditySeconds(accessTokenValiditySeconds);
55         int refreshTokenValiditySeconds = Optional
56                 .ofNullable(client.getRefreshTokenValidity())
57                 .orElse(properties.getRefreshTokenValiditySeconds());
58         clientDetails.setRefreshTokenValiditySeconds(refreshTokenValiditySeconds);
59         clientDetails.setAutoApproveScopes(StringUtils.commaDelimitedListToSet(client.getAutoApproveScopes()));
60         String json = client.getAdditionalInformation();
61         if (org.apache.commons.lang3.StringUtils.isNotBlank(json)) {
62             try {
63                 Map<String, Object> additionalInformation = mapper.readValue(json, Map.class);
64                 clientDetails.setAdditionalInformation(additionalInformation);
65             } catch (Exception e) {
66                 LOGGER.warn("parser addition info error: {}", e);
67             }
68         }
69         return clientDetails;
70     }
71 }
View Code

② 認證伺服器配置,主要是針對授權服務埠的配置,配置使用Redis來儲存令牌。

 1 package com.lyyzoo.sunny.security.config;
 2 
 3 import javax.sql.DataSource;
 4 
 5 import com.lyyzoo.sunny.security.core.CustomUserDetailsService;
 6 import com.lyyzoo.sunny.security.oauth.CustomClientDetailsService;
 7 import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
 8 import org.springframework.context.annotation.Bean;
 9 import org.springframework.context.annotation.Configuration;
10 import org.springframework.data.redis.connection.RedisConnectionFactory;
11 import org.springframework.security.authentication.AuthenticationManager;
12 import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
13 import org.springframework.security.crypto.password.NoOpPasswordEncoder;
14 import org.springframework.security.oauth2.config.annotation.configurers.ClientDetailsServiceConfigurer;
15 import org.springframework.security.oauth2.config.annotation.web.configuration.AuthorizationServerConfigurerAdapter;
16 import org.springframework.security.oauth2.config.annotation.web.configuration.EnableAuthorizationServer;
17 import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerEndpointsConfigurer;
18 import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerSecurityConfigurer;
19 import org.springframework.security.oauth2.provider.code.JdbcAuthorizationCodeServices;
20 import org.springframework.security.oauth2.provider.token.store.redis.RedisTokenStore;
21 
22 /**
23  * 認證伺服器配置
24  *
25  * @author bojiangzhou 2018/11/02
26  */
27 @EnableAuthorizationServer
28 @Configuration
29 public class AuthorizationServerConfiguration extends AuthorizationServerConfigurerAdapter {
30 
31     @Override
32     public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
33         security
34                 .passwordEncoder(NoOpPasswordEncoder.getInstance())
35                 .allowFormAuthenticationForClients()
36         ;
37     }
38 
39     private static final String FIELD_ACCESS_TOKEN = "oauth2:access_token:";
40 
41     private AuthenticationManager authenticationManager;
42     private CustomClientDetailsService clientDetailsService;
43     private CustomUserDetailsService userDetailsService;
44     private DataSource dataSource;
45     private RedisConnectionFactory redisConnectionFactory;
46 
47     public AuthorizationServerConfiguration(AuthenticationConfiguration authenticationConfiguration,
48                                             CustomClientDetailsService clientDetailsService,
49                                             CustomUserDetailsService userDetailsService,
50                                             DataSource dataSource,
51                                             RedisConnectionFactory redisConnectionFactory) throws Exception {
52         this.authenticationManager = authenticationConfiguration.getAuthenticationManager();
53         this.clientDetailsService = clientDetailsService;
54         this.userDetailsService = userDetailsService;
55         this.dataSource = dataSource;
56         this.redisConnectionFactory = redisConnectionFactory;
57     }
58 
59     @Override
60     public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
61         endpoints
62                 .authorizationCodeServices(new JdbcAuthorizationCodeServices(dataSource))
63                 .tokenStore(tokenStore())
64                 .userDetailsService(userDetailsService)
65                 .authenticationManager(authenticationManager)
66         ;
67     }
68 
69     @Override
70     public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
71         clients.withClientDetails(clientDetailsService);
72     }
73 
74     @Bean
75     @ConditionalOnMissingBean(RedisTokenStore.class)
76     public RedisTokenStore tokenStore() {
77         RedisTokenStore redisTokenStore = new RedisTokenStore(redisConnectionFactory);
78         redisTokenStore.setPrefix(FIELD_ACCESS_TOKEN);
79         return redisTokenStore;
80     }
81 
82 }
View Code

七、總結

1、參考文件

Spring Security 參考手冊

Spring Security 核心過濾器鏈分析

Spring boot security

初識 Spring Security

理解OAuth 2.0

JSON Web Token 入門教程

The OAuth 2.0 Authorization Framework

Spring Security 與 OAuth2

2、總結

本篇主要講述了基於SpringSecurity和OAuth2的幾種登入認證方式,主要是分析了整個流程以及相關的原始碼、原理。前後端分離部分目前只是使用 Postman 簡單測試了下,後面有時間考慮使用 Vue 做前端框架,搭建一個前端出來,後面再完善。

本來還要做SSO單點登入和授權相關的內容的,考慮到時間精力有限,就不在這裡做介紹了。通過前面對原始碼的分析梳理,相信這部分內容也不在話下。

下一步計劃是做 Spring cloud 這部分的內容,開發微服務中的註冊中心(Eureka)、閘道器(Gateway)等等,通過開發這些服務,去熟悉spring cloud 的使用、熟悉部分核心程式碼及原理。

 

<------------------------------------------------------------------------------------------------------------->

關鍵詞:import security org springframework 認證 使用者 public 登入 string sunny

相關推薦:

No more