一、Spring Security
1、什么是 Spring Security?
(1)基本認識
Spring Security 是基於 Spring 框架,用於解決 Web 應用安全性的 一種方案,是一款優秀的權限管理框架。
Web 應用的安全一般關注 用戶認證(authentication) 以及 用戶授權(authorization) 這兩個部分。簡單的理解就是 Web 應用 如何確定 你是誰 以及 你能干什么。
【官網地址:】 https://spring.io/projects/spring-security
(2)用戶認證(authentication)
用戶認證就是 驗證某個用戶是否為系統中的合法主體,也即該用戶是否能登陸系統,通常根據用戶名以及密碼進行確認。
簡單的理解就是 使 Web 應用確定 你是誰。
(3)用戶授權(authorization)
用戶授權就是 驗證某個用戶是否有執行某個操作的權限。
簡單的理解就是 使 Web 應用確定 你能干什么。
(4)記住幾個點
【@EnableWebSecurity】
用於開啟 WebSecurity 模式。有時不需要也可以實現相應的功能。
【@EnableGlobalMethodSecurity】
用於開啟注解。常見參數為:prePostEnabled、securedEnabled。
【WebSecurityConfigurerAdapter】
用於自定義 Security 策略。
【AuthenticationManagerBuilder】
用於自定義 認證策略。
2、Spring Security 與 Shiro 簡單比較一下?
(1)Spring Security
基於 Spring 框架開發,可以與 Spring 無縫整合。
屬於重量級的權限控制框架(依賴其他組件、引入各種依賴),提供了全面的權限控制。
(2)Shiro
Apache 的輕量級權限控制框架,不與任何框架捆綁。
使用起來比 Spring Security 簡單。
(3)使用
一般來說,使用 Shiro 可以解決大部分項目的問題,且容易操作。
而 SpringBoot 提供了自動化配置方案,通過較少的配置就可以使用 Spring Security。
所以常見組合通常為: SSM + Shiro 或者 SpringBoot / SpringCloud + Spring Security。
3、Spring Security 初體驗(SpringBoot + Spring Security)
(1)步驟
【步驟:】
Step1:新建一個 SpringBoot 項目。
Step2:引入 Web 依賴、Spring Security 依賴。
Step3:新建一個 controller 進行測試。
注:
此處僅導入依賴,未進行任何配置,所以顯示的都是默認效果。
【效果:】
當 Spring Security 依賴存在時,訪問 controller 時會默認跳轉到登陸頁面。
默認用戶名為:user
密碼在控制台上可以看到(隨機生成)。
(2)新建一個 SpringBoot 項目,並添加 Web、Spring Security 等依賴。
【依賴:】
<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>
(3)新建一個 controller,並簡單測試一下 Spring Security。
【controller:】 package com.lyh.demo.springsecurity.controller; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @RequestMapping("test") @RestController public class TestController { @GetMapping("/hello") public String hello() { return "hello spring security"; } }
如下圖所示,未添加 SpringSecurity 依賴時,訪問 controller 沒有限制。
而 添加上依賴后,訪問 controller 會首先跳轉到登錄頁面,成功登錄后才允許訪問。
4、Spring Security 再次體驗(SSM + Spring Security)
(1)步驟
使用 SSM 時,需要進行一些繁瑣的配置,沒有 SpringBoot 用起來舒服,
此處簡單配置一下,后面介紹仍然以 SpringBoot 為主。
【步驟:】 Step1:創建一個 maven 工程 或者 web 工程(能使用 SpringMVC 即可),可參考:https://www.cnblogs.com/l-y-h/p/12030104.html Step2:配置 SpringSecurity,並測試。
(2)新建 maven 工程,導入相關依賴
此處使用 tomcat 8 版本啟動項目,tomcat 7 啟動后在登錄時可能會報錯。
【依賴】 <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-core</artifactId> <version>5.3.4.RELEASE</version> </dependency> <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-web</artifactId> <version>5.3.4.RELEASE</version> </dependency> <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-config</artifactId> <version>5.3.4.RELEASE</version> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-webmvc</artifactId> <version>5.2.8.RELEASE</version> </dependency> <dependency> <groupId>junit</groupId> <artifactId>junit</artifactId> <version>4.11</version> <scope>test</scope> </dependency> 【注意:(tomcat7 版本可能會報如下的錯誤,更換 tomcat 8 以上版本即可)】 java.lang.NoSuchMethodError: javax.servlet.http.HttpServletRequest.changeSessionId()Ljava/lang/String;
(3)配置基本的 web 環境(Spring 以及 SpringMVC)
【web.xml】 <?xml version="1.0" encoding="UTF-8"?> <web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_3_1.xsd" version="3.1"> <!-- step1: 配置全局的參數,啟動Spring容器 --> <context-param> <param-name>contextConfigLocation</param-name> <!-- 若沒有提供值,默認會去找/WEB-INF/applicationContext.xml。 --> <param-value>classpath:applicationContext.xml</param-value> </context-param> <listener> <listener-class>org.springframework.web.context.ContextLoaderListener</listener-class> </listener> <!-- step2: 配置SpringMVC的前端控制器,用於攔截所有的請求 --> <servlet> <servlet-name>springmvcDispatcherServlet</servlet-name> <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class> <init-param> <param-name>contextConfigLocation</param-name> <!-- 若沒有提供值,默認會去找WEB-INF/*-servlet.xml。 --> <param-value>classpath:dispatcher-servlet.xml</param-value> </init-param> <!-- 啟動優先級,數值越小優先級越大 --> <load-on-startup>1</load-on-startup> </servlet> <servlet-mapping> <servlet-name>springmvcDispatcherServlet</servlet-name> <!-- 將DispatcherServlet請求映射配置為"/",則Spring MVC將捕獲Web容器所有的請求,包括靜態資源的請求 --> <url-pattern>/</url-pattern> </servlet-mapping> <!-- step3: characterEncodingFilter字符編碼過濾器,放在所有過濾器的前面 --> <filter> <filter-name>characterEncodingFilter</filter-name> <filter-class>org.springframework.web.filter.CharacterEncodingFilter</filter-class> <init-param> <!--要使用的字符集,一般我們使用UTF-8(保險起見UTF-8最好)--> <param-name>encoding</param-name> <param-value>UTF-8</param-value> </init-param> <init-param> <!--是否強制設置request的編碼為encoding,默認false,不建議更改--> <param-name>forceRequestEncoding</param-name> <param-value>false</param-value> </init-param> <init-param> <!--是否強制設置response的編碼為encoding,建議設置為true--> <param-name>forceResponseEncoding</param-name> <param-value>true</param-value> </init-param> </filter> <filter-mapping> <filter-name>characterEncodingFilter</filter-name> <!--這里不能留空或者直接寫 ' / ' ,否則可能不起作用--> <url-pattern>/*</url-pattern> </filter-mapping> <!-- step4: 配置過濾器,將post請求轉為delete,put --> <filter> <filter-name>HiddenHttpMethodFilter</filter-name> <filter-class>org.springframework.web.filter.HiddenHttpMethodFilter</filter-class> </filter> <filter-mapping> <filter-name>HiddenHttpMethodFilter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping> </web-app> 【applicationContext.xml】 <?xml version="1.0" encoding="UTF-8"?> <beans xmlns="http://www.springframework.org/schema/beans" xmlns:context="http://www.springframework.org/schema/context" xmlns:p="http://www.springframework.org/schema/p" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation=" http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans-4.0.xsd http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context-4.0.xsd http://www.springframework.org/schema/util http://www.springframework.org/schema/util/spring-util-4.0.xsd"> <!-- step1: 配置包掃描方式。掃描所有包,但是排除Controller層 --> <context:component-scan base-package="com.lyh.demo"> <context:exclude-filter type="annotation" expression="org.springframework.stereotype.Controller"/> </context:component-scan> </beans> 【dispatcher-servlet.xml】 <?xml version="1.0" encoding="UTF-8"?> <beans xmlns="http://www.springframework.org/schema/beans" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:context="http://www.springframework.org/schema/context" xmlns:mvc="http://www.springframework.org/schema/mvc" xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd http://www.springframework.org/schema/mvc http://www.springframework.org/schema/mvc/spring-mvc.xsd"> <!-- step1: 配置Controller掃描方式 --> <!-- 使用組件掃描的方式可以一次掃描多個Controller,只需指定包路徑即可 --> <context:component-scan base-package="com.lyh.demo" use-default-filters="false"> <!-- 一般在SpringMVC的配置里,只掃描Controller層,Spring配置中掃描所有包,但是排除Controller層。 context:include-filter要注意,如果base-package掃描的不是最終包,那么其他包還是會掃描、加載,如果在SpringMVC的配置中這么做,會導致Spring不能處理事務, 所以此時需要在<context:component-scan>標簽上,增加use-default-filters="false",就是真的只掃描context:include-filter包括的內容--> <context:include-filter type="annotation" expression="org.springframework.stereotype.Controller" /> </context:component-scan> <!-- step2: 配置視圖解析器 --> <bean id="defaultViewResolver" class="org.springframework.web.servlet.view.InternalResourceViewResolver"> <property name="prefix" value="/WEB-INF/"/><!--設置JSP文件的目錄位置--> <property name="suffix" value=".jsp"/> </bean> <!-- step3: 標准配置 --> <!-- 將springmvc不能處理的請求交給 spring 容器處理 --> <mvc:default-servlet-handler/> <!-- 簡化注解配置,並提供更高級的功能 --> <mvc:annotation-driven /> </beans>
(4)配置 SpringSecurity,並新建一個 controller 進行測試
【web.xml 中配置核心過濾器鏈 springSecurityFilterChain】 <filter> <filter-name>springSecurityFilterChain</filter-name> <filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class> </filter> <filter-mapping> <filter-name>springSecurityFilterChain</filter-name> <url-pattern>/*</url-pattern> </filter-mapping> 【新建一個 spring-security.xml 用於進行 Spring Security 相關配置】 <?xml version="1.0" encoding="UTF-8"?> <beans xmlns="http://www.springframework.org/schema/beans" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:security="http://www.springframework.org/schema/security" xsi:schemaLocation=" http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans-4.0.xsd http://www.springframework.org/schema/security http://www.springframework.org/schema/security/spring-security.xsd"> <!-- 配置 Spring-Security. auto-config="true" 表示使用框架默認提供的登錄界面 use-expressions="true" 表示使用 Spring 的 EL 表達式 --> <security:http auto-config="true" use-expressions="true"> <!-- 配置攔截請求。 pattern="/**" 表示攔截所有請求 access="hasAnyRole('ROLE_USER')" 表示只有角色為 ROLE_USER 的用戶才能訪問並登陸系統 --> <security:intercept-url pattern="/**" access="hasAnyRole('ROLE_USER')"/> </security:http> <!-- 配置用戶信息(用戶管理) 密碼默認是加密的,若不想密碼加密,則可以在 密碼前面添加 {noop} --> <security:authentication-manager> <security:authentication-provider> <security:user-service> <security:user name="tom" password="{noop}123456" authorities="ROLE_USER" /> <security:user name="jarry" password="{noop}123456" authorities="ROLE_ADMIN" /> <security:user name="jack" password="123456" authorities="ROLE_USER" /> </security:user-service> </security:authentication-provider> </security:authentication-manager> </beans> 【在 web.xml 中導入 spring-security.xml 文件(與導入 applicationContext.xml 類似,也可以在 applicationContext.xml 中通過 <import> 標簽引入 spring-security.xml)】 <context-param> <param-name>contextConfigLocation</param-name> <!-- 若沒有提供值,默認會去找/WEB-INF/applicationContext.xml。 --> <param-value> classpath:applicationContext.xml classpath:spring-security.xml </param-value> </context-param> 【完整 web.xml 如下:】 <?xml version="1.0" encoding="UTF-8"?> <web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_3_1.xsd" version="3.1"> <!-- step1: 配置全局的參數,啟動Spring容器 --> <context-param> <param-name>contextConfigLocation</param-name> <!-- 若沒有提供值,默認會去找/WEB-INF/applicationContext.xml。 --> <param-value> classpath:applicationContext.xml classpath:spring-security.xml </param-value> </context-param> <listener> <listener-class>org.springframework.web.context.ContextLoaderListener</listener-class> </listener> <!-- step2: 配置SpringMVC的前端控制器,用於攔截所有的請求 --> <servlet> <servlet-name>springmvcDispatcherServlet</servlet-name> <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class> <init-param> <param-name>contextConfigLocation</param-name> <!-- 若沒有提供值,默認會去找WEB-INF/*-servlet.xml。 --> <param-value>classpath:dispatcher-servlet.xml</param-value> </init-param> <!-- 啟動優先級,數值越小優先級越大 --> <load-on-startup>1</load-on-startup> </servlet> <servlet-mapping> <servlet-name>springmvcDispatcherServlet</servlet-name> <!-- 將DispatcherServlet請求映射配置為"/",則Spring MVC將捕獲Web容器所有的請求,包括靜態資源的請求 --> <url-pattern>/</url-pattern> </servlet-mapping> <!-- step3: characterEncodingFilter字符編碼過濾器,放在所有過濾器的前面 --> <filter> <filter-name>characterEncodingFilter</filter-name> <filter-class>org.springframework.web.filter.CharacterEncodingFilter</filter-class> <init-param> <!--要使用的字符集,一般我們使用UTF-8(保險起見UTF-8最好)--> <param-name>encoding</param-name> <param-value>UTF-8</param-value> </init-param> <init-param> <!--是否強制設置request的編碼為encoding,默認false,不建議更改--> <param-name>forceRequestEncoding</param-name> <param-value>false</param-value> </init-param> <init-param> <!--是否強制設置response的編碼為encoding,建議設置為true--> <param-name>forceResponseEncoding</param-name> <param-value>true</param-value> </init-param> </filter> <filter-mapping> <filter-name>characterEncodingFilter</filter-name> <!--這里不能留空或者直接寫 ' / ' ,否則可能不起作用--> <url-pattern>/*</url-pattern> </filter-mapping> <!-- step4: 配置過濾器,將post請求轉為delete,put --> <filter> <filter-name>HiddenHttpMethodFilter</filter-name> <filter-class>org.springframework.web.filter.HiddenHttpMethodFilter</filter-class> </filter> <filter-mapping> <filter-name>HiddenHttpMethodFilter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping> <!-- Step5:配置 SpringSecurity 核心過濾器鏈 --> <filter> <filter-name>springSecurityFilterChain</filter-name> <filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class> </filter> <filter-mapping> <filter-name>springSecurityFilterChain</filter-name> <url-pattern>/*</url-pattern> </filter-mapping> </web-app> 【新建一個 TestController.java 進行測試】 package com.lyh.demo.controller; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @RequestMapping("test") @RestController public class TestController { @GetMapping("/hello") public String hello() { return "hello spring security"; } }
如下圖所示,未配置 springSecurityFilterChain 時,等同於普通的系統登錄。
配置 springSecurityFilterChain 后,在 spring-security.xml 中可以看到,
配置了如下內容:
攔截所有請求,並只允許擁有 ROLE_USER 這個角色的用戶才可以登錄。
設置了三個用戶,tom 為 ROLE_USER 角色,且密碼未加密,所以可以正常登陸。
jarry 為 ROLE_ADMIN 角色,沒有權限,所以不能正常登陸。
jack 為 ROLE_USER 角色,但密碼被加密,所以不能正常登陸。
5、Spring Security 過濾器鏈
(1)本質
Spring Security 基於 Servlet 過濾器實現的。
默認由 15 個過濾器組成過濾器鏈(可以通過配置添加、移除過濾器),通過過濾器攔截請求並進行相關操作。
(2)簡單了解幾個過濾器
【org.springframework.security.web.context.SecurityContextPersistenceFilter】 此過濾器主要是在 SecurityContextRepository 中 保存或者更新 SecurityContext,並交給后續的過濾器操作。 而 SecurityContext 中保存了當前用戶認證、權限等信息。 【org.springframework.security.web.csrf.CsrfFilter】 此過濾器用於防止 CSRF 攻擊。Spring Security 4.0 開始,默認開啟 CSRF 防護,針對 PUT、POST、DELETE 等請求進行防護。 注: CSRF 指的是 Cross Site Request Forgery,即 跨站請求偽造。 簡單理解為:攻擊者冒用用戶身份去執行操作。 舉例: 用戶打開瀏覽器並成功登陸某個網站 A, 此時用戶 未登出網站 A,且在同一瀏覽器中新增一個 Tab 頁並訪問 網站 B, 而瀏覽器接收到網站 B 返回的惡意代碼后,在用戶不知情的情況下攜帶 cookie 等用戶信息向 A 網站發送請求。 網站 A 處理該請求,從而導致網站 B 的惡意代碼被執行。 簡單理解就是:用戶登錄一個網站 A,並打開了另一個網站 B,B 網站攜帶惡意代碼 且使用用戶身份去訪問 網站 A。 XSS 指的是 Cross Site Scripting,即 跨站腳本。 簡單理解:攻擊者將惡意代碼嵌入網站,當用戶訪問網站時導致 惡意代碼被執行。 【org.springframework.security.web.authentication.logout.LogoutFilter】 匹配 URL(默認為 /logout),用於實現用戶退出並清除認證信息。 【org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter】 匹配 URL(默認為 /login),用於實現用戶登錄認證操作(必須為 POST 請求)。 【org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter】 若沒有指定登錄認證界面,此過濾器會提供一個默認的界面。 【org.springframework.security.web.authentication.ui.DefaultLogoutPageGeneratingFilter】 若沒有指定登出界面,此過濾器會提供一個默認的界面。 【org.springframework.security.web.authentication.AnonymousAuthenticationFilter】 創建一個匿名身份,用於系統的訪問。(兼容游客登錄模式) 【org.springframework.security.web.access.ExceptionTranslationFilter】 位於整個 springSecurityFilterChain 過濾鏈后方,用於處理鏈路中的異常(跳轉到指定頁面或者返回錯誤信息)。 【org.springframework.security.web.access.intercept.FilterSecurityInterceptor】 獲取資源訪問的授權信息,根據 SecurityContext 中存儲的用戶信息來決定操作是否有權限。
(3)這些過濾器是如何加載進來的?
通過前面 SSM + Spring Security 可以看到,在 web.xml 中配置了名為 springSecurityFilterChain 的過濾器,可以 Debug 看下 DelegatingFilterProxy 加載的流程。
【基本流程:】
Step1:
通過 DelegatingFilterProxy 過濾器的 doFilter() 獲取到 FilterChainProxy 過濾器並執行。
Step2:
通過 FilterChainProxy 過濾器的 doFilter() 調用 doFilterInternal() 加載到 過濾器鏈。
Step3:
doFilterInternal() 內部通過 SecurityFilterChain 接口獲取到 過濾器鏈。
Step4:
SecurityFilterChain 接口實現類為 DefaultSecurityFilterChain。
二、SpringBoot + SpringSecurity 相關操作
1、三種認證方式(設置用戶名、密碼)
不進行任何 SpringSecurity 配置時,系統默認提供用戶名以及密碼,但是這種情況肯定不適用於工作場景。那么如何進行 認證呢?
(1)方式一:
通過配置文件 application.properties 或者 application.yml 中直接定義。
不太適用於實際工作場景。
【在 application.properties 中直接定義 用戶名、密碼】 spring.security.user.name=tom spring.security.user.password=123456
(2)方式二:
通過配置類的形式。(需要繼承 WebSecurityConfigurerAdapter 抽象類)
不太適用於實際工作場景。
【通過配置類的形式:】 package com.lyh.demo.springsecurity.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; /** * 配置 Spring Security */ @Configuration public class SpringSecurityConfig extends WebSecurityConfigurerAdapter { @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder(); auth.inMemoryAuthentication() .withUser("jack") // .password("{noop}" + "123456") // 未配置 PasswordEncoder 時,可以在 密碼前拼接上 {noop},防止出錯 .password(bCryptPasswordEncoder.encode("123456")) .roles("admin"); } /** * 配置加密類,若不配置,則 bCryptPasswordEncoder.encode() 進行加密時會出錯。 * java.lang.IllegalArgumentException: There is no PasswordEncoder mapped for the id "null" * * 若不想配置,可以在 設置 password 時,在密碼前添加上 {noop} * @return 加密類 */ @Bean PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } }
(3)方式三
通過配置類 以及 自定義實現類(實現 UserDetailsService 接口)實現。
適用於工作場景(從數據庫中查詢出用戶信息並認證)。
【步驟一:在配置類中 指定使用 UserDetailsService 接口,並注入其 實現類】 package com.lyh.demo.springsecurity.config; import org.springframework.beans.factory.annotation.Autowired; 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.core.userdetails.UserDetailsService; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; /** * 配置 Spring Security */ @Configuration public class SpringSecurityConfig extends WebSecurityConfigurerAdapter { @Autowired private UserDetailsService userDetailsService; @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder()); } @Bean PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } }
【步驟二:編寫自定義實現類(實現 UserDetailsService 接口)】 package com.lyh.demo.springsecurity.service; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.AuthorityUtils; import org.springframework.security.core.userdetails.User; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.stereotype.Service; import java.util.List; @Service("userDetailsService") public class MyUserDetailsService implements UserDetailsService { @Override public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException { // 設置用戶權限,若有多個權限可以使用 逗號分隔 List<GrantedAuthority> auths = AuthorityUtils.commaSeparatedStringToAuthorityList("admin"); return new User("jarry", new BCryptPasswordEncoder().encode("123456"), auths); } }
2、SpringSecurity 中登錄認證過程中 的密碼加密(BCryptPasswordEncoder)
(1)為什么要了解密碼加密?
Spring Security 5.0 以上版本 對於密碼處理需要特別注意一下,前面也介紹了,Spring Security 認證時會對密碼進行加密,采用 {encodingId}password 的形式設置加密方式。
如果不想密碼加密,可以在配置密碼時在 密碼前拼接上 {noop}, 即 ({noop}password)。
而實際場景中,數據庫存儲的密碼都是非明文存儲(即存儲的都是加密后的密碼),所以有必要了解一下 SpringSecurity 加密相關內容。
【相關的 encodingId 與其 對應的 實體類 如下:】 public static PasswordEncoder createDelegatingPasswordEncoder() { String encodingId = "bcrypt"; Map<String, PasswordEncoder> encoders = new HashMap(); encoders.put(encodingId, new BCryptPasswordEncoder()); encoders.put("ldap", new LdapShaPasswordEncoder()); encoders.put("MD4", new Md4PasswordEncoder()); encoders.put("MD5", new MessageDigestPasswordEncoder("MD5")); encoders.put("noop", NoOpPasswordEncoder.getInstance()); encoders.put("pbkdf2", new Pbkdf2PasswordEncoder()); encoders.put("scrypt", new SCryptPasswordEncoder()); encoders.put("SHA-1", new MessageDigestPasswordEncoder("SHA-1")); encoders.put("SHA-256", new MessageDigestPasswordEncoder("SHA-256")); encoders.put("sha256", new StandardPasswordEncoder()); encoders.put("argon2", new Argon2PasswordEncoder()); return new DelegatingPasswordEncoder(encodingId, encoders); }
(2)PasswordEncoder
SpringSecurity 默認需要在容器中存在 PasswordEncoder 實例對象,用於進行密碼加密。所以配置 SpringSecurity 時,需要在容器中配置一個 PasswordEncoder Bean 對象(一般使用 BCryptPasswordEncoder 實例對象)。
【在配置類中通過 @Bean 配置一個 PasswordEncoder 的 Bean 對象:】 @Bean PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } 【PasswordEncoder 常用方法:】 String encode(CharSequence rawPassword); // 用於密碼加密 boolean matches(CharSequence rawPassword, String encodedPassword); // 用於密碼解密,rawPassword 表示待匹配的密碼,encodedPassword 表示加密后的密碼。
(3)BCryptPasswordEncoder
是最常用的一種密碼解析器,其通過 哈希算法 並加上 隨機鹽(salt)的方式進行密碼加密。
密碼解密時,根據加密后的數據 A 得到鹽值(salt),將待比較數據根據鹽值進行一次加密得到 B,如果 B 與 A 是相同的結果,則說明密碼是正確的。
密碼加密、解密的關鍵點在於 鹽值的計算。
密碼加密相關代碼如下所示:
【 encode() 加密:】 加密代碼如下所示,首先調用 BCrypt.gensalt() 方法計算出 鹽值(salt), 然后調用 BCrypt.hashpw() 方法,根據 鹽值(salt)進行密碼(password)加密。 而在 BCrypt 中的 hashpw() 中,會通過 salt.substring() 截取並得到真實的鹽值(real_salt), 通過 B.crypt_raw() 求得一個哈希數組(hashed),通過 encode_base64() 進行加密。 public String encode(CharSequence rawPassword) { if (rawPassword == null) { throw new IllegalArgumentException("rawPassword cannot be null"); } String salt; if (random != null) { salt = BCrypt.gensalt(version.getVersion(), strength, random); } else { salt = BCrypt.gensalt(version.getVersion(), strength); } return BCrypt.hashpw(rawPassword.toString(), salt); } 【BCrypt】 public static String hashpw(String password, String salt) { byte passwordb[]; passwordb = password.getBytes(StandardCharsets.UTF_8); return hashpw(passwordb, salt); } public static String hashpw(byte passwordb[], String salt) { BCrypt B; String real_salt; byte saltb[], hashed[]; char minor = (char) 0; int rounds, off; StringBuilder rs = new StringBuilder(); if (salt == null) { throw new IllegalArgumentException("salt cannot be null"); } int saltLength = salt.length(); if (saltLength < 28) { throw new IllegalArgumentException("Invalid salt"); } if (salt.charAt(0) != '$' || salt.charAt(1) != '2') throw new IllegalArgumentException ("Invalid salt version"); if (salt.charAt(2) == '$') off = 3; else { minor = salt.charAt(2); if ((minor != 'a' && minor != 'x' && minor != 'y' && minor != 'b') || salt.charAt(3) != '$') throw new IllegalArgumentException ("Invalid salt revision"); off = 4; } // Extract number of rounds if (salt.charAt(off + 2) > '$') throw new IllegalArgumentException ("Missing salt rounds"); if (off == 4 && saltLength < 29) { throw new IllegalArgumentException("Invalid salt"); } rounds = Integer.parseInt(salt.substring(off, off + 2)); real_salt = salt.substring(off + 3, off + 25); saltb = decode_base64(real_salt, BCRYPT_SALT_LEN); if (minor >= 'a') // add null terminator passwordb = Arrays.copyOf(passwordb, passwordb.length + 1); B = new BCrypt(); hashed = B.crypt_raw(passwordb, saltb, rounds, minor == 'x', minor == 'a' ? 0x10000 : 0); rs.append("$2"); if (minor >= 'a') rs.append(minor); rs.append("$"); if (rounds < 10) rs.append("0"); rs.append(rounds); rs.append("$"); encode_base64(saltb, saltb.length, rs); encode_base64(hashed, bf_crypt_ciphertext.length * 4 - 1, rs); return rs.toString(); }
密碼解密相關代碼如下所示:
【matches() 解密:】 解密代碼如下所示,首先確保 encodedPassword 是加密后的代碼。 然后調用 BCrypt.checkpw() 進行密碼匹配。 而 BCrypt 的 checkpw() 中,可以看到其會將待比較的密碼 重新進行一次 hashpw() 密碼加密。 而此時傳入的鹽值是 加密的代碼,在 hashpw() 方法中會截取出相應的 鹽值(real_salt)並用於加密。 加密完成后,再去比較新加密的密碼 與 原來加密的密碼 是否相同即可。 所以如果待比較的密碼 與 加密的密碼是相同的,也即相當於 根據相同的 鹽值 再加密了一次,加密結果是相同的。 public boolean matches(CharSequence rawPassword, String encodedPassword) { if (rawPassword == null) { throw new IllegalArgumentException("rawPassword cannot be null"); } if (encodedPassword == null || encodedPassword.length() == 0) { logger.warn("Empty encoded password"); return false; } if (!BCRYPT_PATTERN.matcher(encodedPassword).matches()) { logger.warn("Encoded password does not look like BCrypt"); return false; } return BCrypt.checkpw(rawPassword.toString(), encodedPassword); } 【BCrypt】 public static boolean checkpw(String plaintext, String hashed) { return equalsNoEarlyReturn(hashed, hashpw(plaintext, hashed)); } static boolean equalsNoEarlyReturn(String a, String b) { return MessageDigest.isEqual(a.getBytes(StandardCharsets.UTF_8), b.getBytes(StandardCharsets.UTF_8)); }
3、從數據庫中查詢用戶信息並認證(MyBatis-Plus + MySQL 8 )
(1)建表(SQL)
MyBatis-Plus 使用可以參考:https://www.cnblogs.com/l-y-h/p/12859477.html
配置 SpringSecurity 時需要配置 使用密碼加密,
若不使用加密,則需在設置密碼時在密碼前拼接上 {noop}。
若使用加密,則使用 BCryptPasswordEncoder 的 encode() 方法對其進行加密。
若數據庫存儲的已經是 BCryptPasswordEncoder 加密后的數據,不用再次加密。
此處為了方便理解,存儲密碼時均使用 明文存儲。
【建表 SQL :】 DROP DATABASE IF EXISTS testSpringSecurity; CREATE DATABASE testSpringSecurity; USE testSpringSecurity; DROP TABLE IF EXISTS users; CREATE TABLE users ( id BIGINT(20) PRIMARY KEY AUTO_INCREMENT COMMENT '主鍵ID', name VARCHAR(30) NOT NULL COMMENT '姓名', password VARCHAR(64) NOT NULL COMMENT '密碼', role VARCHAR(20) NOT NULL COMMENT '角色' ); INSERT INTO users (name, password, role) VALUES ('tom', '123456', 'user'), ('jarry', '123456', 'admin'), ('jack', '123456', 'ROLE_USER');
(2)引入 MyBatis-Plus 與 MySQL 相關依賴
【依賴:】
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.3.1.tmp</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.18</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.10</version>
</dependency>
(3)配置 MyBatis-Plus 以及 MySQL 數據源信息
【數據源信息】 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver username: root password: 123456 url: jdbc:mysql://localhost:3306/testSpringSecurity?useUnicode=true&characterEncoding=utf8
(4)編寫 數據表對應的 實體類,以及相應的 mapper 或者 service(用於操作數據庫)
【實體類:】 package com.lyh.demo.springsecurity.entity; import lombok.Data; @Data public class Users { private Long id; private String name; private String password; private String role; } 【Mapper:】 package com.lyh.demo.springsecurity.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.lyh.demo.springsecurity.entity.Users; import org.apache.ibatis.annotations.Mapper; import org.springframework.stereotype.Service; @Mapper @Service public interface UsersMapper extends BaseMapper<Users> { }
(5)結合 SpringSecurity 進行安全驗證。
通過前面分析,添加上 SpirngSecurity 配置類后,會執行 loadUserByUsername() 方法將需要認證的用戶信息加載到當前認證系統中,所以在此添加 查詢數據庫的邏輯即可。
首先根據用戶名 在數據庫中 查詢出相應的 用戶、密碼 並封裝到 實體類中,並將此時的用戶、密碼、角色等加入到 當前認證系統中。然后再根據 輸入的用戶名、密碼 進行驗證。
【修改 MyUserDetailsService 中 loadUserByUsername() 代碼:(改為從數據庫中獲取用戶)】 package com.lyh.demo.springsecurity.service; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.lyh.demo.springsecurity.entity.Users; import com.lyh.demo.springsecurity.mapper.UsersMapper; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.AuthorityUtils; import org.springframework.security.core.userdetails.User; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.stereotype.Service; import java.util.List; @Service("userDetailsService") public class MyUserDetailsService implements UserDetailsService { @Autowired private UsersMapper usersMapper; @Override public UserDetails loadUserByUsername(String name) throws UsernameNotFoundException { // 定義查詢條件,根據用戶名 從數據庫查詢 對應的 用戶、密碼、角色 QueryWrapper<Users> queryWrapper = new QueryWrapper<>(); queryWrapper.eq("name", name); Users user = usersMapper.selectOne(queryWrapper); // 用戶不存在時,直接拋異常 if (user == null) { throw new UsernameNotFoundException("用戶不存在"); } // 用戶存在時,把 用戶、密碼、角色 加入到當前認證系統中 List<GrantedAuthority> auths = AuthorityUtils.commaSeparatedStringToAuthorityList(user.getRole()); // 將數據庫中的密碼進行 加密 return new User(user.getName(), new BCryptPasswordEncoder().encode(user.getPassword()), auths); // return new User(user.getName(), user.getPassword(), auths); // 若數據庫密碼已經加密過,直接使用即可 } }
4、自定義頁面(不使用默認頁面)以及 頁面跳轉、頁面訪問權限控制
(1)自定義頁面
在前面與 數據庫 交互的基礎上,添加如下代碼。
【登錄頁面:(login.html)】 <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>Login</title> </head> <body> <div> <form method="post" action="/login"> <h2>Please sign in</h2> <p> <label for="username">Username</label> <input type="text" id="username" name="username" placeholder="Username" required="" autofocus=""> </p> <p> <label for="password" class="sr-only">Password</label> <input type="password" id="password" name="password" placeholder="Password" required=""> </p> <button type="submit">Sign in</button> </form> </div> </body> </html> 【403 頁面:】 <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>403</title> </head> <body> <h1>403</h1> </body> </html> 【在 TestController 中添加一個 處理錯誤的邏輯:】 package com.lyh.demo.springsecurity.controller; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @RequestMapping("test") @RestController public class TestController { @GetMapping("/hello") public String hello() { return "hello spring security"; } @PostMapping("/error") public String error() { return "login error"; } }
(2)編寫配置類,配置頁面跳轉規則
在配置類中,重寫 configure() 方法,並通過 formLogin() 方法設置相關頁面。
【配置類中重寫 configure() 方法:】 package com.lyh.demo.springsecurity.config; import org.springframework.beans.factory.annotation.Autowired; 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.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; /** * 配置 Spring Security */ @Configuration public class SpringSecurityConfig extends WebSecurityConfigurerAdapter { @Autowired private UserDetailsService userDetailsService; @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder()); } @Override protected void configure(HttpSecurity http) throws Exception { /** * csrf() 表示開啟 csrf 防護。 * disable() 表示關閉 csrf 防護。 */ http.csrf().disable(); /** * formLogin() 用於自定義表單登錄。 * loginPage() 用於自定義登錄頁面。 * defaultSuccessUrl() 登錄成功后 跳轉的路徑。 * loginProcessingUrl() 表單提交的 action 地址(默認為 /login,修改后,對應的表單 action 也要修改),由系統提供 UsernamePasswordAuthenticationFilter 過濾器攔截並處理。 * usernameParameter() 用於自定義表單提交的用戶參數名,默認為 username,修改后,對應的表單參數也要修改。 * passwordParameter() 用於自定義表單提交的用戶密碼名,默認為 password,修改后,對應的表單參數也要修改。 * failureForwardUrl() 用於自定義表單提交失敗后 重定向地址,可用於前后端分離中,指向某個 controller,注意使用 POST 處理。 */ http.formLogin() .loginPage("/login.html") .loginProcessingUrl("/login") .defaultSuccessUrl("/test/hello") //.usernameParameter("name") //.passwordParameter("pwd") .failureForwardUrl("/test/error") ; /** * authorizeRequests() 用於 開啟認證,基於 HttpServletRequest 對 url 進行身份控制並授權訪問。 * antMatchers() 用於匹配 url。 * permitAll() 用於允許任何人訪問該 url。 * hasAuthority() 用於指定 具有某種權限的 人才能訪問 url。 * hasAnyAuthority() 用於指定 多個權限 進行訪問,多個權限間使用逗號分隔。 * * hasRole() 寫法與 hasAuthority() 類似,但是其會在 角色前 拼接上 ROLE_,使用時需要注意。 * hasAnyRole() 寫法與 hasAnyAuthority() 類似,同樣會在 角色前 拼接上 ROLE_。 * * 使用時 hasAuthority()、hasAnyAuthority() 或者 hasAnyRole()、hasAnyAuthority() 任選一對即可,同時使用四種可能會出現問題。 */ http.authorizeRequests() .antMatchers("/test/hello").hasAuthority("user") //.antMatchers("/test/hello").hasAnyRole("USER,GOD") //.antMatchers("/test/hello").hasRole("GOD") .antMatchers("/test/hello").hasAnyAuthority("user,admin") .antMatchers("/login", "/test/error").permitAll(); /** * 自定義 403 頁面 */ http.exceptionHandling().accessDeniedPage("/403.html"); } @Bean PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } }
如下圖所示,tom 的角色為 user、jarry 的角色為 admin,jack 的角色為 ROLE_USER。
只允許 user、admin 角色能夠訪問 /test/hello,即 tom、jarry 可以成功訪問系統,而 jack 訪問時會跳轉到 403 頁面,若 用戶名 或者 密碼輸入錯誤時,將會跳轉到 /test/error 畫面。
5、了解幾個注解
為了簡化開發,可以使用注解進行相關操作(操作不太靈活,慎用)。
(1)@Secured
添加在 方法上,並可以指定用戶角色,作用是只允許指定的用戶角色去訪問 該方法。
【使用步驟一:】 在配置類上,通過 @EnableGlobalMethodSecurity(securedEnabled = true) 開啟注解。 【使用步驟二:】 在方法上添加注解 @Secured,並指定 角色,角色前綴要為 ROLE_。 @GetMapping("/testSecured") @Secured({"ROLE_USER"}) public String testSecured() { return "success"; } 注: 由於 角色需要使用 ROLE_ 為前綴,所以數據庫存儲的 角色需要以 ROLE_ 為前綴 或者 設置權限時手動加上 ROLE_。
(2)@PreAuthorize
添加在 方法上,並可以指定用戶角色,作用是只允許指定的用戶角色去訪問 該方法。
在進入方法之前 會進行 校驗,校驗通過后才能執行方法。
【使用步驟一:】 在配置類上,通過 @EnableGlobalMethodSecurity(prePostEnabled = true) 開啟注解。 【使用步驟二:】 在方法上添加 @PreAuthorize 注解,並指定角色,角色的指定可以使用 Spring 表達式。 @GetMapping("/testSecured") @PreAuthorize("hasAnyAuthority('user', 'ROLE_USER')") public String testSecured() { return "success"; }
(3)@PostAuthorize
添加在 方法上,並可以指定用戶角色,作用是只允許指定的用戶角色去訪問 該方法。
在進入方法之后 會進行 校驗。不管有沒有權限,都會執行方法,適合帶有返回值的校驗。
【使用步驟一:】 在配置類上,通過 @EnableGlobalMethodSecurity(prePostEnabled = true) 開啟注解。 【使用步驟二:】 在方法上添加 @PostAuthorize 注解,並指定角色,角色的指定可以使用 Spring 表達式。 @GetMapping("/testSecured") @PostAuthorize("hasAuthority('user')") public String testSecured() { System.out.println("不管有沒有權限,我都會執行"); return "success"; }
6、用戶注銷操作
(1)自定義一個登錄成功頁面,並添加一個 退出鏈接。
【登錄成功頁面 success.html】
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>success</title>
</head>
<body>
<h1>Success</h1>
<a href="/logout">注銷</a>
</body>
</html>
(2)編寫配置類,修改頁面退出規則。
此處為了跳轉到 success.html 頁面,還需要 通過 http.formLogin().defaultSuccessUrl() 去指定頁面。
【添加退出規則:】 http.formLogin() .loginPage("/login.html") .loginProcessingUrl("/login") .defaultSuccessUrl("/success.html") //.usernameParameter("name") //.passwordParameter("pwd") .failureForwardUrl("/test/error") ; /** * logout() 用於自定義退出邏輯。 * logoutUrl() 用於攔截退出請求,默認為 /logout。 * logoutSuccessUrl() 用於自定義退出成功后,跳轉的頁面。 */ http.logout() .logoutUrl("/logout") .logoutSuccessUrl("/login.html") ;
如下圖所示,tom、jarry 可以訪問 /test/hello,jack 不可以訪問,所以當使用 tom、jarry 登錄時可以成功登陸,jack 會顯示 403,一旦點擊注銷后,需要再次進行登錄才能繼續訪問 /test/hello。
7、記住我
(1)工作流程
記住我 功能上指的是 用戶通過瀏覽器登錄一次網站后,關閉瀏覽器並再次訪問網站時,可以不用再次登錄而直接進行相關操作。
【工作流程:】
第一次通過瀏覽器登錄系統時:
首先 用戶名、密碼 會被 UsernamePasswordAuthenticationFilter 過濾器攔截,並進行認證。
認證通過后,會調用 RememberMeServices 生成 token,並將 token 寫入數據庫 以及 瀏覽器 cookie 中。
第二次通過瀏覽器登錄系統時:
直接攜帶 cookie 訪問,會被 RememberMeAuthenticationFilter 過濾器攔截,根據 cookie 讀取出 token 信息。
從數據庫中查找出 對應的 token 並比較,若相同,則可以登錄系統,否則跳轉到登錄頁面。
工作流程見下圖(圖片來源於網絡):
(2)基本實現:
由於 token 需要存儲在 數據庫中,所以需要配置數據源信息,並操作,而 SpringSecurity 中已經提供了相關操作類,只需在配置類中配置即可。
【配置如下:(注入 DataSource,並配置 PersistentTokenRepository 交給 Spring 管理)】 @Autowired private DataSource dataSource; @Bean public PersistentTokenRepository persistentTokenRepository() { JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl(); // 設置數據源 jdbcTokenRepository.setDataSource(dataSource); // 自動建表 // jdbcTokenRepository.setCreateTableOnStartup(true); return jdbcTokenRepository; } 【完整配置類:(通過 http.rememberMe() 配置)】 package com.lyh.demo.springsecurity.config; import org.springframework.beans.factory.annotation.Autowired; 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.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.authentication.rememberme.JdbcTokenRepositoryImpl; import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository; import javax.sql.DataSource; /** * 配置 Spring Security */ @Configuration @EnableWebSecurity public class SpringSecurityConfig extends WebSecurityConfigurerAdapter { @Autowired private UserDetailsService userDetailsService; @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder()); } @Autowired private DataSource dataSource; /** * 默認使用 PersistentTokenRepository 的子類 InMemoryTokenRepositoryImpl 將 token 放在內存中, * 可以使用子類 JdbcTokenRepositoryImpl 將 token 持久化到 數據庫中。 * 注: * * jdbcTokenRepository.setCreateTableOnStartup(true); 等同於下面 SQL, * 若不手動創建,可以使用代碼自動創建,但是執行一次后需要將其注釋掉。 * * create table persistent_logins ( * username varchar(64) not null, * series varchar(64) primary key, * token varchar(64) not null, * last_used timestamp not null * ) */ @Bean public PersistentTokenRepository persistentTokenRepository() { JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl(); // 設置數據源 jdbcTokenRepository.setDataSource(dataSource); // 自動建表 // jdbcTokenRepository.setCreateTableOnStartup(true); return jdbcTokenRepository; } @Override protected void configure(HttpSecurity http) throws Exception { /** * rememberMe() 用於實現記住我功能。 * tokenRepository() 設置數據訪問層。 * userDetailsService() 設置 userDetailsService。 * tokenValiditySeconds() 設置過期時間。 * rememberMeParameter() 自定義參數名,默認為 remember-me */ http.rememberMe() .tokenRepository(persistentTokenRepository()) .userDetailsService(userDetailsService) //.rememberMeParameter("remember") .tokenValiditySeconds(24 * 60 * 60); /** * csrf() 表示開啟 csrf 防護。 * disable() 表示關閉 csrf 防護。 */ http.csrf().disable(); /** * formLogin() 用於自定義表單登錄。 * loginPage() 用於自定義登錄頁面。 * defaultSuccessUrl() 登錄成功后 跳轉的路徑。 * loginProcessingUrl() 表單提交的 action 地址(默認為 /login,修改后,對應的表單 action 也要修改),由系統提供 UsernamePasswordAuthenticationFilter 過濾器攔截並處理。 * usernameParameter() 用於自定義表單提交的用戶參數名,默認為 username,修改后,對應的表單參數也要修改。 * passwordParameter() 用於自定義表單提交的用戶密碼名,默認為 password,修改后,對應的表單參數也要修改。 * failureForwardUrl() 用於自定義表單提交失敗后 重定向地址,可用於前后端分離中,指向某個 controller。 */ http.formLogin() .loginPage("/login.html") .loginProcessingUrl("/login") .defaultSuccessUrl("/success.html") //.usernameParameter("name") //.passwordParameter("pwd") .failureForwardUrl("/test/error") ; /** * logout() 用於自定義退出邏輯。 * logoutUrl() 用於攔截退出請求,默認為 /logout。 * logoutSuccessUrl() 用於自定義退出成功后,跳轉的頁面。 */ http.logout() .logoutUrl("/logout") .logoutSuccessUrl("/login.html") ; /** * authorizeRequests() 用於 開啟認證,基於 HttpServletRequest 對 url 進行身份控制並授權訪問。 * antMatchers() 用於匹配 url。 * permitAll() 用於允許任何人訪問該 url。 * hasAuthority() 用於指定 具有某種權限的 人才能訪問 url。 * hasAnyAuthority() 用於指定 多個權限 進行訪問,多個權限間使用逗號分隔。 * * hasRole() 寫法與 hasAuthority() 類似,但是其會在 角色前 拼接上 ROLE_,使用時需要注意。 * hasAnyRole() 寫法與 hasAnyAuthority() 類似,同樣會在 角色前 拼接上 ROLE_。 * * 使用時 hasAuthority()、hasAnyAuthority() 或者 hasAnyRole()、hasAnyAuthority() 任選一對即可,同時使用四種可能會出現問題。 */ http.authorizeRequests() .antMatchers("/test/hello").hasAuthority("user") //.antMatchers("/test/hello").hasAnyRole("USER,GOD") //.antMatchers("/test/hello").hasRole("GOD") .antMatchers("/test/hello").hasAnyAuthority("user,admin") .antMatchers("/login", "/test/error").permitAll(); /** * 自定義 403 頁面 */ http.exceptionHandling().accessDeniedPage("/403.html"); } @Bean PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } }
如下圖所示,點擊 記住我,並登陸后,會在瀏覽器 cookie 以及 數據庫中 各存放一份 token,關閉瀏覽器並再次登錄時,無需重新登錄,會自動檢測 cookie 中的 token 值是否正確,若相同則可以正常登陸。注銷時,瀏覽器 token 以及 數據庫的 token 會一起注銷。
(3)源碼分析:
Step1:
第一次通過瀏覽器登錄系統時,首先會被 UsernamePasswordAuthenticationFilter 過濾器攔截,認證通過后,會在 AbstractAuthenticationProcessingFilter 抽象類的 successfulAuthentication() 方法中 進行 token 的處理。
【UsernamePasswordAuthenticationFilter 繼承 AbstractAuthenticationProcessingFilter:】 public class UsernamePasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter {} 【AbstractAuthenticationProcessingFilter 的 doFilter() 中 調用了 successfulAuthentication() 方法:】 public abstract class AbstractAuthenticationProcessingFilter extends GenericFilterBean implements ApplicationEventPublisherAware, MessageSourceAware { public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException { ... successfulAuthentication(request, response, chain, authResult); } }
Step2:
AbstractAuthenticationProcessingFilter 中定義了 RememberMeServices 接口,在 successfulAuthentication() 方法中 會調用 RememberMeServices 接口的 loginSuccess() 方法。
【調用 RememberMeServices 的 loginSuccess() 方法:】 private RememberMeServices rememberMeServices = new NullRememberMeServices(); protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException { ... rememberMeServices.loginSuccess(request, response, authResult); }
Step3:
RememberMeServices 接口的 loginSuccess() 方法 由子類 AbstractRememberMeServices 實現,loginSuccess() 會先檢測是否存在 記住我 的功能,默認參數名為 remember-me,若表單中不存在 或者 為 false 時,會直接返回。為 true 時,會執行 onLoginSuccess() 方法。
【調用 AbstractRememberMeServices 的 loginSuccess() 方法:】 public final void loginSuccess(HttpServletRequest request, HttpServletResponse response, Authentication successfulAuthentication) { if (!rememberMeRequested(request, parameter)) { logger.debug("Remember-me login not requested."); return; } onLoginSuccess(request, response, successfulAuthentication); }
Step4:
onLoginSuccess() 方法由 AbstractRememberMeServices 的子類 PersistentTokenBasedRememberMeServices 去實現。向數據庫中 添加 token 以及 向 cookie 中添加 token。
【調用 PersistentTokenBasedRememberMeServices 的 onLoginSuccess() 方法:】 protected void onLoginSuccess(HttpServletRequest request, HttpServletResponse response, Authentication successfulAuthentication) { String username = successfulAuthentication.getName(); logger.debug("Creating new persistent login for user " + username); PersistentRememberMeToken persistentToken = new PersistentRememberMeToken( username, generateSeriesData(), generateTokenData(), new Date()); try { tokenRepository.createNewToken(persistentToken); addCookie(persistentToken, request, response); } catch (Exception e) { logger.error("Failed to save persistent token ", e); } }
Step5:
關閉瀏覽器,再次登錄時,由 RememberMeAuthenticationFilter 過濾器攔截請求,在其 doFilter() 方法中 調用 RememberMeServices 接口的 autoLogin() 方法進行處理。
【RememberMeAuthenticationFilter 的 】 public class RememberMeAuthenticationFilter extends GenericFilterBean implements ApplicationEventPublisherAware { private RememberMeServices rememberMeServices; public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException { ... Authentication rememberMeAuth = rememberMeServices.autoLogin(request,response); ... } }
Step6:
RememberMeServices 接口的 autoLogin() 方法由 AbstractRememberMeServices 子類實現,其根據 cookie 值解析出相應的 token。並根據 token 從數據庫中查詢用戶,並驗證用戶是否合法。
public final Authentication autoLogin(HttpServletRequest request, HttpServletResponse response) { String rememberMeCookie = extractRememberMeCookie(request); ... String[] cookieTokens = decodeCookie(rememberMeCookie); user = processAutoLoginCookie(cookieTokens, request, response); userDetailsChecker.check(user); }
Step7:
調用 processAutoLoginCookie() 方法根據 token 從數據庫中查詢出用戶。
protected UserDetails processAutoLoginCookie(String[] cookieTokens, HttpServletRequest request, HttpServletResponse response) { final String presentedSeries = cookieTokens[0]; final String presentedToken = cookieTokens[1]; PersistentRememberMeToken token = tokenRepository.getTokenForSeries(presentedSeries); if (!presentedToken.equals(token.getTokenValue())) { } ... return getUserDetailsService().loadUserByUsername(token.getUsername()); }