零、前言
本文基於《基於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 }
④ 從 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 }
③ 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 }
④ 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 }
⑤ 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 }
⑥ 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 }
⑦ 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 }
⑧ 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 }
⑨ 配置
前面的開發完成當然還需做配置,通過 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 }
⑩ 登錄頁面
三、手機短信登錄
經過前面用戶名+密碼的登錄流程分析后,現在再來開發手機號+短信驗證碼的方式登錄。手機短信登錄無法直接使用標准登錄的流程,所以需要模擬標准登錄流程開發。
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 }
② 短信登錄認證過濾器
參照 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 }
③ 短信登錄認證器
參考 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 }
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 }
之后,需要在 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 }
短信登錄頁面:
四、三方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>
① 首先是服務提供商,對應 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 }

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 }
提供 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 }
② 構建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 }
③ 定制化 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 }
④ 通過 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 }
⑤ 通過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 }
⑥ 自定義 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 }

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 }
⑥ 自定義 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 }
⑦ 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 }
⑧ 如果用戶未綁定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 }
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 }
② 認證服務器配置,主要是針對授權服務端口的配置,配置使用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 }
七、總結
1、參考文檔
The OAuth 2.0 Authorization Framework
2、總結
本篇主要講述了基於SpringSecurity和OAuth2的幾種登錄認證方式,主要是分析了整個流程以及相關的源碼、原理。前后端分離部分目前只是使用 Postman 簡單測試了下,后面有時間考慮使用 Vue 做前端框架,搭建一個前端出來,后面再完善。
本來還要做SSO單點登錄和授權相關的內容的,考慮到時間精力有限,就不在這里做介紹了。通過前面對源碼的分析梳理,相信這部分內容也不在話下。
下一步計划是做 Spring cloud 這部分的內容,開發微服務中的注冊中心(Eureka)、網關(Gateway)等等,通過開發這些服務,去熟悉spring cloud 的使用、熟悉部分核心代碼及原理。
3、源碼地址
源碼僅供參考,很多代碼都不完善,盡自己學習使用。
https://gitee.com/bojiangzhou/sunny [都不點贊還要源碼 o(一︿一+)o ]
<------------------------------------------------------------------------------------------------------------->