基於 SpringSecurity 實現標準用戶名密碼登錄,基於 SpringSocial 實現QQ登錄,基於 OAuth2 實現認證伺服器。在完成登錄功能的同時,一步步分析 spring security、spring social、oauth 的實現原理,源碼分析等。 ...
零、前言
本文基於《基於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