1. SpringSecurity框架簡介
1.1概要
Spring 是非常流行和成功的 Java 應用開發框架,SpringSecurity 正是 Spring 家族中的成員。SpringSecurity 基於 Spring 框架,提供了一套 Web 應用安全性的完整解決方案。
正如你可能知道的關於安全方面的兩個主要區域是“認證”和“授權”(或者訪問控制),一般來說,Web 應用的安全性包括用戶認證(Authentication)和用戶授權(Authorization)兩個部分,這兩點也是 Spring Security 重要核心功能。
(1)用戶認證指的是:驗證某個用戶是否為系統中的合法主體,也就是說用戶能否訪問該系統。用戶認證一般要求用戶提供用戶名和密碼。系統通過校驗用戶名和密碼來完成認證過程。通俗點說就是系統認為用戶是否能登錄
(2)用戶授權指的是驗證某個用戶是否有權限執行某個操作。在一個系統中,不同用戶所具有的權限是不同的。比如對一個文件來說,有的用戶只能進行讀取,而有的用戶可以進行修改。一般來說,系統會為不同的用戶分配不同的角色,而每個角色則對應一系列的權限。通俗點講就是系統判斷用戶是否有權限去做某些事情。
1.2歷史
SpringSecurity 開始於 2003 年年底,““spring 的 acegi 安全系統”。 起因是 Spring開發者郵件列表中的一個問題,有人提問是否考慮提供一個基於 spring 的安全實現。
SpringSecurity 以“The Acegi Secutity System forSpring” 的名字始於 2013 年晚些時候。一個問題提交到 Spring 開發者的郵件列表,詢問是否已經有考慮一個機遇 Spring的安全性社區實現。那時候 Spring 的社區相對較小(相對現在)。實際上 Spring 自己在2013 年只是一個存在於 ScourseForge 的項目,這個問題的回答是一個值得研究的領域,雖然目前時間的缺乏組織了我們對它的探索。
考慮到這一點,一個簡單的安全實現建成但是並沒有發布。幾周后,Spring 社區的其他成員詢問了安全性,這次這個代碼被發送給他們。其他幾個請求也跟隨而來。到 2014 年一月大約有 20 萬人使用了這個代碼。這些創業者的人提出一個 SourceForge 項目加入是為了,這是在 2004 三月正式成立。
在早些時候,這個項目沒有任何自己的驗證模塊,身份驗證過程依賴於容器管理的安全性和 Acegi 安全性。而不是專注於授權。開始的時候這很適合,但是越來越多的用戶請求額外的容器支持。容器特定的認證領域接口的基本限制變得清晰。還有一個相關的問題增加新的容器的路徑,這是最終用戶的困惑和錯誤配置的常見問題。
Acegi 安全特定的認證服務介紹。大約一年后,Acegi 安全正式成為了Spring 框架的子項目。1.0.0 最終版本是出版於 2006 -在超過兩年半的大量生產的軟件項目和數以百計的改進和積極利用社區的貢獻。
Acegi 安全 2007 年底正式成為了 Spring 組合項目,更名為"Spring Security"。
1.3同款產品對比
1.3.1 Spring Security
Spring 技術棧的組成部分。
通過提供完整可擴展的認證和授權支持保護你的應用程序。
https://spring.io/projects/spring-security
SpringSecurity特點:
-
和 Spring 無縫整合。
-
全面的權限控制。
-
專門為 Web 開發而設計。
- 舊版本不能脫離Web 環境使用。
- 新版本對整個框架進行了分層抽取,分成了核心模塊和Web 模塊。單獨引入核心模塊就可以脫離Web 環境。
-
重量級。
1.3.2 Shiro
Apache 旗下的輕量級權限控制框架。
特點:
- 輕量級。Shiro 主張的理念是把復雜的事情變簡單。針對對性能有更高要求
的互聯網應用有更好表現。
-
通用性。
- 好處:不局限於Web 環境,可以脫離 Web 環境使用。
- 缺陷:在Web 環境下一些特定的需求需要手動編寫代碼定制。
Spring Security 是 Spring家族中的一個安全管理框架,實際上,在 Spring Boot 出現之前,Spring Security 就已經發展了多年了,但是使用的並不多,安全管理這個領域,一直是 Shiro 的天下。
相對於 Shiro,在 SSM 中整合Spring Security 都是比較麻煩的操作,所以,SpringSecurity 雖然功能比Shiro 強大,但是使用反而沒有 Shiro 多(Shiro 雖然功能沒有Spring Security 多,但是對於大部分項目而言,Shiro 也夠用了)。
自從有了 Spring Boot 之后,Spring Boot 對於 Spring Security 提供了自動化配置方案,可以使用更少的配置來使用 SpringSecurity。因此,一般來說,常見的安全管理技術棧的組合是這樣的:
- SSM + Shiro
- Spring Boot/Spring Cloud + SpringSecurity
以上只是一個推薦的組合而已,如果單純從技術上來說,無論怎么組合,都是可以運行的。
1.4 模塊划分
....
2.SpringSecurity入門案例
2.1 創建一個項目
2.2 運行這個項目
訪問 localhost:8080
默認的用戶名:user
密碼在項目啟動的時候在控制台會打印,注意每次啟動的時候密碼都回發生變化!
輸入用戶名,密碼,這樣表示可以訪問了,404 表示我們沒有這個控制器,但是我們可以 訪問了。
2.3 權限管理中的相關概念
2.3.1 主體
英文單詞:principal
使用系統的用戶或設備或從其他系統遠程登錄的用戶等等。簡單說就是誰使用系 統誰就是主體。
2.3.2 認證
英文單詞:authentication
權限管理系統確認一個主體的身份,允許主體進入系統。簡單說就是“主體”證 明自己是誰。 籠統的認為就是以前所做的登錄操作。
2.3.3 授權
英文單詞:authorization
將操作系統的“權力”“授予”“主體”,這樣主體就具備了操作系統中特定功 能的能力。 所以簡單來說,授權就是給用戶分配權限。
2.4 添加一個控制器進行訪問
package com.tuniu.controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("test")
public class HelloSecurity {
@GetMapping("/hello")
public String hello() {
return "hello Security;";
}
}
2.5 SpringSecurity 基本原理
2.5.1 SpringSecurity 本質
是一個過濾器鏈
從啟動是可以獲取到過濾器鏈:
org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFil
ter
org.springframework.security.web.context.SecurityContextPersistenceFilter
org.springframework.security.web.header.HeaderWriterFilter
org.springframework.security.web.csrf.CsrfFilter
org.springframework.security.web.authentication.logout.LogoutFilter
org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter
org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter
org.springframework.security.web.authentication.ui.DefaultLogoutPageGeneratingFilter
org.springframework.security.web.savedrequest.RequestCacheAwareFilter
org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter
org.springframework.security.web.authentication.AnonymousAuthenticationFilter
org.springframework.security.web.session.SessionManagementFilter
org.springframework.security.web.access.ExceptionTranslationFilter
org.springframework.security.web.access.intercept.FilterSecurityInterceptor
代碼底層流程:
重點看三個過濾器:
FilterSecurityInterceptor
ExceptionTranslationFilter
UsernamePasswordAuthenticationFilter
FilterSecurityInterceptor::是一個方法級的權限過濾器, 基本位於過濾鏈的最底部
super.beforeInvocation(fi) 表示查看之前的 filter 是否通過。
fi.getChain().doFilter(fi.getRequest(), fi.getResponse());表示真正的調用后台的服務。
ExceptionTranslationFilter::是個異常過濾器,用來處理在認證授權過程中拋出的異常
UsernamePasswordAuthenticationFilter :對/login 的 POST 請求做攔截,校驗表單中用戶 名,密碼。
2.5.2 SpringSecurity過濾器啟動原理
1.使用springsecurity配置過濾器(DelegatingFilterProxy);
2.該類的doFilter方法會通過initDelegate(wac)先獲取到過濾器bean(FilterChainProxy),通過他去初始化其他的filter(delegate.init(this.getFilterConfig()););
3.FilterChainProxy的doFilter方法會通過getFilters(fwRequest)方法獲取到WebApplicationContext配置中的所有配置過的的filter;
4.緊接着開始往后加載其他的filter
2.6 UserDetailsService 接口講解
當什么也沒有配置的時候,賬號和密碼是由 Spring Security 定義生成的。而在實際項目中 賬號和密碼都是從數據庫中查詢出來的。 所以我們要通過自定義邏輯控制認證邏輯。
如果需要自定義邏輯時,只需要實現 UserDetailsService 接口即可。接口定義如下:
返回值 UserDetails
這個類是系統默認的用戶"主體"
// 表示獲取登錄用戶所有權限
Collection<? extends GrantedAuthority> getAuthorities();
// 表示獲取密碼
String getPassword();
// 表示獲取用戶名
String getUsername();
// 表示判斷賬戶是否過期
boolean isAccountNonExpired();
// 表示判斷賬戶是否被鎖定
boolean isAccountNonLocked();
// 表示憑證{密碼}是否過期
boolean isCredentialsNonExpired();
// 表示當前用戶是否可用
boolean isEnabled();
以下是 UserDetails 實現類
以后我們只需要使用 User 這個實體類即可!
方法參數 username:
表示用戶名。此值是客戶端表單傳遞過來的數據。默認情況下必須叫 username,否則無 法接收。
2.7 PasswordEncoder 接口講解
// 表示把參數按照特定的解析規則進行解析
String encode(CharSequence rawPassword);
// 表示驗證從存儲中獲取的編碼密碼與編碼后提交的原始密碼是否匹配。如果密碼匹
配,則返回 true;如果不匹配,則返回 false。第一個參數表示需要被解析的密碼。第二個
參數表示存儲的密碼。
boolean matches(CharSequence rawPassword, String encodedPassword);
// 表示如果解析的密碼能夠再次進行解析且達到更安全的結果則返回 true,否則返回
false。默認返回 false。
default boolean upgradeEncoding(String encodedPassword) {
return false;
}
接口實現類:
BCryptPasswordEncoder 是 Spring Security 官方推薦的密碼解析器,平時多使用這個解析器。
BCryptPasswordEncoder 是對 bcrypt 強散列方法的具體實現。是基於 Hash 算法實現的單向加密。可以通過 strength 控制加密強度,默認 10.
查用方法演示:
@Test
public void test01(){
// 創建密碼解析器
BCryptPasswordEncoder bCryptPasswordEncoder = new
BCryptPasswordEncoder();
// 對密碼進行加密
String atguigu = bCryptPasswordEncoder.encode("atguigu");
// 打印加密之后的數據
System.out.println("加密之后數據:\t"+atguigu);
//判斷原字符加密后和加密之前是否匹配
boolean result = bCryptPasswordEncoder.matches("atguigu", atguigu);
// 打印比較結果
System.out.println("比較結果:\t"+result);
}
2.8 SpringBoot 對 Security 的自動配置
https://docs.spring.io/springsecurity/site/docs/5.3.4.RELEASE/reference/html5/#servlet-hello
3. SpringSecurity Web 權限方案
3.1 設置登錄系統的賬號、密碼
方式一:在 application.properties
spring.security.user.name=atguigu
spring.security.user.password=atguigu
方式二:編寫類實現接口
package com.tuniu.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
String encode = passwordEncoder.encode("123");
auth.inMemoryAuthentication().withUser("lzg").password(encode).roles("admin");
}
@Bean
PasswordEncoder getPasswordEncoder(){
return new BCryptPasswordEncoder();
}
}
3.2 實現數據庫認證來完成用戶登錄
完成自定義登錄
3.2.1 准備 sql
CREATE TABLE `users` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`username` varchar(20) NOT NULL,
`password` varchar(100) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `username` (`username`)
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4
--密碼:123
insert into users(username,password) values('admin','$2a$10$onNtpOIhtMf.4IVyecahWOGD9/1phA/I1lT4LdU9lMQrUbTyyH3n2');--id = 1
insert into users(username,password) values('luy','$2a$10$MVQfFUNDNTGtZQYeV1aD/el3nZ2Fq.puiDR0B8FMCe1wBYtIYJbGS');--id = 2
create table role(
id bigint primary key auto_increment,
name varchar(20)
);
insert into role values(1,'管理員');
insert into role values(2,'普通用戶');
create table role_user(
uid bigint,
rid bigint
);
insert into role_user values(1,1);
insert into role_user values(2,2);
create table menu(
id bigint primary key auto_increment,
name varchar(20),
url varchar(100),
parentid bigint,
permission varchar(20)
);
insert into menu values(1,'系統管理','',0,'menu:system');
insert into menu values(2,'用戶管理','',0,'menu:user');
create table role_menu(
mid bigint,
rid bigint
);
insert into role_menu values(1,1);
insert into role_menu values(2,1);
insert into role_menu values(2,2);
3.2.2 添加依賴
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!--mybatis-plus-->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.0.5</version>
</dependency>
<!--mysql-->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<!--lombok 用來簡化實體類-->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
3.2.3 制作實體類
@Data
public class Users {
private Integer id;
private String username;
private String password;
}
3.2.4 整合 MybatisPlus 制作 mapper
@Mapper
public interface UsersMapper extends BaseMapper<Users> {
}
3.2.5 制作登錄實現類
@Service("userDetailsService")
public class MyUserDetailsService implements UserDetailsService {
@Resource
private UsersMapper usersMapper;
@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
QueryWrapper<Users> wrapper = new QueryWrapper<>();
wrapper.eq("username",s);
Users users = usersMapper.selectOne(wrapper);
if (users == null) {
throw new UsernameNotFoundException("用戶名不存在!");
}
System.out.println(users);
List<GrantedAuthority> list = AuthorityUtils.commaSeparatedStringToAuthorityList("ROLE_admin");
return new User(users.getUsername(),users.getPassword(),list);
}
}
3.2.6 配置數據庫鏈接
#mysql8 數據庫連接
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/demo?serverTimezone=GMT%2B8
spring.datasource.username=root
spring.datasource.password=root
3.2.7 測試訪問
輸入用戶名,密碼
3.3 未認證請求跳轉到登錄頁
3.3.1 引入前端模板依賴
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
3.3.2 引入登錄頁面
將准備好的登錄頁面導入項目中,放入templates文件夾下
<!DOCTYPE html>
<!-- 需要添加
<html xmlns:th="http://www.thymeleaf.org">
這樣在后面的th標簽就不會報錯
-->
<html xmlns:th="http://www.thymeleaf.org">
<head lang="en">
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
<title>xx</title>
</head>
<body>
<h1>表單提交</h1>
<!-- 表單提交用戶信息,注意字段的設置,直接是*{} -->
<form action="/login" method="POST">
用戶名:<input type="text" name="username"/><br/>
密碼:<input type="password" name="password"/><br/>
<input type="submit"value="提交"/>
</form>
</body>
</html>
注意:頁面提交方式必須為 post 請求,所以上面的頁面不能使用,用戶名,密碼必須為 username,password 原因: 在執行登錄的時候會走一個過濾器 UsernamePasswordAuthenticationFilter
3.3.3 編寫控制器
@Controller
public class LoginController {
@GetMapping("/loginPage")
public String login(){
return "login";
}
@RequestMapping("/fail")
public String fail(){
return "fail";
}
@RequestMapping("/success")
public String success(){
return "success";
}
@GetMapping("findAll")
@ResponseBody
public String findAll(){
return "findAll";
}
}
3.3.4 編寫配置類放行登錄頁面以及靜態資源
在SecurityConfig配置類:
@Configuration
public class SecurityConfiger extends WebSecurityConfigurerAdapter {
@Resource
private MyUserDetailsService userDetailsService;
//將密碼加密器加到容器中
@Bean
public PasswordEncoder getPasswordEncoder(){
return new BCryptPasswordEncoder();
}
//重寫configure配置,將我們自己的校驗密碼器注入到該bean中
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService).passwordEncoder(getPasswordEncoder());
}
//重寫configure配置,編寫權限校驗規則
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests() // 認證配置
.antMatchers("/","/login","/hello").permitAll() //設置哪些路徑可以直接訪問,不需要認證
.anyRequest() // 任何請求
.authenticated() // 都需要身份驗證
.and().csrf().disable();//關閉cors
}
}
3.3.5 測試
訪問:http://127.0.0.1:8081/loginPage
訪問http://127.0.0.1:8081/findAll會提示 403 錯誤 表示沒有這個權限。
3.3.6 設置未授權的請求跳轉到登錄頁
配置類configure(HttpSecurity http)方法加入:
@Configuration
public class SecurityConfiger extends WebSecurityConfigurerAdapter {
@Resource
private MyUserDetailsService userDetailsService;
//將密碼加密器加到容器中
@Bean
public PasswordEncoder getPasswordEncoder(){
return new BCryptPasswordEncoder();
}
//重寫configure配置,將我們自己的校驗密碼器注入到該bean中
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService).passwordEncoder(getPasswordEncoder());
}
//重寫configure配置,編寫權限校驗規則
@Override
protected void configure(HttpSecurity http) throws Exception {
http.formLogin()
.loginPage("/loginPage").permitAll() //登錄頁面跳轉請求
.loginProcessingUrl("/login").permitAll() //登錄發起的post請求方法
.successForwardUrl("/success").permitAll() //登錄成功頁面
.failureForwardUrl("/fail"); //無權限頁面
http.authorizeRequests() // 認證配置
.antMatchers("/","/login","/hello").permitAll() //設置哪些路徑可以直接訪問,不需要認證
.anyRequest() // 任何請求
.authenticated() // 都需要身份驗證
.and().csrf().disable(); //關閉cors
}
}
如果修改配置可以調用 usernameParameter()和 passwordParameter()方法。
<form action="/login" method="post">
用戶名:<input type="text" name="loginAcct"/><br/>
密碼:<input type="password" name="userPswd"/><br/>
<input type="submit" value="提交"/>
</form>
小記:
用戶信息查詢使用的類是:UserDetailsService,需要對它進行繼承並重寫,需要將重寫后的bean通過configure(AuthenticationManagerBuilder auth)注入到配置類中
密碼加密使用的類是:PasswordEncoder,需要將它進行實例化,使用BCryptPasswordEncoder實例化,並放入spring容器中
權限規則的修改類是:繼承WebSecurityConfigurerAdapter,並重寫里面的configure(HttpSecurity http)方法
3.4 基於角色或權限進行訪問控制
3.4.1 hasAuthority 方法
如果當前的主體具有指定的權限,則返回 true,否則返回 false
只能對一個權限進行驗證
- 修改配置類
http.authorizeRequests() // 認證配置
.antMatchers("/","/login","/hello").permitAll() //設置哪些路徑可以直接訪問,不需要認證
.antMatchers("/findAll").hasAuthority("admin") //hasAuthority(),當前登錄的用戶,只有具有了admin權限才可以訪問這個路徑
.anyRequest() // 任何請求
.authenticated() // 都需要身份驗證
.and().csrf().disable();
- 添加一個控制器
@GetMapping("/find")
@ResponseBody
public String find(){
return "find";
}
- 給用戶登錄主體賦予權限
- 測試結果:
先要登錄,並且用戶必須要admin權限才能進行訪問:
List<GrantedAuthority> list = AuthorityUtils.commaSeparatedStringToAuthorityList("admin");
定義用戶的權限。
3.4.2 hasAnyAuthority 方法
如果當前的主體有任何提供的權限(給定的作為一個逗號分隔的字符串列表)的話,返回 true.
http.authorizeRequests() // 認證配置
.antMatchers("/","/login","/hello").permitAll() //設置哪些路徑可以直接訪問,不需要認證
.antMatchers("/findAll").hasAnyAuthority("admin,manager") //hasAnyAuthority(),當前登錄的用戶,有其中一個權限就能訪問這個路徑
.anyRequest() // 任何請求
.authenticated() // 都需要身份驗證
.and().csrf().disable();
3.4.3 hasRole 方法
如果用戶具備給定角色就允許訪問,否則出現 403。
如果當前主體具有指定的角色,則返回 true。
底層源碼:
給用戶添加角色:
AuthorityUtils.commaSeparatedStringToAuthorityList("ROLE_admin");
@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
QueryWrapper<Users> wrapper = new QueryWrapper<>();
wrapper.eq("username",s);
Users users = usersMapper.selectOne(wrapper);
if (users == null) {
throw new UsernameNotFoundException("用戶名不存在!");
}
System.out.println(users);
List<GrantedAuthority> list = AuthorityUtils.commaSeparatedStringToAuthorityList("admin,ROLE_sale");
return new User(users.getUsername(),users.getPassword(),list);
}
修改配置文件:
注意配置文件中不需要添加”ROLE_“,因為上述的底層代碼會自動添加與之進行匹配。
http.authorizeRequests() // 認證配置
.antMatchers("/","/login","/hello").permitAll() //設置哪些路徑可以直接訪問,不需要認證
.antMatchers("/findAll").hasRole("sale") //hasRole(),只有具有了sale角色的才能訪問該路徑
.anyRequest() // 任何請求
.authenticated() // 都需要身份驗證
.and().csrf().disable();
3.4.4 hasAnyRole
表示用戶具備任何一個角色都可以訪問。
給用戶添加角色:
List<GrantedAuthority> list = AuthorityUtils.commaSeparatedStringToAuthorityList("admin,ROLE_admin,ROLE_sale");
修改配置文件:
http.authorizeRequests() // 認證配置
.antMatchers("/","/login","/hello").permitAll() //設置哪些路徑可以直接訪問,不需要認證
.antMatchers("/findAll").hasAnyRole("sale,admin") //hasAnyRole(),只有具有了sale或admin角色的才能訪問該路徑
.anyRequest() // 任何請求
.authenticated() // 都需要身份驗證
.and().csrf().disable();
3.5 基於數據庫實現權限認證
3.5.1 添加實體類
@Data
public class Menu {
private Long id;
private String name;
private String url;
private Long parentId;
private String permission;
}
@Data
public class Role {
private Long id;
private String name;
}
3.5.2 編寫接口與實現
UserInfoMapper:
@Mapper
public interface UserInfoMapper extends BaseMapper<Role> {
/**
* 根據用戶 Id 查詢用戶角色
* @param userId
* @return
*/
List<Role> selectRoleByUserId(Long userId);
/**
* 根據用戶 Id 查詢菜單
* @param userId
* @return
*/
List<Menu> selectMenuByUserId(Long userId);
}
上述接口需要進行多表管理查詢:
需要在 resource/mapper 目錄下自定義 UserInfoMapper.xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.tuniu.springsecurity02.mapper.UserInfoMapper">
<!--根據用戶 Id 查詢角色信息-->
<select id="selectRoleByUserId" resultType="com.tuniu.springsecurity02.entity.Role">
SELECT r.id,r.name FROM role r
INNER JOIN role_user ru ON ru.rid=r.id
where ru.uid=#{0}
</select>
<!--根據用戶 Id 查詢權限信息-->
<select id="selectMenuByUserId" resultType="com.tuniu.springsecurity02.entity.Menu">
SELECT m.id,m.name,m.url,m.parentid,m.permission
FROM menu m
INNER JOIN role_menu rm ON m.id=rm.mid
INNER JOIN role r ON r.id=rm.rid
INNER JOIN role_user ru ON r.id=ru.rid
WHERE ru.uid=#{0}
</select>
</mapper>
修改UsersServiceImpl類,主要是加了查詢數據庫和解析返回數據的動作:
@Service("userDetailsService")
public class MyUserDetailsService implements UserDetailsService {
@Resource
private UsersMapper usersMapper;
@Resource
private UserInfoMapper userInfoMapper;
@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
QueryWrapper<Users> wrapper = new QueryWrapper<>();
wrapper.eq("username",s);
Users users = usersMapper.selectOne(wrapper);
if (users == null) {
throw new UsernameNotFoundException("用戶名不存在!");
}
System.out.println(users);
//權限集合
List<GrantedAuthority> list = new ArrayList<>();
List<Menu> menus = userInfoMapper.selectMenuByUserId(Long.valueOf(users.getId()));
List<Role> roles = userInfoMapper.selectRoleByUserId(Long.valueOf(users.getId()));
//處理權限
for (Menu menu : menus) {
list.add(new SimpleGrantedAuthority(menu.getPermission()));
}
//處理角色
for (Role role : roles) {
list.add(new SimpleGrantedAuthority("ROLE_"+role.getName()));
}
// List<GrantedAuthority> list = AuthorityUtils.commaSeparatedStringToAuthorityList("admin,ROLE_admin,ROLE_role");
return new User(users.getUsername(),users.getPassword(),list);
}
}
3.5.3 在配置文件中添加映射
在配置文件中 application.properties 添加
#配置xml文件所在的位置
mybatis-plus.mapper-locations=classpath*:/mapper/**/*.xml
3.5.4 修改訪問配置類
http.authorizeRequests() // 認證配置
.antMatchers("/","/login","/hello").permitAll() //設置哪些路徑可以直接訪問,不需要認證
.antMatchers("/findAll").hasRole("管理員")
.antMatchers("/findAll").hasAnyAuthority("menu:system")
.anyRequest() // 任何請求
.authenticated() // 都需要身份驗證
.and().csrf().disable();
3.5.5 使用管理員與非管理員進行測試
如果非管理員賬號(luy)測試會提示 403 沒有權限
管理員賬號(admin)登錄能正常訪問
3.6 自定義 403 頁面
3.6.1 修改訪問配置類
//配置沒有權限訪問跳轉自定義頁面
http.exceptionHandling().accessDeniedPage("/fail");
3.6.2 添加對應控制器
@RequestMapping("/fail")
public String fail(){
return "fail";
}
fail.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<h1>沒有訪問權限!</h1>
</body>
</html>
3.6.3 測試
3.7 注解使用
3.7.1 @Secured
判斷是否具有角色,另外需要注意的是這里匹配的字符串需要添加前綴“ROLE_“。
進入方法前校驗權限
使用注解先要開啟注解功能!
@EnableGlobalMethodSecurity(securedEnabled=true)
@SpringBootApplication
@MapperScan("com.tuniu.springsecurity02.mapper")
@EnableGlobalMethodSecurity(securedEnabled = true)
public class Springsecurity02Application {
public static void main(String[] args) {
SpringApplication.run(Springsecurity02Application.class, args);
}
}
在控制器方法上添加注解
@GetMapping("/testSecured")
@Secured({"ROLE_管理員","ROLE_普通用戶"}) //校驗權限的注解
public String testSecured(){
return "hello,Secured";
}
@GetMapping("/testSecured1")
@Secured({"ROLE_普通用戶"}) //校驗權限的注解
public String testSecured1(){
return "hello,Secured1";
}
@GetMapping("/testSecured2")
@Secured({"ROLE_管理員"}) //校驗權限的注解
public String testSecured2(){
return "hello,Secured2";
}
結果:
luy,只有‘’普通用戶‘’角色,只能訪問/testSecured,/testSecured1請求
admin,只有‘管理員‘角色,只能訪問/testSecured,/testSecured2請求
3.7.2 @PreAuthorize
@PreAuthorize:注解適合進入方法前的權限驗證, @PreAuthorize 可以將登錄用 戶的 roles/permissions 參數傳到方法中。
進入方法前校驗權限
先開啟注解功能:
@EnableGlobalMethodSecurity(prePostEnabled = true)
@SpringBootApplication
@MapperScan("com.tuniu.springsecurity02.mapper")
@EnableGlobalMethodSecurity(securedEnabled = true,prePostEnabled = true)
public class Springsecurity02Application {
public static void main(String[] args) {
SpringApplication.run(Springsecurity02Application.class, args);
}
}
@GetMapping("/testPreAuthorize")
@PreAuthorize("hasAnyAuthority('menu:system')")
public String testPreAuthorize(){
System.out.println("PreAuthorize");
return "hello PreAuthorize";
}
@GetMapping("/testPreAuthorize1")
@PreAuthorize("hasRole('ROLE_普通用戶')")
public String testPreAuthorize1(){
System.out.println("PreAuthorize1");
return "hello PreAuthorize1";
}
測試結果:
luy,只有‘’普通用戶‘’角色,能訪問/testPreAuthorize1;只有menu:user權限,不能訪問/testPreAuthorize
Admin,只有‘’管理員‘’角色,不能訪問/testPreAuthorize1;有menu:system權限,能訪問/testPreAuthorize
3.7.3 @PostAuthorize
先開啟注解功能:
@EnableGlobalMethodSecurity(prePostEnabled = true)
@PostAuthorize 注解使用並不多,在方法執行后再進行權限驗證,適合驗證帶有返回值的權限.
進入方法后,對出參數進行校驗權限
@GetMapping("/testPostAuthorize")
@PostAuthorize("hasAnyAuthority('menu:system')")
public String preAuthorize(){
System.out.println("test--PostAuthorize");
return "PostAuthorize";
}
測試結果:
luy,只有menu:user權限,不能訪問/testPostAuthorize,會打印test--PostAuthorize
Admin,有menu:system權限,能訪問/testPostAuthorize,會打印test--PostAuthorize
3.7.4 @PostFilter
@PostFilter :權限驗證之后對數據進行過濾 留下用戶名是 admin1 的數據
表達式中的 filterObject 引用的是方法返回值 List 中的某一個元素
@RequestMapping("/getAll")
@PreAuthorize("hasRole('ROLE_管理員')")
@PostFilter("filterObject.username == 'admin1'")
public List<Users> getAllUser(){
System.out.println("PostFilter");
List<Users> list = new ArrayList<>();
list.add(new Users(1,"admin1","6666"));
list.add(new Users(2,"admin2","888"));
return list;
}
測試結果:
luy,只有‘’普通用戶‘’角色,不能訪問/getAll;不會打印PostFilter;
Admin,只有‘’管理員‘’角色,能訪問/getAll;會打印PostFilter;
前端返回結果是:
3.7.5 @PreFilter
@PreFilter: 進入控制器之前對數據進行過濾
@RequestMapping("getTestPreFilter")
@PreAuthorize("hasRole('ROLE_管理員')")
@PreFilter(value = "filterObject.id%2==0")
public List<Users> getTestPreFilter(@RequestBody List<Users> list){
list.forEach(t-> {
System.out.println(t.toString());
});
return list;
}
先登錄,然后使用apiPost 進行測試,拷貝登錄后的cookie
測試的 Json 數據:
[
{
"id": 1,
"username": "admin",
"password": "666"
},
{
"id": 2,
"username": "admins",
"password": "888"
},
{
"id": 3,
"username": "admins11",
"password": "11888"
},
{
"id": 4,
"username": "admins22",
"password": "22888"
}
]
測試返回結果:
[
{
"id": 2,
"username": "admins",
"password": "888"
},
{
"id": 4,
"username": "admins22",
"password": "22888"
}
]
3.7.6 權限表達式
https://docs.spring.io/spring-security/site/docs/5.3.4.RELEASE/reference/html5/#el-access
3.8 基於數據庫的記住我
3.8.1 創建表
CREATE TABLE persistent_logins (
username varchar(64) NOT NULL,
series varchar(64) NOT NULL,
token varchar(64) NOT NULL,
last_used timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE
CURRENT_TIMESTAMP,
PRIMARY KEY (series)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
3.8.2 添加數據庫的配置文件
spring.datasource.driver-class-name=com.mysql.jdbc.Driver
spring.datasource.url=jdbc:mysql://127.0.0.1:3306/spring5?serverTimezone=GMT%2B8
spring.datasource.username=root
spring.datasource.password=root
3.8.3 編寫配置類
創建配置類:
@Configuration
public class BrowserSecurityConfig {
@Resource
private DataSource dataSource;
//注入操作的類PersistentTokenRepository
@Bean
public PersistentTokenRepository getPersistentTokenRepository(){
JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl();
// 賦值數據源
jdbcTokenRepository.setDataSource(dataSource);
// 自動創建表,第一次執行會創建,以后要執行就要刪除掉!
//jdbcTokenRepository.setCreateTableOnStartup(true);
return jdbcTokenRepository;
}
}
3.8.4 修改安全配置類
在配置類SecurityConfiger中加入:
@Resource
private PersistentTokenRepository persistentTokenRepository;
// 開啟記住我功能
http.rememberMe()
.tokenRepository(persistentTokenRepository) //令牌操作類
.userDetailsService(userDetailsService); //用戶信息查詢類
3.8.5 頁面添加記住我復選框
記住我:<input type="checkbox"name="remember-me"title="記住密碼"/><br/>
此處:name 屬性值必須位 remember-me.不能改為其他值,這個是默認的名字
3.8.6 測試記住我
登錄成功之后,關閉瀏覽器再次訪問 http://localhost:8090/findAll,發現依然可以使用!
3.8.7 設置有效期
默認 2 周時間。但是可以通過設置狀態有效時間,即使項目重新啟動下次也可以正常登 錄。
在配置文件SecurityConfiger中設置:
// 開啟記住我功能
http.rememberMe()
.tokenValiditySeconds(60) //過期時間,單位是秒
.tokenRepository(persistentTokenRepository) //令牌操作類
.userDetailsService(userDetailsService); //用戶信息查詢類
3.9 用戶注銷
3.9.1 在登錄頁面添加一個退出連接
success.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
登錄成功!
<a href="/logout">退出</a>
</body>
</html>
3.9.2 在配置類中添加退出映射地址
//退出
http.logout()
.logoutUrl("/logout") //退出的請求
.logoutSuccessUrl("/loginPage").permitAll(); //退出成功之后跳轉的頁面
3.9.3 測試
退出之后,是無法訪問需要登錄時才能訪問的控制器!
3.10 CSRF
3.10.1 CSRF 理解
跨站請求偽造(英語:Cross-site request forgery),也被稱為 one-click attack 或者 session riding,通常縮寫為 CSRF 或者 XSRF, 是一種挾制用戶在當前已 登錄的 Web 應用程序上執行非本意的操作的攻擊方法。跟跨網站腳本(XSS)相比,XSS 利用的是用戶對指定網站的信任,CSRF 利用的是網站對用戶網頁瀏覽器的信任。
跨站請求攻擊,簡單地說,是攻擊者通過一些技術手段欺騙用戶的瀏覽器去訪問一個 自己曾經認證過的網站並運行一些操作(如發郵件,發消息,甚至財產操作如轉賬和購買 商品)。由於瀏覽器曾經認證過,所以被訪問的網站會認為是真正的用戶操作而去運行。 這利用了 web 中用戶身份驗證的一個漏洞:簡單的身份驗證只能保證請求發自某個用戶的瀏覽器,卻不能保證請求本身是用戶自願發出的。
從 Spring Security 4.0 開始,默認情況下會啟用 CSRF 保護,以防止 CSRF 攻擊應用 程序,Spring Security CSRF 會針對 PATCH,POST,PUT 和 DELETE 方法進行防護。
3.10.2 案例
在登錄頁面添加一個隱藏域:
<input type="hidden" th:if="${_csrf}!=null" th:value="${_csrf.token}" name="_csrf"/>
注釋安全配置的類中的 關閉csrf防護(默認是開着的)的代碼:
// http.csrf().disable();
3.10.3 Spring Security 實現 CSRF 的原理:
- 生成 csrfToken 保存到 HttpSession 或者 Cookie 中。
SaveOnAccessCsrfToken 類有個接口 CsrfTokenRepository
當前接口實現類:HttpSessionCsrfTokenRepository,CookieCsrfTokenRepository
- 請求到來時,從請求中提取 csrfToken,和保存的 csrfToken 做比較,進而判斷當 前請求是否合法。主要通過 CsrfFilter 過濾器來完成。
4. SpringSecurity 微服務權限方案
4.1 什么是微服務
1、微服務由來
微服務最早由 Martin Fowler 與 James Lewis 於 2014 年共同提出,微服務架構風格是一種 使用一套小服務來開發單個應用的方式途徑,每個服務運行在自己的進程中,並使用輕量 級機制通信,通常是 HTTP API,這些服務基於業務能力構建,並能夠通過自動化部署機制 來獨立部署,這些服務使用不同的編程語言實現,以及不同數據存儲技術,並保持最低限 度的集中式管理。
2、微服務優勢
(1)微服務每個模塊就相當於一個單獨的項目,代碼量明顯減少,遇到問題也相對來說比 較好解決。
(2)微服務每個模塊都可以使用不同的存儲方式(比如有的用 redis,有的用 mysql 等),數據庫也是單個模塊對應自己的數據庫。
(3)微服務每個模塊都可以使用不同的開發技術,開發模式更靈活。
3、微服務本質
(1)微服務,關鍵其實不僅僅是微服務本身,而是系統要提供一套基礎的架構,這種架構 使得微服務可以獨立的部署、運行、升級,不僅如此,這個系統架構還讓微服務與微服務 之間在結構上“松耦合”,而在功能上則表現為一個統一的整體。這種所謂的“統一的整 體”表現出來的是統一風格的界面,統一的權限管理,統一的安全策略,統一的上線過 程,統一的日志和審計方法,統一的調度方式,統一的訪問入口等等。
(2)微服務的目的是有效的拆分應用,實現敏捷開發和部署。
4.2 微服務認證與授權實現思路
1、認證授權過程分析
(1)如果是基於 Session,那么 Spring-security 會對 cookie 里的 sessionid 進行解析,找到服務器存儲的 session 信息,然后判斷當前用戶是否符合請求的要求。
(2)如果是 token,則是解析出 token,然后將當前請求加入到 Spring-security 管理的權限信息中去
如果系統的模塊眾多,每個模塊都需要進行授權與認證,所以我們選擇基於 token 的形式 進行授權與認證,用戶根據用戶名密碼認證成功,然后獲取當前用戶角色的一系列權限值,並以用戶名為 key,權限列表為 value 的形式存入 redis 緩存中,根據用戶名相關信息 生成 token 返回,瀏覽器將 token 記錄到 cookie 中,每次調用 api 接口都默認將 token 攜帶到 header 請求頭中,Spring-security 解析 header 頭獲取 token 信息,解析 token 獲取當前 用戶名,根據用戶名就可以從 redis 中獲取權限列表,這樣 Spring-security 就能夠判斷當前請求是否有權限訪問
2、權限管理數據模型
4.3 jwt 介紹
1、訪問令牌的類型
2、JWT 的組成
典型的,一個 JWT 看起來如下圖:
該對象為一個很長的字符串,字符之間通過"."分隔符分為三個子串。
每一個子串表示了一個功能塊,總共有以下三個部分:JWT 頭、有效載荷和簽名
JWT 頭
JWT 頭部分是一個描述 JWT 元數據的 JSON 對象,通常如下所示。
{
"alg": "HS256",
"typ": "JWT"
}
在上面的代碼中,
alg 屬性表示簽名使用的算法,默認為 HMAC SHA256(寫為 HS256);
typ 屬性表示令牌的類型,JWT 令牌統一寫為 JWT。
最后,使用 Base64 URL 算法將上述 JSON 對象轉換為字符串保存。
有效載荷
有效載荷部分,是 JWT 的主體內容部分,也是一個 JSON 對象,包含需要傳遞的數據。 JWT 指定七個默認字段供選擇。
iss:發行人
exp:到期時間
sub:主題
aud:用戶
nbf:在此之前不可用
iat:發布時間
jti:JWT ID 用於標識該 JWT
除以上默認字段外,我們還可以自定義私有字段,如下例:
{
"sub": "1234567890",
"name": "Helen",
"admin": true
}
請注意,默認情況下 JWT 是未加密的,任何人都可以解讀其內容,因此不要構建隱私信息 字段,存放保密信息,以防止信息泄露。
JSON 對象也使用 Base64 URL 算法轉換為字符串保存。
簽名哈希
簽名哈希部分是對上面兩部分數據簽名,通過指定的算法生成哈希,以確保數據不會被篡 改。
首先,需要指定一個密碼(secret)。該密碼僅僅為保存在服務器中,並且不能向用戶公開。然后使用標頭中指定的簽名算法(默認情況下為 HMAC SHA256)根據以下公式生成簽名。
HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(claims), secret)
在計算出簽名哈希后,JWT 頭,有效載荷和簽名哈希的三個部分組合成一個字符串,每個 部分用"."分隔,就構成整個 JWT 對象。
Base64URL 算法
如前所述,JWT 頭和有效載荷序列化的算法都用到了 Base64URL。該算法和常見 Base64 算 法類似,稍有差別。
作為令牌的 JWT 可以放在 URL 中(例如 api.example/?token=xxx)。 Base64 中用的三個 字符是"+","/"和"=",由於在 URL 中有特殊含義,因此 Base64URL 中對他們做了替換: "="去掉,"+"用"-"替換,"/"用"_"替換,這就是 Base64URL 算法。
4.4 具體代碼實現(核心部分)
service_base下的代碼直接拷貝;
spring_security下的代碼需要編寫:
4.3.1 創建認證授權相關的工具類
(1)DefaultPasswordEncoder:密碼處理的方法
//默認密碼處理器
@Component
public class DefaultPasswordEncoder implements PasswordEncoder {
public DefaultPasswordEncoder() {
this(-1);
}
/**
* @param strength
* the log rounds to use, between 4 and 31
*/
public DefaultPasswordEncoder(int strength) {
}
//進行md5加密
@Override
public String encode(CharSequence rawPassword) {
String encrypt = MD5.encrypt(rawPassword.toString());
return encrypt;
}
/**
* 進行密碼比對
* @param rawPassword 數據庫的加密過的密碼
* @param encodedPassword 要加密的密碼
* @return
*/
@Override
public boolean matches(CharSequence rawPassword, String encodedPassword) {
if (encodedPassword == null || encodedPassword.length() == 0) {
return false;
}
boolean result = encodedPassword.equals(MD5.encrypt(rawPassword.toString()));
System.out.println("密碼對比結果:"+result);
return result;
}
}
(2)TokenManager:token 操作的工具類
//token生成器
@Component
public class TokenManager {
//token有效時長
private long tokenEcpiration = 24*60*60*1000;
//編碼秘鑰
private String tokenSignKey = "123456";
//1 使用jwt根據用戶名生成token
public String createToken(String username) {
//實例化
JwtBuilder jwtBuilder = Jwts.builder();
//jwt的唯一標識
jwtBuilder.setId(UUID.randomUUID().toString());
//生成的時間
jwtBuilder.setIssuedAt(new Date());
//主題,就行郵件的主體一樣
jwtBuilder.setSubject(username);
//相當於playload,只是這個會將map轉成json,而那個會是一個字符串
jwtBuilder.setClaims(new HashMap<>(1));
//加密算法
SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
//設置簽名
jwtBuilder.signWith(signatureAlgorithm,tokenSignKey);
//設置過期時間,其中的時間戳要大於生成時間
jwtBuilder.setExpiration(new Date(System.currentTimeMillis() + tokenEcpiration));
//壓縮生成token
return jwtBuilder.compact();
}
//2 根據token字符串得到用戶信息
public String getUserInfoFromToken(String token) {
JwtParser parser = Jwts.parser();
//設置解密鹽
parser.setSigningKey(tokenSignKey);
//設置需要解密的token,並獲取DefaultJwtParser對象
Claims claims = parser.parseClaimsJws(token).getBody();
//獲取token中的主體信息
String subject = claims.getSubject();
return subject;
}
//3 刪除token
public void removeToken(String token) { }
}
(3)TokenLogoutHandler:退出實現
//退出處理器
public class TokenLogoutHandler implements LogoutHandler {
private TokenManager tokenManager;
private RedisTemplate redisTemplate;
public TokenLogoutHandler(TokenManager tokenManager, RedisTemplate redisTemplate) {
this.tokenManager = tokenManager;
this.redisTemplate = redisTemplate;
}
@Override
public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
//1 從header里面獲取token
//2 token不為空,移除token,從redis刪除token
String token = request.getHeader("token");
if (token != null) {
//移除
tokenManager.removeToken(token);
//從token獲取用戶名
String username = tokenManager.getUserInfoFromToken(token);
redisTemplate.delete(username);
}
ResponseUtil.out(response, R.ok());
}
}
(4)UnauthorizedEntryPoint:未授權統一處理
//未授權統一處理類
public class UnauthEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException {
ResponseUtil.out(httpServletResponse, R.error());
}
}
4.3.2 創建認證授權實體類
(1) User用戶實體類
@Data
@ApiModel(description = "用戶實體類")
public class User implements Serializable {
private static final long serialVersionUID = 1L;
@ApiModelProperty(value = "微信openid")
private String username;
@ApiModelProperty(value = "密碼")
private String password;
@ApiModelProperty(value = "昵稱")
private String nickName;
@ApiModelProperty(value = "用戶頭像")
private String salt;
@ApiModelProperty(value = "用戶簽名")
private String token;
}
(2) SecutityUser
@Data
public class SecurityUser implements UserDetails {
//當前登錄用戶
private transient User currentUserInfo;
//當前權限
private List<String> permissionValueList;
public SecurityUser() {
}
public SecurityUser(User user) {
if (user != null) {
this.currentUserInfo = user;
}
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> authorities = new ArrayList<>();
for(String permissionValue : permissionValueList) {
if(StringUtils.isEmpty(permissionValue)) continue;
SimpleGrantedAuthority authority = new SimpleGrantedAuthority(permissionValue);
authorities.add(authority);
}
return authorities;
}
@Override
public String getPassword() {
return currentUserInfo.getPassword();
}
@Override
public String getUsername() {
return currentUserInfo.getUsername();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
4.3.3 創建認證和授權的 filter
(1)TokenLoginFilter:認證的 filter
//用戶登錄的過濾器
public class TokenLoginFilter extends UsernamePasswordAuthenticationFilter {
private TokenManager tokenManager;
private RedisTemplate redisTemplate;
private AuthenticationManager authenticationManager;
public TokenLoginFilter(AuthenticationManager authenticationManager, TokenManager tokenManager, RedisTemplate redisTemplate) {
this.tokenManager = tokenManager;
this.redisTemplate = redisTemplate;
this.authenticationManager = authenticationManager;
this.setPostOnly(true);
this.setRequiresAuthenticationRequestMatcher(new AntPathRequestMatcher("/admin/acl/login","POST"));
}
//1 獲取表單提交用戶名和密碼,並驗證和返回認證結果
@Override
public Authentication attemptAuthentication(HttpServletRequest request,
HttpServletResponse response) throws AuthenticationException {
if (!request.getMethod().equals("POST")) {
throw new AuthenticationServiceException(
"Authentication method not supported: " + request.getMethod());
}
//獲取表單提交數據
try {
User user = new ObjectMapper().readValue(request.getInputStream(), User.class);
UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(
user.getUsername().trim(), user.getPassword());
Authentication authenticate = authenticationManager.authenticate(authRequest);
return authenticate;
} catch (IOException e) {
e.printStackTrace();
throw new RuntimeException();
}
}
//2 認證成功調用的方法,認證成功生產token,並加token放入到Redis 中
/**
*
* @param request
* @param response
* @param chain
* @param authResult attemptAuthentication()返回的認證結果
* @throws IOException
* @throws ServletException
*/
protected void successfulAuthentication(HttpServletRequest request,
HttpServletResponse response, FilterChain chain, Authentication authResult)
throws IOException, ServletException {
// Fire event
if (this.eventPublisher != null) {
eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(
authResult, this.getClass()));
}
//認證成功,得到認證成功之后用戶信息
SecurityUser user = (SecurityUser)authResult.getPrincipal();
//根據用戶名生成token
String token = tokenManager.createToken(user.getCurrentUserInfo().getUsername());
//把用戶名稱和用戶權限列表放到redis
redisTemplate.opsForValue().set(user.getCurrentUserInfo().getUsername(),user.getPermissionValueList());
//返回token
ResponseUtil.out(response, R.ok().data("token",token));
}
//3 認證失敗調用的方法
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request,
HttpServletResponse response, AuthenticationException failed)
throws IOException, ServletException {
ResponseUtil.out(response,R.error());
}
}
(2)TokenAuthenticationFilter:授權 filter
public class TokenAuthFilter extends BasicAuthenticationFilter {
private TokenManager tokenManager;
private RedisTemplate redisTemplate;
public TokenAuthFilter(AuthenticationManager authenticationManager,TokenManager tokenManager,RedisTemplate redisTemplate) {
super(authenticationManager);
this.tokenManager = tokenManager;
this.redisTemplate = redisTemplate;
}
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
throws IOException, ServletException {
if(req.getRequestURI().indexOf("admin") == -1) {
chain.doFilter(req, res);
return;
}
UsernamePasswordAuthenticationToken authentication = null;
try {
//獲取當前認證成功用戶權限信息
authentication = getAuthentication(req);
} catch (Exception e) {
ResponseUtil.out(res, R.error());
}
//判斷如果有權限信息,放到權限上下文中
if (authentication != null) {
SecurityContextHolder.getContext().setAuthentication(authentication);
} else {
ResponseUtil.out(res, R.error());
}
chain.doFilter(req, res);
}
private UsernamePasswordAuthenticationToken getAuthentication(HttpServletRequest request) {
// token 置於 header 里
String token = request.getHeader("token");
if (token != null && !"".equals(token.trim())) {
String userName = tokenManager.getUserInfoFromToken(token);//我們只在token的主體中放入了username
List<String> permissionValueList = (List<String>)
redisTemplate.opsForValue().get(userName);
Collection<GrantedAuthority> authorities = new ArrayList<>();
for(String permissionValue : permissionValueList) {
if(StringUtils.isEmpty(permissionValue)) continue;
SimpleGrantedAuthority authority = new
SimpleGrantedAuthority(permissionValue);
authorities.add(authority);
}
if (!StringUtils.isEmpty(userName)) {
return new UsernamePasswordAuthenticationToken(userName, token,
authorities);
}
return null;
}
return null;
}
}
4.3.4 編寫核心配置類
Spring Security 的核心配置就是繼承 WebSecurityConfigurerAdapter 並注解 @EnableWebSecurity 的配置。這個配置指明了用戶名密碼的處理方式、請求路徑、登錄 登出控制等和安全相關的配置
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class TokenWebSecurityConfig extends WebSecurityConfigurerAdapter {
//自定義查詢數據庫用戶名密碼和權限信息
private UserDetailsService userDetailsService;
//token 管理工具類(生成 token)
private TokenManager tokenManager;
//密碼管理工具類
private DefaultPasswordEncoder defaultPasswordEncoder;
//redis 操作工具類
private RedisTemplate redisTemplate;
@Autowired
public TokenWebSecurityConfig(UserDetailsService userDetailsService,
DefaultPasswordEncoder defaultPasswordEncoder,
TokenManager tokenManager, RedisTemplate
redisTemplate) {
this.userDetailsService = userDetailsService;
this.defaultPasswordEncoder = defaultPasswordEncoder;
this.tokenManager = tokenManager;
this.redisTemplate = redisTemplate;
}
/**
* 配置設置
* @param http
* @throws Exception
*/
//設置退出的地址和token,redis操作地址
@Override
protected void configure(HttpSecurity http) throws Exception {
http.exceptionHandling()
.authenticationEntryPoint(new UnauthEntryPoint())//沒有權限訪問
.and()
.csrf().disable()
.authorizeRequests()
.anyRequest().authenticated()
.and().logout().logoutUrl("/admin/acl/index/logout") //退出路徑
.addLogoutHandler(new TokenLogoutHandler(tokenManager,redisTemplate))
.and()
.addFilter(new TokenLoginFilter(authenticationManager(),tokenManager,redisTemplate))
.addFilter(new TokenAuthFilter(authenticationManager(),tokenManager,redisTemplate))
.httpBasic();
}
//調用userDetailsService和密碼處理
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService).passwordEncoder(defaultPasswordEncoder);
}
//不進行認證的路徑,可以直接訪問
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("/api/**");
}
}
5. SpringSecurity 原理總結
5.1 SpringSecurity 的過濾器介紹
SpringSecurity 采用的是責任鏈的設計模式,它有一條很長的過濾器鏈。現在對這條過濾 器鏈的 15 個過濾器進行說明:
(1) WebAsyncManagerIntegrationFilter:將 Security 上下文與 Spring Web 中用於 處理異步請求映射的 WebAsyncManager 進行集成。
(2) SecurityContextPersistenceFilter:在每次請求處理之前將該請求相關的安全上 下文信息加載到 SecurityContextHolder 中,然后在該次請求處理完成之后,將 SecurityContextHolder 中關於這次請求的信息存儲到一個“倉儲”中,然后將 SecurityContextHolder 中的信息清除,例如在 Session 中維護一個用戶的安全信 息就是這個過濾器處理的。
(3) HeaderWriterFilter:用於將頭信息加入響應中。
(4) CsrfFilter:用於處理跨站請求偽造。
(5)LogoutFilter:用於處理退出登錄。
(6)UsernamePasswordAuthenticationFilter:用於處理基於表單的登錄請求,從表單中 獲取用戶名和密碼。默認情況下處理來自 /login 的請求。從表單中獲取用戶名和密碼 時,默認使用的表單 name 值為 username 和 password,這兩個值可以通過設置這個 過濾器的 usernameParameter 和 passwordParameter 兩個參數的值進行修改。
(7)DefaultLoginPageGeneratingFilter:如果沒有配置登錄頁面,那系統初始化時就會 配置這個過濾器,並且用於在需要進行登錄時生成一個登錄表單頁面。
(8)BasicAuthenticationFilter:檢測和處理 http basic 認證。
(9)RequestCacheAwareFilter:用來處理請求的緩存。
(10)SecurityContextHolderAwareRequestFilter:主要是包裝請求對象 request。
(11)AnonymousAuthenticationFilter:檢測 SecurityContextHolder 中是否存在 Authentication 對象,如果不存在為其提供一個匿名 Authentication。
(12)SessionManagementFilter:管理 session 的過濾器
(13)ExceptionTranslationFilter:處理 AccessDeniedException 和 AuthenticationException 異常。
(14)FilterSecurityInterceptor:可以看做過濾器鏈的出口。
(15)RememberMeAuthenticationFilter:當用戶沒有登錄而直接訪問資源時, 從 cookie 里找出用戶的信息, 如果 Spring Security 能夠識別出用戶提供的remember me cookie, 用戶將不必填寫用戶名和密碼, 而是直接登錄進入系統,該過濾器默認不開啟。
5.2 SpringSecurity 基本流程
Spring Security 采取過濾鏈實現認證與授權,只有當前過濾器通過,才能進入下一個 過濾器:
綠色部分是認證過濾器,需要我們自己配置,可以配置多個認證過濾器。認證過濾器可以 使用 Spring Security 提供的認證過濾器,也可以自定義過濾器(例如:短信驗證)。認證過濾器要在 configure(HttpSecurity http)方法中配置,沒有配置不生效。下面會重點介紹以下三個過濾器:
UsernamePasswordAuthenticationFilter 過濾器:該過濾器會攔截前端提交的 POST 方式 的登錄表單請求,並進行身份認證。
ExceptionTranslationFilter 過濾器:該過濾器不需要我們配置,對於前端提交的請求會直接放行,捕獲后續拋出的異常並進行處理(例如:權限訪問限制)
FilterSecurityInterceptor 過濾器:該過濾器是過濾器鏈的最后一個過濾器,根據資源權限配置來判斷當前請求是否有權限訪問對應的資源。如果訪問受限會拋出相關異常,並由 ExceptionTranslationFilter 過濾器進行捕獲和處理。
5.3 SpringSecurity 認證流程
認證流程是在 UsernamePasswordAuthenticationFilter 過濾器中處理的,具體流程如下 所示:
5.3.1UsernamePasswordAuthenticationFilter 源碼
認證流程總覽:
當前端提交的是一個 POST 方式的登錄表單請求,就會被該過濾器攔截,並進行身份認 證。該過濾器的 doFilter() 方法實現在其抽象父類 AbstractAuthenticationProcessingFilter 中,查看相關源碼:
//過濾器doFilter方法
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) req;
HttpServletResponse response = (HttpServletResponse) res;
if (!requiresAuthentication(request, response)) {
//(1) 判斷請求是否是post方式的登錄表單提交請求,如果不是則直接放行,進入下一個過濾器
chain.doFilter(request, response);
return;
}
if (logger.isDebugEnabled()) {
logger.debug("Request is to process authentication");
}
//Authentication是用來存儲用戶認證信息的類,后續會進行詳細介紹
Authentication authResult;
try {
//(2) 調用子類 UsernamePasswordAuthenticationFilter 重寫的方法進行身份認證,
// 返回的 authResult 對象封裝認證后的用戶信息
authResult = attemptAuthentication(request, response);
if (authResult == null) {
// return immediately as subclass has indicated that it hasn't completed
// authentication
return;
}
//(3) Session 策略處理(如果配置了用戶 Session 最大並發數,就是在此處進行判斷並處理)
sessionStrategy.onAuthentication(authResult, request, response);
}
catch (InternalAuthenticationServiceException failed) {
logger.error(
"An internal error occurred while trying to authenticate the user.",
failed);
//(4) 認證失敗,調用認證失敗的處理器
unsuccessfulAuthentication(request, response, failed);
return;
}
catch (AuthenticationException failed) {
//(4) 認證失敗,調用認證失敗的處理器
unsuccessfulAuthentication(request, response, failed);
return;
}
//(4) 認證成功的處理
if (continueChainBeforeSuccessfulAuthentication) {
//默認 continueChainBeforeSuccessfulAuthentication 為false,所以認證成功之后不會進行下一個過濾器
chain.doFilter(request, response);
}
// 調用認證成功的處理器
successfulAuthentication(request, response, chain, authResult);
}
上述的 第二 過程調用了 UsernamePasswordAuthenticationFilter 的 attemptAuthentication() 方法,源碼如下:
public class UsernamePasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
public static final String SPRING_SECURITY_FORM_USERNAME_KEY = "username"; //默認表單的用戶名參數是:username
public static final String SPRING_SECURITY_FORM_PASSWORD_KEY = "password"; //默認表單的密碼參數是:password
private String usernameParameter = SPRING_SECURITY_FORM_USERNAME_KEY;
private String passwordParameter = SPRING_SECURITY_FORM_PASSWORD_KEY;
private boolean postOnly = true; //默認的請求方式是: post
public UsernamePasswordAuthenticationFilter() {
//默認登錄表單提交的路徑是 /login ,方式是 post 提交
super(new AntPathRequestMatcher("/login", "POST"));
}
//上述doFilter方法中的(3)步驟調用了該方法進行身份驗證
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
if (postOnly && !request.getMethod().equals("POST")) {
//(1) 默認情況下,如果請求方式不是post,會拋出異常
throw new AuthenticationServiceException(
"Authentication method not supported: " + request.getMethod());
}
//(2) 獲取請求攜帶的 username 和 password
String username = obtainUsername(request);
String password = obtainPassword(request);
if (username == null) { username = ""; }
if (password == null) { password = ""; }
username = username.trim();
//(3) 使用前端傳入的 username , password 構造 Authentication 對象,標記對象是未認證狀態
UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password);
//(4) 將請求的一些屬性信息設置到 Authentication對象中,如:remoteAddress,sessionId
setDetails(request, authRequest);
//(5) 調用 ProviderManager 的authenticate()方法進行身份驗證
return this.getAuthenticationManager().authenticate(authRequest);
}
上述的(3)過程創建的 UsernamePasswordAuthenticationToken 是 Authentication 接口的實現類,該類有兩個構造器,一個用於封裝前端請求傳入的未認證的用戶信息,一個用於封裝認證成功后的用戶信息:
public class UsernamePasswordAuthenticationToken extends AbstractAuthenticationToken {
private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;
private final Object principal;
private Object credentials;
//用於封裝前端請求傳入的未認證的用戶信息,前面的 authResult 對象就是使用該構造器進行構造的
public UsernamePasswordAuthenticationToken(Object principal, Object credentials) {
super(null); //用戶權限為null
this.principal = principal; //前端傳入的用戶名
this.credentials = credentials; //前端傳入的密碼
setAuthenticated(false); //標記為未認證
}
//用於封裝認證成功后的用戶信息
public UsernamePasswordAuthenticationToken(Object principal, Object credentials, Collection<? extends GrantedAuthority> authorities) {
super(authorities); //用戶權限集合
this.principal = principal; //封裝認證用戶信息的 UserDetail 對象,不再是用戶名
this.credentials = credentials; //前端傳入的密碼
super.setAuthenticated(true); // 標記認證成功
}
Authentication 接口的實現類用於存儲用戶認證信息,查看該接口具體定義:
//用戶認定信息接口
public interface Authentication extends Principal, Serializable {
//用戶權限集合
Collection<? extends GrantedAuthority> getAuthorities();
//用戶密碼
Object getCredentials();
//請求攜帶的一些屬性信息 (例如:remoteAddress,sessionId,ip地址、證書序列號)
Object getDetails();
//未認證時為前端傳入的用戶名; 認證成功后,為封裝認證用戶信息的 UserDetails 對象
Object getPrincipal();
//是否被認證(true: 認證成功,false: 未認證)
boolean isAuthenticated();
//設置是否被認證(true: 認證成功,false: 未認證)
void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException;
}
5.3.2 ProviderManager 源碼
真正認證的執行者
上述過程中,UsernamePasswordAuthenticationFilter 過濾器的 attemptAuthentication() 方法的(5)過程將未認證的 Authentication 對象傳入 ProviderManager 類的 authenticate() 方法進行身份認證。
ProviderManager 是 AuthenticationManager 接口的實現類,該接口是認證相關的核心接 口,也是認證的入口。在實際開發中,我們可能有多種不同的認證方式,例如:用戶名+ 密碼、郵箱+密碼、手機號+驗證碼等,而這些認證方式的入口始終只有一個,那就是 AuthenticationManager。在該接口的常用實現類 ProviderManager 內部會維護一個 ‘List< AuthenticationProvider >’列表,存放多種認證方式,實際上這是委托者模式 (Delegate)的應用。每種認證方式對應着一個 AuthenticationProvider, AuthenticationManager 根據認證方式的不同(根據傳入的 Authentication 類型判斷)委托 對應的 AuthenticationProvider 進行用戶認證。
//認證動作的執行者
public class ProviderManager implements AuthenticationManager, MessageSourceAware, InitializingBean {
//.....
//傳入未認證的Authentication對象
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
//(1) 獲取傳入的 Authentication 對象類型,即 UsernamePasswordAuthenticationToken.class
Class<? extends Authentication> toTest = authentication.getClass();
AuthenticationException lastException = null;
AuthenticationException parentException = null;
Authentication result = null;
Authentication parentResult = null;
boolean debug = logger.isDebugEnabled();
//(2) 獲取認證方式列表 List<AuthenticationProvider> = getProviders()並循環
for (AuthenticationProvider provider : getProviders()) {
//(3) 判斷當前 AuthenticationProvider 是否適用 UsernamePasswordAuthenticationToken.class 類型的AuthenticationProvider
if (!provider.supports(toTest)) {
continue;
}
if (debug) {
logger.debug("Authentication attempt using "
+ provider.getClass().getName());
}
//成功找到適配當前認證方式的 AuthenticationProvider ,此處為 DaoAuthenticationProvider
try {
//(4) 調用 DaoAuthenticationProvider 的 authenticate() 方法進行認證;
//如果認證成功,會返回一個標記已認證的 Authentication 對象
result = provider.authenticate(authentication);
if (result != null) {
//(5) 認證成功后,將傳入的 Authentication 對象中的 details 信息拷貝到已認證的 Authentication 對象中
copyDetails(authentication, result);
break;
}
}
catch (AccountStatusException | InternalAuthenticationServiceException e) {
prepareException(e, authentication);
throw e;
} catch (AuthenticationException e) {
lastException = e;
}
}
if (result == null && parent != null) {
try {
//(5) 認證失敗,使用父類型 AuthenticationManager 進行驗證
result = parentResult = parent.authenticate(authentication);
}
catch (ProviderNotFoundException e) {
}
catch (AuthenticationException e) {
lastException = parentException = e;
}
}
if (result != null) {
//(6) 認證成功之后,去除 result 的敏感信息,要求相關類實現 CredentialsContainer 接口
if (eraseCredentialsAfterAuthentication && (result instanceof CredentialsContainer)) {
//去除過程就是調用 CredentialsContainer 接口的 eraseCredentials() 方法
((CredentialsContainer) result).eraseCredentials();
}
//(7) 發布認證成功的事件
if (parentResult == null) {
eventPublisher.publishAuthenticationSuccess(result);
}
return result;
}
//(8) 認證失敗之后,拋出失敗的異常信息
if (lastException == null) {
lastException = new ProviderNotFoundException(messages.getMessage(
"ProviderManager.providerNotFound",
new Object[] { toTest.getName() },
"No AuthenticationProvider found for {0}"));
}
if (parentException == null) {
prepareException(lastException, authentication);
}
throw lastException;
}
上述認證成功之后的(6)過程,調用 CredentialsContainer 接口定義的 eraseCredentials() 方法去除敏感信息。查看 UsernamePasswordAuthenticationToken 實現的 eraseCredentials() 方法,該方法實現在其父類中:
public abstract class AbstractAuthenticationToken implements Authentication, CredentialsContainer {
//父類實現了 CredentialsContainer 接口
public void eraseCredentials() {
//credentials (前端傳入的密碼) 會置為null
eraseSecret(getCredentials());
//principal 在已認證的 Acthentication 中是 UserDetails 實現類;如果該實現類想要去除敏感信息,
//需要實現 CredentialsContainer 接口的 eraseCredentials() 方法;
//ps:由於我們自定義的User類沒有實現該接口,所以不進行任何操作.
eraseSecret(getPrincipal());
eraseSecret(details);
}
private void eraseSecret(Object secret) {
if (secret instanceof CredentialsContainer) {
((CredentialsContainer) secret).eraseCredentials();
}
}
5.3.3 認證成功/失敗處理
上述過程就是認證流程的最核心部分,接下來重新回到 UsernamePasswordAuthenticationFilter 過濾器的 doFilter() 方法,查看認證成 功/失敗的處理:
public abstract class AbstractAuthenticationProcessingFilter extends GenericFilterBean implements ApplicationEventPublisherAware, MessageSourceAware {
//過濾器doFilter方法
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
//...
try {
//此處 authResult 對象就是上述 DaoAuthenticationProvider 類的authenticate()方法的返回值
authResult = attemptAuthentication(request, response);
catch (AuthenticationException failed) {
// 認證失敗,調用認證失敗的處理器
unsuccessfulAuthentication(request, response, failed);
return;
}
//...
// 調用認證成功的處理器
successfulAuthentication(request, response, chain, authResult);
查看successfulAuthentication() 和 unsuccessfulAuthentication() 的源碼:
public abstract class AbstractAuthenticationProcessingFilter extends GenericFilterBean implements ApplicationEventPublisherAware, MessageSourceAware {
//認證成功的方法
protected void successfulAuthentication(HttpServletRequest request,
HttpServletResponse response, FilterChain chain, Authentication authResult)
throws IOException, ServletException {
if (logger.isDebugEnabled()) {
logger.debug("Authentication success. Updating SecurityContextHolder to contain: "
+ authResult);
}
//(1) 將認證成功的用戶信息對象 Authentication 封裝進 SecurityContext 對象中,並存入 SecurityContext
//SecurityContextHolder是對 ThreadLocal 的一個封裝,后續會介紹
SecurityContextHolder.getContext().setAuthentication(authResult);
//(2) rememberMe 的處理
rememberMeServices.loginSuccess(request, response, authResult);
if (this.eventPublisher != null) {
//(3) 發布認證成功的事件
eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(
authResult, this.getClass()));
}
//調用認證成功的處理器
successHandler.onAuthenticationSuccess(request, response, authResult);
}
//認證失敗后的處理
protected void unsuccessfulAuthentication(HttpServletRequest request,
HttpServletResponse response, AuthenticationException failed)
throws IOException, ServletException {
//(1) 清除該線程在 SecurityContextHolder 中對應的 SecurityContext 對象
SecurityContextHolder.clearContext();
if (logger.isDebugEnabled()) {
logger.debug("Authentication request failed: " + failed.toString(), failed);
logger.debug("Updated SecurityContextHolder to contain null Authentication");
logger.debug("Delegating to authentication failure handler " + failureHandler);
}
//(2) rememberMe 的處理
rememberMeServices.loginFail(request, response);
//(3) 調用認證失敗處理器
failureHandler.onAuthenticationFailure(request, response, failed);
}
5.4 SpringSecurity 權限訪問流程
上一個部分通過源碼的方式介紹了認證流程,下面介紹權限訪問流程,主要是對 ExceptionTranslationFilter 過濾器和 FilterSecurityInterceptor 過濾器進行介紹。
5.4.1ExceptionTranslationFilter 過濾器
該過濾器是用於處理異常的,不需要我們配置,對於前端提交的請求會直接放行,捕獲后 續拋出的異常並進行處理(例如:權限訪問限制)。具體源碼如下:
public class ExceptionTranslationFilter extends GenericFilterBean {
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) req;
HttpServletResponse response = (HttpServletResponse) res;
try {
//(1) 對前端的請求直接放行,不必要攔截
chain.doFilter(request, response);
logger.debug("Chain processed normally");
}
catch (IOException ex) {
throw ex;
}
catch (Exception ex) {
//(2) 捕獲后續出現的異常進行處理
Throwable[] causeChain = throwableAnalyzer.determineCauseChain(ex);
//訪問需要認證的資源,但當前請求未認證所拋出的異常
RuntimeException ase = (AuthenticationException) throwableAnalyzer
.getFirstThrowableOfType(AuthenticationException.class, causeChain);
if (ase == null) {
//訪問權限受限的資源所拋出的異常
ase = (AccessDeniedException) throwableAnalyzer.getFirstThrowableOfType(
AccessDeniedException.class, causeChain);
}
if (ase != null) {
if (response.isCommitted()) {
throw new ServletException("Unable to handle the Spring Security Exception because the response is already committed.", ex);
}
handleSpringSecurityException(request, response, chain, ase);
}
else {
if (ex instanceof ServletException) {
throw (ServletException) ex;
}
else if (ex instanceof RuntimeException) {
throw (RuntimeException) ex;
}
throw new RuntimeException(ex);
}
}
}
5.4.2FilterSecurityInterceptor 過濾器
FilterSecurityInterceptor 是過濾器鏈的最后一個過濾器,該過濾器是過濾器鏈 的最后一個過濾器,根據資源權限配置來判斷當前請求是否有權限訪問對應的資源。如果訪問受限會拋出相關異常,最終所拋出的異常會由前一個過濾器 ExceptionTranslationFilter 進行捕獲和處理。具體源碼如下:
public class FilterSecurityInterceptor extends AbstractSecurityInterceptor implements Filter {
//...
//過濾器的 dofilter() 方法
public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain) throws IOException, ServletException {
FilterInvocation fi = new FilterInvocation(request, response, chain);
//執行 invoke()方法
invoke(fi);
}
//...
public void invoke(FilterInvocation fi) throws IOException, ServletException {
if ((fi.getRequest() != null)
&& (fi.getRequest().getAttribute(FILTER_APPLIED) != null)
&& observeOncePerRequest) {
fi.getChain().doFilter(fi.getRequest(), fi.getResponse());
}
else {
// first time this request being called, so perform security checking
if (fi.getRequest() != null && observeOncePerRequest) {
fi.getRequest().setAttribute(FILTER_APPLIED, Boolean.TRUE);
}
//(1) 根據資源權限配置來判斷當前請求是否有權限訪問對應的資源.如果不能訪問,則拋出相應的異常
InterceptorStatusToken token = super.beforeInvocation(fi);
try {
//(2) 訪問相關資源,通過 SpringMVC 的核心組件 DispatcherServlet 進行訪問
fi.getChain().doFilter(fi.getRequest(), fi.getResponse());
}
finally {
super.finallyInvocation(token);
}
super.afterInvocation(token, null);
}
}
需要注意,Spring Security 的過濾器鏈是配置在 SpringMVC 的核心組件 DispatcherServlet 運行之前。也就是說,請求通過 Spring Security 的所有過濾器, 不意味着能夠正常訪問資源,該請求還需要通過 SpringMVC 的攔截器鏈。
5.5 SpringSecurity 請求間共享認證信息
一般認證成功后的用戶信息是通過 Session 在多個請求之間共享,那么 Spring Security 中是如何實現將已認證的用戶信息對象 Authentication 與 Session 綁定的進行 具體分析。
- 在前面講解認證成功的處理方法 successfulAuthentication() 時,有以下代碼:
public abstract class AbstractAuthenticationProcessingFilter extends GenericFilterBean implements ApplicationEventPublisherAware, MessageSourceAware {
//...
//認證成功的方法
protected void successfulAuthentication(HttpServletRequest request,
HttpServletResponse response, FilterChain chain, Authentication authResult)
throws IOException, ServletException {
//(1) 將認證成功的用戶信息對象 Authentication 封裝進 SecurityContext 對象中,並存入 SecurityContext
//SecurityContextHolder是對 ThreadLocal 的一個封裝,后續會介紹
SecurityContextHolder.getContext().setAuthentication(authResult);
- 查 看 SecurityContext 接 口 及 其 實 現 類 SecurityContextImpl , 該 類 其 實 就 是 對 Authentication 的封裝:
public class SecurityContextImpl implements SecurityContext {
- 查 看 SecurityContextHolder 類 , 該 類 其 實 是 對 ThreadLocal 的 封 裝 , 存 儲 SecurityContext 對象:
public class SecurityContextHolder {
// ~ Static fields/initializers
// =====================================================================================
public static final String MODE_THREADLOCAL = "MODE_THREADLOCAL";
public static final String MODE_INHERITABLETHREADLOCAL = "MODE_INHERITABLETHREADLOCAL";
public static final String MODE_GLOBAL = "MODE_GLOBAL";
public static final String SYSTEM_PROPERTY = "spring.security.strategy";
private static String strategyName = System.getProperty(SYSTEM_PROPERTY);
private static SecurityContextHolderStrategy strategy;
private static int initializeCount = 0;
//(1) 最先執行
static {
initialize();
}
private static void initialize() {
if (!StringUtils.hasText(strategyName)) {
//默認使用 MODE_THREADLOCAL 模式
strategyName = MODE_THREADLOCAL;
}
if (strategyName.equals(MODE_THREADLOCAL)) {
//默認使用 ThreadLocalSecurityContextHolderStrategy 創建 strategy ,其內部使用 ThreadLocal 對 SecurityContext 進行存儲
strategy = new ThreadLocalSecurityContextHolderStrategy();
}
else if (strategyName.equals(MODE_INHERITABLETHREADLOCAL)) {
strategy = new InheritableThreadLocalSecurityContextHolderStrategy();
}
else if (strategyName.equals(MODE_GLOBAL)) {
strategy = new GlobalSecurityContextHolderStrategy();
}
else {
try {
Class<?> clazz = Class.forName(strategyName);
Constructor<?> customStrategy = clazz.getConstructor();
strategy = (SecurityContextHolderStrategy) customStrategy.newInstance();
}
catch (Exception ex) {
ReflectionUtils.handleReflectionException(ex);
}
}
initializeCount++;
}
public static void clearContext() {
//清空當前線程對應的 ThreadLocal<SecurityContext> 的存儲
strategy.clearContext();
}
public static SecurityContext getContext() {
//注意:如果當前線程對應的 ThreadLocal<SecurityContext> 沒有任何對象存儲,
//strategy.getContext() 會創建並返回一個空的 SecurityContext 對象,
//並且該空的 SecurityContext 對象會存入 ThreadLocal<SecurityContext>
return strategy.getContext();
}
public static void setContext(SecurityContext context) {
//設置當前線程對應的 ThreadLocal<SecurityContext> 的存儲
strategy.setContext(context);
}
ThreadLocalSecurityContextHolderStrategy類:
final class ThreadLocalSecurityContextHolderStrategy implements SecurityContextHolderStrategy {
//使用 ThreadLocal 對 SecurityContext 進行存儲
private static final ThreadLocal<SecurityContext> contextHolder = new ThreadLocal<>();
public void clearContext() {
// 清空當前線程對應的 ThreadLocal<SecurityContext> 的存儲
contextHolder.remove();
}
public SecurityContext getContext() {
//注意:如果當前線程對應的 ThreadLocal<SecurityContext> 沒有任何對象存儲,
//strategy.getContext() 會創建並返回一個空的 SecurityContext 對象,
//並且該空的 SecurityContext 對象會存入 ThreadLocal<SecurityContext>
SecurityContext ctx = contextHolder.get();
if (ctx == null) {
ctx = createEmptyContext();
contextHolder.set(ctx);
}
return ctx;
}
public void setContext(SecurityContext context) {
//設置當前線程對象的 ThreadLocal<SecurityContext 的存儲
Assert.notNull(context, "Only non-null SecurityContext instances are permitted");
contextHolder.set(context);
}
public SecurityContext createEmptyContext() {
//創建一個空的 SecurityContext 對象
return new SecurityContextImpl();
}
}
5.5.1SecurityContextPersistenceFilter 過濾器
前面提到過,在 UsernamePasswordAuthenticationFilter 過濾器認證成功之 后,會在認證成功的處理方法中將已認證的用戶信息對象 Authentication 封裝進 SecurityContext,並存入 SecurityContextHolder。
之后,響應會通過 SecurityContextPersistenceFilter 過濾器,該過濾器的位置 在所有過濾器的最前面,請求到來先進它,響應返回最后一個通過它,所以在該過濾器中 處理已認證的用戶信息對象 Authentication 與 Session 綁定。
認證成功的響應通過 SecurityContextPersistenceFilter 過濾器時,會從 SecurityContextHolder 中取出封裝了已認證用戶信息對象 Authentication 的 SecurityContext,放進 Session 中。當請求再次到來時,請求首先經過該過濾器,該過濾器會判斷當前請求的 Session 是否存有 SecurityContext 對象,如果有則將該對象取出再次 放入SecurityContextHolder 中,之后該請求所在的線程獲得認證用戶信息,后續的資源訪 問不需要進行身份認證;當響應再次返回時,該過濾器同樣從 SecurityContextHolder 取出 SecurityContext 對象,放入 Session 中。具體源碼如下:
public class SecurityContextPersistenceFilter extends GenericFilterBean {
static final String FILTER_APPLIED = "__spring_security_scpf_applied";
private SecurityContextRepository repo;
private boolean forceEagerSessionCreation = false;
public SecurityContextPersistenceFilter() {
this(new HttpSessionSecurityContextRepository());
}
public SecurityContextPersistenceFilter(SecurityContextRepository repo) {
this.repo = repo;
}
//過濾器 doFilter() 方法
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) req;
HttpServletResponse response = (HttpServletResponse) res;
if (request.getAttribute(FILTER_APPLIED) != null) {
//判斷屬性 FILTER_APPLIED, 確保每個請求只應用一次篩選器
chain.doFilter(request, response);
return;
}
final boolean debug = logger.isDebugEnabled();
//該請求經過該過濾器后,就對屬性 FILTER_APPLIED 設置一個值
request.setAttribute(FILTER_APPLIED, Boolean.TRUE);
if (forceEagerSessionCreation) {
HttpSession session = request.getSession();
if (debug && session.isNew()) {
logger.debug("Eagerly created session: " + session.getId());
}
}
HttpRequestResponseHolder holder = new HttpRequestResponseHolder(request,
response);
//(1) 請求到來時,檢查當前 Session 中是否有 SecurityContext 對象,
//如果有,從 Session 中取出該對象;
//如果沒有,會創建一個空的 SecurityContext 對象;
SecurityContext contextBeforeChainExecution = repo.loadContext(holder);
try {
//(2) 將上述獲得 SecurityContext 對象放入 SecurityContextHolder 中
SecurityContextHolder.setContext(contextBeforeChainExecution);
//(3) 進入下一個過濾器
chain.doFilter(holder.getRequest(), holder.getResponse());
}
finally {
//(4) 響應返回時,從 SecurityContextHolder 中取出SecurityContext
SecurityContext contextAfterChainExecution = SecurityContextHolder.getContext();
//(5) 移除 SecurityContextHolder 中的 SecurityContext 對象
SecurityContextHolder.clearContext();
//(6) 將取出的 SecurityContext 對象放入 Session
repo.saveContext(contextAfterChainExecution, holder.getRequest(),
holder.getResponse());
request.removeAttribute(FILTER_APPLIED);
if (debug) {
logger.debug("SecurityContextHolder now cleared, as request processing completed");
}
}
}
public void setForceEagerSessionCreation(boolean forceEagerSessionCreation) {
this.forceEagerSessionCreation = forceEagerSessionCreation;
}
}