Spring Security 入門(一):認證和原理分析


Spring Security是一種基於Spring AOPServlet Filter的安全框架,其核心是一組過濾器鏈,實現 Web 請求和方法調用級別的用戶鑒權和權限控制。本文將會介紹該安全框架的身份認證和退出登錄的基本用法,並對其相關源碼進行分析。

表單認證

Spring Security提供了兩種認證方式:HttpBasic 認證和 HttpForm 表單認證。HttpBasic 認證不需要我們編寫登錄頁面,當瀏覽器請求 URL 需要認證才能訪問時,頁面會自動彈出一個登錄窗口,要求用戶輸入用戶名和密碼進行認證。大多數情況下,我們還是通過編寫登錄頁面進行 HttpForm 表單認證。

快速入門

☕️ 工程的整體目錄

☕️ 在 pom.xml 添加依賴

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.3.3.RELEASE</version>
    <relativePath/>
</parent>

<dependencies>
    <!-- spring web 依賴 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <!-- spring security 依賴 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>

    <!-- thymeleaf 模板引擎 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-thymeleaf</artifactId>
    </dependency>

    <!-- 熱部署 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-devtools</artifactId>
        <scope>runtime</scope>
        <optional>true</optional>
    </dependency>

    <!-- lombok -->
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>
    
    <!-- 封裝了一些常用的工具類 -->
    <dependency>
        <groupId>org.apache.commons</groupId>
        <artifactId>commons-lang3</artifactId>
        <version>3.10</version>
    </dependency>   

    <!-- 測試 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
        <exclusions>
            <exclusion>
                <groupId>org.junit.vintage</groupId>
                <artifactId>junit-vintage-engine</artifactId>
            </exclusion>
        </exclusions>
    </dependency>    
</dependencies>

☕️ 在 application.properties 添加配置

# 關閉 thymeleaf 緩存
spring.thymeleaf.cache=false

☕️ 編寫 Controller 層

package com.example.contorller;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ResponseBody;

@Controller
public class HomeController {

    @GetMapping({"/", "/index"})
    @ResponseBody
    public String index() {   // 跳轉到主頁
        return "歡迎您登錄!!!";
    }
}
package com.example.contorller;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
public class LoginController {

    @GetMapping("/login/page")
    public String loginPage() {  // 獲取登錄頁面
        return "login";   
    }
}

☕️ 編寫 login.html 頁面

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>登錄</title>
</head>
<body>
    <h3>表單登錄</h3>
    <form method="post" th:action="@{/login/form}">
        <input type="text" name="name" placeholder="用戶名"><br>
        <input type="password" name="pwd" placeholder="密碼"><br>
        <div th:if="${param.error}">
            <span th:text="${session.SPRING_SECURITY_LAST_EXCEPTION.message}" style="color:red">用戶名或密碼錯誤</span>
        </div>
        <button type="submit">登錄</button>
    </form>
</body>
</html>

☕️ 編寫安全配置類 SpringSecurityConfig

package com.example.config;

import org.springframework.context.annotation.Bean;
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.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;

@EnableWebSecurity       // 開啟 MVC Security 安全配置
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {

    /**
     * 密碼編碼器,密碼不能明文存儲
     */
    @Bean
    public BCryptPasswordEncoder passwordEncoder() {
        // 使用 BCryptPasswordEncoder 密碼編碼器,該編碼器會將隨機產生的 salt 混入最終生成的密文中
        return new BCryptPasswordEncoder();
    }

    /**
     * 定制用戶認證管理器來實現用戶認證
     */
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        // 采用內存存儲方式,用戶認證信息存儲在內存中
        auth.inMemoryAuthentication()
                .withUser("admin").password(passwordEncoder()
                .encode("123456")).roles("ROLE_ADMIN");
    }

    /**
     * 定制基於 HTTP 請求的用戶訪問控制
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // 啟動 form 表單登錄
        http.formLogin()
                // 設置登錄頁面的訪問路徑,默認為 /login,GET 請求;該路徑不設限訪問
                .loginPage("/login/page")
                // 設置登錄表單提交路徑,默認為 loginPage() 設置的路徑,POST 請求
                .loginProcessingUrl("/login/form")
                // 設置登錄表單中的用戶名參數,默認為 username
                .usernameParameter("name")
                // 設置登錄表單中的密碼參數,默認為 password
                .passwordParameter("pwd")
                // 認證成功處理,如果存在原始訪問路徑,則重定向到該路徑;如果沒有,則重定向 /index
                .defaultSuccessUrl("/index")
                // 認證失敗處理,重定向到指定地址,默認為 loginPage() + ?error;該路徑不設限訪問
                .failureUrl("/login/page?error");

        // 開啟基於 HTTP 請求訪問控制
        http.authorizeRequests()
                // 以下訪問不需要任何權限,任何人都可以訪問
                .antMatchers("/login/page").permitAll()
                // 其它任何請求訪問都需要先通過認證
                .anyRequest().authenticated();

        // 關閉 csrf 防護
        http.csrf().disable();  
    }

    /**
     * 定制一些全局性的安全配置,例如:不攔截靜態資源的訪問
     */
    @Override
    public void configure(WebSecurity web) throws Exception {
        // 靜態資源的訪問不需要攔截,直接放行
        web.ignoring().antMatchers("/**/*.css", "/**/*.js", "/**/*.png", "/**/*.jpg", "/**/*.jpeg");
    }
}

上述的安全配置類繼承了WebSecurityConfigurerAdapter抽象類,並重寫了三個重載的 configure() 方法:

/**
 * 定制用戶認證管理器來實現用戶認證
 *  1. 提供用戶認證所需信息(用戶名、密碼、當前用戶的資源權)
 *  2. 可采用內存存儲方式,也可能采用數據庫方式
 */
void configure(AuthenticationManagerBuilder auth);

/**
 * 定制基於 HTTP 請求的用戶訪問控制
 *  1. 配置攔截的哪一些資源
 *  2. 配置資源所對應的角色權限
 *  3. 定義認證方式:HttpBasic、HttpForm
 *  4. 定制登錄頁面、登錄請求地址、錯誤處理方式
 *  5. 自定義 Spring Security 過濾器等
 */
void configure(HttpSecurity http);

/**
 * 定制一些全局性的安全配置,例如:不攔截靜態資源的訪問
 */
void configure(WebSecurity web);

安全配置類需要使用 @EnableWebSecurity 注解修飾,該注解是一個組合注解,內部包含了 @Configuration 注解,所以安全配置類不需要添加 @Configuration 注解即可被 Spring 容器識別。具體定義如下:

@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE})
@Documented
@Import({WebSecurityConfiguration.class, SpringWebMvcImportSelector.class, OAuth2ImportSelector.class})
@EnableGlobalAuthentication
@Configuration
public @interface EnableWebSecurity {
    boolean debug() default false;
}

☕️ 測試

啟動項目,訪問localhost:8080,重定向到/login/page登錄頁面要求身份認證:

輸入正確的用戶名和密碼認證成功后,重定向到原始訪問路徑:


UserDetailsService 和 UserDetails 接口

在實際開發中,Spring Security應該動態的從數據庫中獲取信息進行自定義身份認證,采用數據庫方式進行身份認證一般需要實現兩個核心接口 UserDetailsService 和 UserDetails。

⭐️ UserDetailService 接口

該接口只有一個方法 loadUserByUsername(),用於定義從數據庫中獲取指定用戶信息的邏輯。如果未獲取到用戶信息,則需要手動拋出 UsernameNotFoundException 異常;如果獲取到用戶信息,則將該用戶信息封裝到 UserDetails 接口的實現類中並返回。

public interface UserDetailsService {
    // 輸入參數 username 是前端傳入的用戶名
    UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}

⭐️ UserDetails 接口

UserDetails 接口定義了用於描述用戶信息的方法,具體定義如下:

public interface UserDetails extends Serializable {
    // 返回用戶權限集合
    Collection<? extends GrantedAuthority> getAuthorities();

    // 返回用戶的密碼
    String getPassword();

    // 返回用戶的用戶名
    String getUsername();

    // 賬戶是否未過期(true 未過期, false 過期)
    boolean isAccountNonExpired();

    // 賬戶是否未鎖定(true 未鎖定, false 鎖定)
    // 用戶賬戶可能會被封鎖,達到一定要求可恢復
    boolean isAccountNonLocked();

    // 密碼是否未過期(true 未過期, false 過期)
    // 一些安全級別高的系統,可能要求 30 天更換一次密碼
    boolean isCredentialsNonExpired();

    // 賬戶是否可用(true 可用, false 不可用)
    // 系統一般不會真正的刪除用戶信息,而是假刪除,通過一個狀態碼標志用戶是否被刪除
    boolean isEnabled();
}

自定義用戶認證

✏️ 數據庫准備

DROP TABLE IF EXISTS `user`;
CREATE TABLE `user` (
      `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主鍵',
      `username` varchar(50) NOT NULL COMMENT '用戶名',
      `password` varchar(64) COMMENT '密碼',
      `mobile` varchar(20) COMMENT '手機號',
      `enabled` tinyint NOT NULL DEFAULT '1' COMMENT '用戶是否可用',
      `roles` text COMMENT '用戶角色,多個角色之間用逗號隔開',
      PRIMARY KEY (`id`),
      KEY `index_username`(`username`),
      KEY `index_mobile`(`mobile`)
) COMMENT '用戶表';
	
-- 密碼明文都為 123456	
INSERT INTO `user` VALUES ('1', 'admin', '$2a$10$JNVWTh5Yq56kJtrCZkcDk.DL/L/i8g3KrTAshcHW3mFf8//lnfG56', '11111111111', '1', 'ROLE_ADMIN,ROLE_USER');
INSERT INTO `user` VALUES ('2', 'user', '$2a$10$JNVWTh5Yq56kJtrCZkcDk.DL/L/i8g3KrTAshcHW3mFf8//lnfG56', '22222222222', '1', 'ROLE_USER');

我們將用戶信息和角色信息放在同一張表中,roles 字段設定為 text 類型,多個角色之間用逗號隔開。

✏️ 在 pom.xml 中添加依賴

<!-- mysql 驅動 -->
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <scope>runtime</scope>
</dependency>

<!-- mybatis -->
<dependency>
    <groupId>org.mybatis.spring.boot</groupId>
    <artifactId>mybatis-spring-boot-starter</artifactId>
    <version>2.1.3</version>
</dependency>

✏️ 在 application.properties 中添加配置

# 配置數據庫連接的基本信息
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/spring_security_test?characterEncoding=utf-8&useSSL=false&serverTimezone=Hongkong
spring.datasource.username=root
spring.datasource.password=123456

# 開啟自動駝峰命名規則(camel case)映射
mybatis.configuration.map-underscore-to-camel-case=true
# 配置 Mapper 映射文件位置
mybatis.mapper-locations=classpath*:/mapper/**/*.xml
# 別名包掃描路徑,通過該屬性可以給指定包中的類注冊別名
mybatis.type-aliases-package=com.example.entity

✏️ 創建 User 實體類,實現 UserDetails 接口

package com.example.entity;

import lombok.Data;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.Collection;
import java.util.List;

@Data
public class User implements UserDetails {

    private Long id;   // 主鍵

    private String username;  // 用戶名

    private String password;   // 密碼
    
    private String mobile;    // 手機號

    private String roles;    // 用戶角色,多個角色之間用逗號隔開

    private boolean enabled;  // 用戶是否可用

    private List<GrantedAuthority> authorities;  // 用戶權限集合

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {  // 返回用戶權限集合
        return authorities;
    }

    @Override
    public boolean isAccountNonExpired() {  // 賬戶是否未過期
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {  // 賬戶是否未鎖定
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {  // 密碼是否未過期
        return true;
    }

    @Override
    public boolean isEnabled() {  // 賬戶是否可用
        return enabled;
    }

    @Override
    public boolean equals(Object obj) {  // equals() 方法一般要重寫
        return obj instanceof User && this.username.equals(((User) obj).username);
    }

    @Override
    public int hashCode() {   // hashCode() 方法一般要重寫
        return this.username.hashCode();
    }
}

✏️ 創建 UserMapper 接口

package com.example.mapper;

import com.example.entity.User;
import org.apache.ibatis.annotations.Select;

public interface UserMapper {
    @Select("select * from user where username = #{username}")
    User selectByUsername(String username);
}

Mapper 接口需要注冊到 Spring 容器中,所以在啟動類上添加 Mapper 的包掃描路徑:

@SpringBootApplication
@MapperScan("com.example.mapper")
public class Application {

    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

✏️ 創建 CustomUserDetailsService 類,實現 UserDetailsService 接口

package com.example.service;

import com.example.entity.User;
import com.example.mapper.UserMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

@Service
public class CustomUserDetailsService implements UserDetailsService {

    @Autowired
    private UserMapper userMapper;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        //(1) 從數據庫嘗試讀取該用戶
        User user = userMapper.selectByUsername(username);
        // 用戶不存在,拋出異常
        if (user == null) {
            throw new UsernameNotFoundException("用戶不存在");
        }

        //(2) 將數據庫形式的 roles 解析為 UserDetails 的權限集合
        // AuthorityUtils.commaSeparatedStringToAuthorityList() 是 Spring Security 提供的方法,用於將逗號隔開的權限集字符串切割為可用權限對象列表
        user.setAuthorities(AuthorityUtils.commaSeparatedStringToAuthorityList(user.getRoles()));

        //(3) 返回 UserDetails 對象
        return user;
    }
}

✏️ 修改安全配置類 SpringSecurityConfig

@EnableWebSecurity       // 開啟 MVC Security 安全配置
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {
    //...
    @Autowired
    private CustomUserDetailsService userDetailsService;
    //...
        
    /**
     * 定制用戶認證管理器來實現用戶認證
     */
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        // 采用內存存儲方式,用戶認證信息存儲在內存中
        // auth.inMemoryAuthentication()
        //        .withUser("admin").password(passwordEncoder()
        //        .encode("123456")).roles("ROLE_ADMIN");
        
        // 不再使用內存方式存儲用戶認證信息,而是動態從數據庫中獲取
        auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
    }
    
    /**
     * 定制基於 HTTP 請求的用戶訪問控制
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
	//...
        // 開啟基於 HTTP 請求訪問控制
        http.authorizeRequests()
                // 以下訪問不需要任何權限,任何人都可以訪問
                .antMatchers("/login/page").permitAll()
                // 以下訪問需要 ROLE_ADMIN 權限
                .antMatchers("/admin/**").hasRole("ADMIN")
                // 以下訪問需要 ROLE_USER 權限
                .antMatchers("/user/**").hasAuthority("ROLE_USER")
                // 其它任何請求訪問都需要先通過認證
                .anyRequest().authenticated();
		//...
    }    
    //...
}

此處需要簡單介紹下Spring Security的授權方式,在Spring Security中角色屬於權限的一部分。對於角色ROLE_ADMIN的授權方式有兩種:hasRole("ADMIN")hasAuthority("ROLE_ADMIN"),這兩種方式是等價的。可能有人會疑惑,為什么在數據庫中的角色名添加了ROLE_前綴,而 hasRole() 配置時不需要加ROLE_前綴,我們查看相關源碼:

private static String hasRole(String role) {
    Assert.notNull(role, "role cannot be null");
    if (role.startsWith("ROLE_")) {
        throw new IllegalArgumentException("role should not start with 'ROLE_' since it is automatically inserted. Got '" + role + "'");
    } else {
        return "hasRole('ROLE_" + role + "')";
    }
}

由上可知,hasRole() 在判斷權限時會自動在角色名前添加ROLE_前綴,所以配置時不需要添加ROLE_前綴,同時這也要求 UserDetails 對象的權限集合中存儲的角色名要有ROLE_前綴。如果不希望匹配這個前綴,那么改為調用 hasAuthority() 方法即可。

✏️ 創建 AdminController 和 UserController

package com.example.contorller;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;

@Controller
@RequestMapping("/admin")
public class AdminController {   // 只能擁有 ROLE_ADMIN 權限的用戶訪問

    @GetMapping("/hello")
    @ResponseBody
    public String hello() {   
        return "hello,admin!!!";
    }
}
package com.example.contorller;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;

@Controller
@RequestMapping("/user")
public class UserController {  // 只能擁有 ROLE_USER 權限的用戶訪問

    @GetMapping("/hello")
    @ResponseBody
    public String hello() {
        return "hello,User!!!";
    }
}

✏️ 測試

訪問localhost:8080/user/hello,重定向到/login/page登錄頁面要求身份認證:

用戶名輸入 user,密碼輸入 123456,認證成功后重定向到原始訪問路徑:

訪問localhost:8080/admin/hello,訪問受限,頁面顯示 403。


基本流程分析

Spring Security采取過濾鏈實現認證與授權,只有當前過濾器通過,才能進入下一個過濾器:

綠色部分是認證過濾器,需要我們自己配置,可以配置多個認證過濾器。認證過濾器可以使用Spring Security提供的認證過濾器,也可以自定義過濾器(例如:短信驗證)。認證過濾器要在configure(HttpSecurity http)方法中配置,沒有配置不生效。下面會重點介紹以下三個過濾器:

  • UsernamePasswordAuthenticationFilter過濾器:該過濾器會攔截前端提交的 POST 方式的登錄表單請求,並進行身份認證。

  • ExceptionTranslationFilter過濾器:該過濾器不需要我們配置,對於前端提交的請求會直接放行,捕獲后續拋出的異常並進行處理(例如:權限訪問限制)。

  • FilterSecurityInterceptor過濾器:該過濾器是過濾器鏈的最后一個過濾器,根據資源權限配置來判斷當前請求是否有權限訪問對應的資源。如果訪問受限會拋出相關異常,並由ExceptionTranslationFilter過濾器進行捕獲和處理。

認證流程

認證流程是在UsernamePasswordAuthenticationFilter過濾器中處理的,具體流程如下所示:

📚 UsernamePasswordAuthenticationFilter源碼

當前端提交的是一個 POST 方式的登錄表單請求,就會被該過濾器攔截,並進行身份認證。該過濾器的 doFilter() 方法實現在其抽象父類AbstractAuthenticationProcessingFilter中,查看相關源碼:

public abstract class AbstractAuthenticationProcessingFilter extends GenericFilterBean implements ApplicationEventPublisherAware, MessageSourceAware {
    //...
    // 過濾器 doFilter() 方法
    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
        HttpServletRequest request = (HttpServletRequest)req;
        HttpServletResponse response = (HttpServletResponse)res;
        if (!this.requiresAuthentication(request, response)) {
            //(1) 判斷該請求是否為 POST 方式的登錄表單提交請求,如果不是則直接放行,進入下一個過濾器
            chain.doFilter(request, response);
        } else {
            if (this.logger.isDebugEnabled()) {
                this.logger.debug("Request is to process authentication");
            }
	    // Authentication 是用來存儲用戶認證信息的類,后續會進行詳細介紹
            Authentication authResult;
            try {
                //(2) 調用子類 UsernamePasswordAuthenticationFilter 重寫的方法進行身份認證,
                // 返回的 authResult 對象封裝認證后的用戶信息
                authResult = this.attemptAuthentication(request, response);
                if (authResult == null) {
                    return;
                }
		//(3) Session 策略處理(如果配置了用戶 Session 最大並發數,就是在此處進行判斷並處理)
                this.sessionStrategy.onAuthentication(authResult, request, response);
            } catch (InternalAuthenticationServiceException var8) {
                this.logger.error("An internal error occurred while trying to authenticate the user.", var8);
                //(4) 認證失敗,調用認證失敗的處理器
                this.unsuccessfulAuthentication(request, response, var8);
                return;
            } catch (AuthenticationException var9) {
                this.unsuccessfulAuthentication(request, response, var9);
                return;
            }

            //(4) 認證成功的處理
            if (this.continueChainBeforeSuccessfulAuthentication) {
                // 默認的 continueChainBeforeSuccessfulAuthentication 為 false,所以認證成功之后不進入下一個過濾器
                chain.doFilter(request, response);
            }
	    // 調用認證成功的處理器
            this.successfulAuthentication(request, response, chain, authResult);
        }
    }
}

上述的(2)過程調用了UsernamePasswordAuthenticationFilter的 attemptAuthentication() 方法,源碼如下:

public class UsernamePasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
    public static final String SPRING_SECURITY_FORM_USERNAME_KEY = "username";
    public static final String SPRING_SECURITY_FORM_PASSWORD_KEY = "password";
    private String usernameParameter = "username";  // 默認表單用戶名參數為 username
    private String passwordParameter = "password";  // 默認密碼參數為 password
    private boolean postOnly = true;   // 默認請求方式只能為 POST

    public UsernamePasswordAuthenticationFilter() {
        // 默認登錄表單提交路徑為 /login,POST 方式請求
        super(new AntPathRequestMatcher("/login", "POST"));
    }

    // 上述的 doFilter() 方法調用此 attemptAuthentication() 方法進行身份認證
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        if (this.postOnly && !request.getMethod().equals("POST")) {
            //(1) 默認情況下,如果請求方式不是 POST,會拋出異常
            throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
        } else {
            //(2) 獲取請求攜帶的 username 和 password
            String username = this.obtainUsername(request);
            String password = this.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
            this.setDetails(request, authRequest);
            //(5) 調用 ProviderManager 類的 authenticate() 方法進行身份認證
            return this.getAuthenticationManager().authenticate(authRequest);
        }
    }
    //...
}

上述的(3)過程創建的UsernamePasswordAuthenticationToken是 Authentication 接口的實現類,該類有兩個構造器,一個用於封裝前端請求傳入的未認證的用戶信息,一個用於封裝認證成功后的用戶信息:

public class UsernamePasswordAuthenticationToken extends AbstractAuthenticationToken {
    private static final long serialVersionUID = 530L;
    private final Object principal;
    private Object credentials;

    // 用於封裝前端請求傳入的未認證的用戶信息,前面的 authRequest 對象就是調用該構造器進行構造的
    public UsernamePasswordAuthenticationToken(Object principal, Object credentials) {
        super((Collection)null);         // 用戶權限為 null
        this.principal = principal;      // 前端傳入的用戶名
        this.credentials = credentials;  // 前端傳入的密碼
        this.setAuthenticated(false);    // 標記未認證
    }

    // 用於封裝認證成功后的用戶信息
    public UsernamePasswordAuthenticationToken(Object principal, Object credentials, Collection<? extends GrantedAuthority> authorities) {
        super(authorities);             // 用戶權限集合
        this.principal = principal;     // 封裝認證用戶信息的 UserDetails 對象,不再是用戶名
        this.credentials = credentials; // 前端傳入的密碼 
        super.setAuthenticated(true);   // 標記認證成功
    }
    //...
}

Authentication 接口的實現類用於存儲用戶認證信息,查看該接口具體定義:

public interface Authentication extends Principal, Serializable {
    // 用戶權限集合
    Collection<? extends GrantedAuthority> getAuthorities();
    // 用戶密碼
    Object getCredentials();
    // 請求攜帶的一些屬性信息(例如:remoteAddress,sessionId)
    Object getDetails();
    // 未認證時為前端請求傳入的用戶名;認證成功后為封裝認證用戶信息的 UserDetails 對象
    Object getPrincipal();
    // 是否被認證(true:認證成功,false:未認證)
    boolean isAuthenticated();
    // 設置是否被認證(true:認證成功,false:未認證)
    void setAuthenticated(boolean var1) throws IllegalArgumentException;
}

📚 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> 的迭代器
        Iterator var8 = this.getProviders().iterator();
      
	// 循環迭代
        while(var8.hasNext()) {
            AuthenticationProvider provider = (AuthenticationProvider)var8.next();
            //(3) 判斷當前 AuthenticationProvider 是否適用 UsernamePasswordAuthenticationToken.class 類型的 Authentication 的認證
            if (provider.supports(toTest)) {
                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 對象中
                        this.copyDetails(authentication, result);
                        break;
                    }
                } catch (InternalAuthenticationServiceException | AccountStatusException var13) {
                    this.prepareException(var13, authentication);
                    throw var13;
                } catch (AuthenticationException var14) {
                    lastException = var14;
                }
            }
        }

        if (result == null && this.parent != null) {
            try {
                //(5) 認證失敗,使用父類型 AuthenticationManager 進行驗證
                result = parentResult = this.parent.authenticate(authentication);
            } catch (ProviderNotFoundException var11) {
            } catch (AuthenticationException var12) {
                parentException = var12;
                lastException = var12;
            }
        }

        if (result != null) {
            //(6) 認證成功之后,去除 result 的敏感信息,要求相關類實現 CredentialsContainer 接口
            if (this.eraseCredentialsAfterAuthentication && result instanceof CredentialsContainer) {
                // 去除過程就是調用 CredentialsContainer 接口的 eraseCredentials() 方法
                ((CredentialsContainer)result).eraseCredentials();
            }
	    //(7) 發布認證成功的事件
            if (parentResult == null) {
                this.eventPublisher.publishAuthenticationSuccess(result);
            }

            return result;
        } else {
            //(8) 認證失敗之后,拋出失敗的異常信息
            if (lastException == null) {
                lastException = new ProviderNotFoundException(this.messages.getMessage("ProviderManager.providerNotFound", new Object[]{toTest.getName()}, "No AuthenticationProvider found for {0}"));
            }

            if (parentException == null) {
                this.prepareException((AuthenticationException)lastException, authentication);
            }

            throw lastException;
        }
    }
    //...
}

上述認證成功之后的(6)過程,調用 CredentialsContainer 接口定義的 eraseCredentials() 方法去除敏感信息。查看 UsernamePasswordAuthenticationToken 實現的 eraseCredentials() 方法,該方法實現在其父類中:

public abstract class AbstractAuthenticationToken implements Authentication, CredentialsContainer {
    // 父類實現了 CredentialsContainer 接口
    //...
    public void eraseCredentials() {
        // credentials(前端傳入的密碼)會置為 null
        this.eraseSecret(this.getCredentials());
        // principal 在已認證的 Authentication 中是 UserDetails 實現類;如果該實現類想要
        // 去除敏感信息,需要實現 CredentialsContainer 接口的 eraseCredentials() 方法;
        // 由於我們自定義的 User 類沒有實現該接口,所以不進行任何操作。
        this.eraseSecret(this.getPrincipal());
        this.eraseSecret(this.details);
    }    

    private void eraseSecret(Object secret) {
        if (secret instanceof CredentialsContainer) {
            ((CredentialsContainer)secret).eraseCredentials();
        }
    }    
}

📚 DaoAuthenticationProvider 源碼

上述的(4)過程,ProviderManager 將未認證的 Authentication 對象委托給 DaoAuthenticationProvider 進行身份認證。該類的 authenticate() 方法實現在其抽象父類 AbstractUserDetailsAuthenticationProvider 中,其源碼如下:

public abstract class AbstractUserDetailsAuthenticationProvider implements AuthenticationProvider, InitializingBean, MessageSourceAware {
	//...
	// 入參為未認證的 Authentication 對象
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        //(1) 如果入參的 Authentication 類型不是 UsernamePasswordAuthenticationToken,拋出異常
        Assert.isInstanceOf(UsernamePasswordAuthenticationToken.class, authentication, () -> {
            return this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.onlySupports", "Only UsernamePasswordAuthenticationToken is supported");
        });
        // 獲取用戶名
        String username = authentication.getPrincipal() == null ? "NONE_PROVIDED" : authentication.getName();
        //(2) 從緩存中獲取當前用戶對應的 UserDetails 對象
        boolean cacheWasUsed = true;
        // 默認的 userCache 為 NullUserCache 類,該類的 getUserFromCache() 方法始終返回 null。
        // 也就是說默認情況下,Spring Security 不緩存用戶信息對象 UserDetails
        UserDetails user = this.userCache.getUserFromCache(username);
        
        if (user == null) {
            cacheWasUsed = false;

            try {
                //(3) 當緩存沒有 UserDetails,則調用子類 DaoAuthenticationProvider 重寫的 retrieverUser() 方法獲取;
                // 其內部調用對應的 UserDetailsService 的 loadUserByUsername() 方法進行獲取
                user = this.retrieveUser(username, (UsernamePasswordAuthenticationToken)authentication);
            } catch (UsernameNotFoundException var6) {
                //(4) 未獲取到 UserDetails,拋出相關異常
                this.logger.debug("User '" + username + "' not found");
                if (this.hideUserNotFoundExceptions) {
                    throw new BadCredentialsException(this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
                }

                throw var6;
            }

            Assert.notNull(user, "retrieveUser returned null - a violation of the interface contract");
        }

        try {
            //(4) 成功獲取 UserDetails 對象,進行前置檢查,檢查賬號是否鎖定、是否可用、是否過期
            this.preAuthenticationChecks.check(user);
            //(5) 調用子類 DaoAuthenticationProvider 重寫的 additionalAuthenticationChecks() 方法;
            // 檢查前端傳入的密碼是否正確,內部調用密碼編碼器 PasswordEncoder 的 matches() 方法進行判斷
            this.additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken)authentication);
        } catch (AuthenticationException var7) {
            //(6) 檢查過程出現異常,重新進行認證
            if (!cacheWasUsed) {
                throw var7;
            }

            // 和前面一樣,調用子類 DaoAuthenticationProvider 重寫的 retrieverUser() 方法獲取 UserDetails
            // 內部調用對應的 UserDetailsService 的 loadUserByUsername() 方法進行獲取
            cacheWasUsed = false;
            user = this.retrieveUser(username, (UsernamePasswordAuthenticationToken)authentication);
            this.preAuthenticationChecks.check(user);
            this.additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken)authentication);
        }

        //(6) 后置檢查,檢查用戶密碼是否過期
        this.postAuthenticationChecks.check(user);
        if (!cacheWasUsed) {   
            //(7) 將從 UserDetailsService 中取出的用戶信息對象 UserDetails 放入緩存中。
            // userCache 默認為 NullUserCache 類,該類的 putUserInCache() 是一個空方法,
            // 所以默認情況下,Spring Security 不緩存用戶信息對象 UserDetails
            this.userCache.putUserInCache(user);
        }

        Object principalToReturn = user;
        if (this.forcePrincipalAsString) {
            // forcePrincipalAsString 默認為 false
            principalToReturn = user.getUsername();
        }
		
        //(8) 調用子類 DaoAuthenticationProvider 重寫的 createSuccessAuthentication() 方法,
        // 該方法將認證成功后的用戶信息封裝成 Authentication 對象(標記已認證),並返回。
        // 需要注意,此處傳入的 principal 是 UserDetails 對象,不再是 username
        return this.createSuccessAuthentication(principalToReturn, authentication, user);
    }  
    
    // 該方法由子類 DaoAuthenticationProvider 同名方法調用
    protected Authentication createSuccessAuthentication(Object principal, Authentication authentication, UserDetails user) {
        // 構造 Authentication 對象(標記已認證),需要注意傳入的 principal 是 UserDetails 對象
        UsernamePasswordAuthenticationToken result = new UsernamePasswordAuthenticationToken(principal, authentication.getCredentials(), this.authoritiesMapper.mapAuthorities(user.getAuthorities()));
        // 將傳入的 Authentication 的 details 信息設置到新構造的 Authentication 對象中
        result.setDetails(authentication.getDetails());
        return result;
    }    
    //...
}

上述的過程,調用了子類 DaoAuthenticationProvider 重寫的方法,其源碼如下:

public class DaoAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider {
    //...
    // 上述的(3)過程調用的方法,根據用戶名調用 UserDetailsService 的 loadUserByUsername() 方法獲取 UserDetails 對象
    protected final UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
        this.prepareTimingAttackProtection();

        try {
            // 調用 UserDetailsService 的 loadUserByUsername() 方法獲取 UserDetails 對象
            UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username);
            if (loadedUser == null) {
                throw new InternalAuthenticationServiceException("UserDetailsService returned null, which is an interface contract violation");
            } else {
                return loadedUser;
            }
        } catch (UsernameNotFoundException var4) {
            // 用戶不存在異常的處理
            this.mitigateAgainstTimingAttack(authentication);
            throw var4;
        } catch (InternalAuthenticationServiceException var5) {
            throw var5;
        } catch (Exception var6) {
            throw new InternalAuthenticationServiceException(var6.getMessage(), var6);
        }
    }
    
    // 上述的(5)過程調用的方法,檢查前端傳入的密碼是否正確
    protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
        if (authentication.getCredentials() == null) {
            this.logger.debug("Authentication failed: no credentials provided");
            throw new BadCredentialsException(this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
        } else {
            // 使用密碼編碼器 passwordEncoder 的 matches() 方法檢查密碼是否正確
            String presentedPassword = authentication.getCredentials().toString();
            if (!this.passwordEncoder.matches(presentedPassword, userDetails.getPassword())) {
                this.logger.debug("Authentication failed: password does not match stored value");
                throw new BadCredentialsException(this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
            }
        }
    }
    
    // 上述的(8)過程調用的方法,將認證成功后的用戶信息封裝成 Authentication 對象(標記已認證),並返回
    protected Authentication createSuccessAuthentication(Object principal, Authentication authentication, UserDetails user) {
        // 此處需要注意,傳入的 principal 是一個 UserDetails 對象
        boolean upgradeEncoding = this.userDetailsPasswordService != null && this.passwordEncoder.upgradeEncoding(user.getPassword());
        if (upgradeEncoding) {
            String presentedPassword = authentication.getCredentials().toString();
            String newPassword = this.passwordEncoder.encode(presentedPassword);
            user = this.userDetailsPasswordService.updatePassword(user, newPassword);
        }
	// 調用父類同名的 createSuccessAuthentication() 方法
        return super.createSuccessAuthentication(principal, authentication, user);
    }
    //...
}

📚 認證成功/失敗處理

上述過程就是認證流程的最核心部分,接下來重新回到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() 方法返回的 Authentication 對象(標記已認證)
	    authResult = this.attemptAuthentication(request, response);
            //...
        } catch (AuthenticationException var9) {
            // 調用認證失敗的處理器
            this.unsuccessfulAuthentication(request, response, var9);
            return;
        }

	//...
        // 調用認證成功的處理器
        this.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 (this.logger.isDebugEnabled()) {
            this.logger.debug("Authentication success. Updating SecurityContextHolder to contain: " + authResult);
        }

        //(1) 將認證成功的用戶信息對象 Authentication 封裝進 SecurityContext 對象中,並存入 SecurityContextHolder;
        // SecurityContextHolder 是對 ThreadLocal 的一個封裝,后續會介紹
        SecurityContextHolder.getContext().setAuthentication(authResult);
        //(2) rememberMe 的處理
        this.rememberMeServices.loginSuccess(request, response, authResult);
        if (this.eventPublisher != null) {
            //(3) 發布認證成功的事件
            this.eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(authResult, this.getClass()));
        }
	//(4) 調用認證成功處理器
        this.successHandler.onAuthenticationSuccess(request, response, authResult);
    }

    // 認證失敗后的處理
    protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
        //(1) 清除該線程在 SecurityContextHolder 中對應的 SecurityContext 對象
        SecurityContextHolder.clearContext();
        if (this.logger.isDebugEnabled()) {
            this.logger.debug("Authentication request failed: " + failed.toString(), failed);
            this.logger.debug("Updated SecurityContextHolder to contain null Authentication");
            this.logger.debug("Delegating to authentication failure handler " + this.failureHandler);
        }
	//(2) rememberMe 的處理
        this.rememberMeServices.loginFail(request, response);
        //(3) 調用認證失敗處理器
        this.failureHandler.onAuthenticationFailure(request, response, failed);
    }
}

📚 認證流程中各核心類和接口的關系圖


權限訪問流程

上一個部分通過源碼的方式介紹了認證流程,下面介紹權限訪問流程,主要是對ExceptionTranslationFilter過濾器和FilterSecurityInterceptor過濾器進行介紹。

ExceptionTranslationFilter過濾器

該過濾器是用於處理異常的,不需要我們配置,對於前端提交的請求會直接放行,捕獲后續拋出的異常並進行處理(例如:權限訪問限制)。具體源碼如下:

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);
            this.logger.debug("Chain processed normally");
        } catch (IOException var9) {
            throw var9;
        } catch (Exception var10) {
            //(2) 捕獲后續出現的異常進行處理
            Throwable[] causeChain = this.throwableAnalyzer.determineCauseChain(var10);
            // 訪問需要認證的資源,但當前請求未認證所拋出的異常
            RuntimeException ase = (AuthenticationException)this.throwableAnalyzer.getFirstThrowableOfType(AuthenticationException.class, causeChain);  
            if (ase == null) {
                // 訪問權限受限的資源所拋出的異常
                ase = (AccessDeniedException)this.throwableAnalyzer.getFirstThrowableOfType(AccessDeniedException.class, causeChain);
            }
	    // ...
        }
    }
    //...
}

FilterSecurityInterceptor過濾器

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() 方法
        this.invoke(fi);
    }   
    
    public void invoke(FilterInvocation fi) throws IOException, ServletException {
        if (fi.getRequest() != null && fi.getRequest().getAttribute("__spring_security_filterSecurityInterceptor_filterApplied") != null && this.observeOncePerRequest) {
            fi.getChain().doFilter(fi.getRequest(), fi.getResponse());
        } else {
            if (fi.getRequest() != null && this.observeOncePerRequest) {
                fi.getRequest().setAttribute("__spring_security_filterSecurityInterceptor_filterApplied", 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, (Object)null);
        }
    }
    //...
}

需要注意,Spring Security的過濾器鏈是配置在 SpringMVC 的核心組件 DispatcherServlet 運行之前。也就是說,請求通過Spring Security的所有過濾器,不意味着能夠正常訪問資源,該請求還需要通過 SpringMVC 的攔截器鏈。


請求間共享認證信息

一般認證成功后的用戶信息是通過 Session 在多個請求之間共享,那么Spring Security中是如何實現將已認證的用戶信息對象 Authentication 與 Session 綁定的,該部分會進行具體分析。

原理分析

✍ SecurityContext 和 SecurityContextHolder

在前面講解認證成功的處理方法 successfulAuthentication() 時,有以下代碼:

public abstract class AbstractAuthenticationProcessingFilter extends GenericFilterBean implements ApplicationEventPublisherAware, MessageSourceAware {
    //...  
    // 認證成功后的處理
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
        //...
        // 將已認證的用戶信息對象 Authentication 封裝進 SecurityContext 對象中,並存入 SecurityContextHolder
        SecurityContextHolder.getContext().setAuthentication(authResult);
	//...
    }
}

查看 SecurityContext 接口及其實現類 SecurityContextImpl,該類其實就是對 Authentication 的封裝:

public interface SecurityContext extends Serializable {
    Authentication getAuthentication();

    void setAuthentication(Authentication var1);
}
public class SecurityContextImpl implements SecurityContext {
    private static final long serialVersionUID = 520L;
    private Authentication authentication;

    public SecurityContextImpl() {
    }

    public SecurityContextImpl(Authentication authentication) {
        this.authentication = authentication;
    }

    public Authentication getAuthentication() {
        return this.authentication;
    }

    public void setAuthentication(Authentication authentication) {
        this.authentication = authentication;
    }
    //...
}

查看 SecurityContextHolder 類,該類其實是對 ThreadLocal 的封裝,存儲 SecurityContext 對象:

public class SecurityContextHolder {
    //...
    private static SecurityContextHolderStrategy strategy;
    private static int initializeCount = 0;
    
    public SecurityContextHolder() {
    }    
    
    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 var2) {
                ReflectionUtils.handleReflectionException(var2);
            }
        } 

        ++initializeCount;
    }

    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);
    }
    
    public static void clearContext() {
        // 清空當前線程對應的 ThreadLocal<SecurityContext> 的存儲
        strategy.clearContext();
    }
    
    public static SecurityContextHolderStrategy getContextHolderStrategy() {
        return strategy;
    }    
    
    public static SecurityContext createEmptyContext() {
        return strategy.createEmptyContext();
    }    
    
    //...   
}
final class ThreadLocalSecurityContextHolderStrategy implements SecurityContextHolderStrategy {
    // 使用 ThreadLocal 對 SecurityContext 進行存儲
    private static final ThreadLocal<SecurityContext> contextHolder = new ThreadLocal();

    ThreadLocalSecurityContextHolderStrategy() {
    }
    
    public SecurityContext getContext() {
        // 需要注意,如果當前線程對應的 ThreadLocal<SecurityContext> 沒有任何對象存儲,
        // getContext() 會創建並返回一個空的 SecurityContext 對象,
        // 並且該空的 SecurityContext 對象會存入 ThreadLocal<SecurityContext>
        SecurityContext ctx = (SecurityContext)contextHolder.get();
        if (ctx == null) {
            ctx = this.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 void clearContext() {
        // 清空當前線程對應的 ThreadLocal<SecurityContext> 的存儲
        contextHolder.remove();
    }       
    
    public SecurityContext createEmptyContext() {
        // 創建一個空的 SecurityContext 對象
        return new SecurityContextImpl();
    }
}

SecurityContextPersistenceFilter過濾器

前面提到過,在UsernamePasswordAuthenticationFilter過濾器認證成功之后,會在認證成功的處理方法中將已認證的用戶信息對象 Authentication 封裝進 SecurityContext,並存入 SecurityContextHolder。之后,響應會通過SecurityContextPersistenceFilter過濾器,該過濾器的位置在所有過濾器的最前面,請求到來先進它,響應返回最后一個通過它,所以在該過濾器中處理已認證的用戶信息對象 Authentication 與 Session 綁定。

認證成功的響應通過SecurityContextPersistenceFilter過濾器時,會從 SecurityContextHolder 中取出封裝了已認證用戶信息對象 Authentication 的 SecurityContext,放進 Session 中。當請求再次到來時,請求首先經過該過濾器,該過濾器會判斷當前請求的 Session 是否存有 SecurityContext 對象,如果有則將該對象取出再次放入 SecurityContextHolder 中,之后該請求所在的線程獲得認證用戶信息,后續的資源訪問不需要進行身份認證;當響應再次返回時,該過濾器同樣從 SecurityContextHolder 取出 SecurityContext 對象,放入 Session 中。具體源碼如下:

public class SecurityContextPersistenceFilter extends GenericFilterBean {
    //...
    // 過濾器的 doFilter() 方法
    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
        HttpServletRequest request = (HttpServletRequest)req;
        HttpServletResponse response = (HttpServletResponse)res;
        if (request.getAttribute("__spring_security_scpf_applied") != null) {
            chain.doFilter(request, response);
        } else {
            boolean debug = this.logger.isDebugEnabled();
            request.setAttribute("__spring_security_scpf_applied", Boolean.TRUE);
            if (this.forceEagerSessionCreation) {
                HttpSession session = request.getSession();
                if (debug && session.isNew()) {
                    this.logger.debug("Eagerly created session: " + session.getId());
                }
            }
            HttpRequestResponseHolder holder = new HttpRequestResponseHolder(request, response);
            //(1) 請求到來時,檢查當前 Session 中是否存有 SecurityContext 對象,
            // 如果有,從 Session 中取出該對象;如果沒有,創建一個空的 SecurityContext 對象
            SecurityContext contextBeforeChainExecution = this.repo.loadContext(holder);
            boolean var13 = false;

            try {
                var13 = true;
                //(2) 將上述獲得 SecurityContext 對象放入 SecurityContextHolder 中
                SecurityContextHolder.setContext(contextBeforeChainExecution);
                //(3) 進入下一個過濾器
                chain.doFilter(holder.getRequest(), holder.getResponse());
                var13 = false;
            } finally {
                if (var13) {
                    SecurityContext contextAfterChainExecution = SecurityContextHolder.getContext();
                    SecurityContextHolder.clearContext();
                    this.repo.saveContext(contextAfterChainExecution, holder.getRequest(), holder.getResponse());
                    request.removeAttribute("__spring_security_scpf_applied");
                    if (debug) {
                        this.logger.debug("SecurityContextHolder now cleared, as request processing completed");
                    }

                }
            }
			
            //(4) 響應返回時,從 SecurityContextHolder 中取出 SecurityContext
            SecurityContext contextAfterChainExecution = SecurityContextHolder.getContext();
            //(5) 移除 SecurityContextHolder 中的 SecurityContext 對象 
            SecurityContextHolder.clearContext();
            //(6) 將取出的 SecurityContext 對象放進 Session
            this.repo.saveContext(contextAfterChainExecution, holder.getRequest(), holder.getResponse());
            request.removeAttribute("__spring_security_scpf_applied");
            if (debug) {
                this.logger.debug("SecurityContextHolder now cleared, as request processing completed");
            }
        }
    }    
    //...
}

獲取認證用戶信息

由前文可知,封裝了已認證用戶信息對象 Authentication 的 SecurityContext 即存儲在 SecurityContextHolder 中,也存儲在 Session 中,所以可以有兩種方式獲取用戶信息。

💡 使用 SecurityContextHolder 獲取

@Controller
public class TestController {
    @GetMapping("/test1")
    @ResponseBody
    public Object test1() {
        // 從 SecurityContextHolder 獲取認證用戶信息對象 Authentication
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        return authentication;
    }
}

訪問localhost:8080/test1,使用 admin 的用戶名和密碼認證之后,瀏覽器頁面顯示:

{
    "authorities": [
        {
            "authority": "ROLE_ADMIN"
        },
        {
            "authority": "ROLE_USER"
        }
    ],
    "details": {
        "remoteAddress": "0:0:0:0:0:0:0:1",
        "sessionId": null
    },
    "authenticated": true,
    "principal": {
        "id": 1,
        "username": "admin",
        "password": "$2a$10$JNVWTh5Yq56kJtrCZkcDk.DL/L/i8g3KrTAshcHW3mFf8//lnfG56",
        "mobile": "11111111111",
        "roles": "ROLE_ADMIN,ROLE_USER",
        "enabled": true,
        "authorities": [
            {
                "authority": "ROLE_ADMIN"
            },
            {
                "authority": "ROLE_USER"
            }
        ],
        "accountNonExpired": true,
        "credentialsNonExpired": true,
        "accountNonLocked": true
    },
    "credentials": null,
    "name": "admin"
}

由上可以驗證我們前面的分析,敏感信息 credentials 被去除,principal 存儲的為 UserDetails 實現類,可以通過強轉獲取 UserDetails 對象:

@GetMapping("/test2")
@ResponseBody
public Object test2() {
    // 從 SecurityContextHolder 獲取認證用戶信息對象 Authentication
    Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
    // 從 Authentication 中獲取 UserDetails
    UserDetails user = (UserDetails) authentication.getPrincipal();
    return user;
}
{
    "id": 1,
    "username": "admin",
    "password": "$2a$10$JNVWTh5Yq56kJtrCZkcDk.DL/L/i8g3KrTAshcHW3mFf8//lnfG56",
    "roles": "ROLE_ADMIN,ROLE_USER",
	"mobile": "11111111111",    
    "enabled": true,
    "authorities": [
        {
            "authority": "ROLE_ADMIN"
        },
        {
            "authority": "ROLE_USER"
        }
    ],
    "accountNonExpired": true,
    "credentialsNonExpired": true,
    "accountNonLocked": true
}

💡 使用 HttpSession 獲取

@GetMapping("/test3")
@ResponseBody
public Object test3(HttpSession session) {
    // 獲取 Session 獲取 SecurityContext
    SecurityContext context = (SecurityContext) session.getAttribute("SPRING_SECURITY_CONTEXT");
    // 從 Authentication 中獲取 UserDetails
    UserDetails user = (UserDetails) context.getAuthentication().getPrincipal();
    return user;
}
{
    "id": 1,
    "username": "admin",
    "password": "$2a$10$JNVWTh5Yq56kJtrCZkcDk.DL/L/i8g3KrTAshcHW3mFf8//lnfG56",
    "roles": "ROLE_ADMIN,ROLE_USER",
    "mobile": "11111111111",
    "enabled": true,
    "authorities": [
        {
            "authority": "ROLE_ADMIN"
        },
        {
            "authority": "ROLE_USER"
        }
    ],
    "accountNonExpired": true,
    "credentialsNonExpired": true,
    "accountNonLocked": true
}

自定義認證成功/失敗處理器

登錄處理的方法介紹

此處先對http.formLogin()返回值的主要方法進行說明,這些方法涉及用戶登錄的處理,具體如下:

  • loginPage(String loginPage):設置用戶登錄頁面的訪問路徑,默認為 GET 請求的 /login
  • loginProcessingUrl(String loginProcessingUrl):設置登錄表單提交的路徑,默認為是 POST 請求的 loginPage() 設置的路徑
  • successForwardUrl(String forwordUrl):設置用戶認證成功后轉發的地址。
  • successHandler(AuthenticationSuccessHandler successHandler):配置用戶認證成功后的自定義處理器。
  • defaultSuccessUrl(String defaultSuccessUrl):設置用戶認證成功后重定向的地址。這里需要注意,該路徑是用戶直接訪問登錄頁面認證成功后重定向的路徑,如果是其他路徑跳轉到登錄頁面認證成功后會重定向到原始訪問路徑。可設置第二個參數為 true,使認證成功后始終重定向到該地址。
  • failureForwrad(String forwardUrl):設置用戶認證失敗后轉發的地址。
  • failureHandler(AuthenticationFailureHandler authenticationFailureHandler):設置用戶登錄失敗后的自定義錯誤處理器。
  • failureUrl(String authenticationFailureUrl):設置用戶登錄失敗后重定向的地址,指定的路徑要能匿名訪問,默認為loginPage() + ?error
  • usernameParamter(String usernameParamter):設置登錄表單中的用戶名參數,默認為 username。
  • passwordParamter(String passwordParamter):設置登錄表單中的密碼參數,默認為 password。

內置的處理器介紹

前面的 defaultSuccessUrl() 和 failureUrl() 方法使用的是Spring Security內置的認證成功和失敗處理器。我們也可以自定義認證成功和失敗處理器,根據前端請求方式返回不同的響應類型數據,如果客戶端是 ajax 請求,響應 JSON 數據通知前端認證成功或失敗;如果客戶端是正常的表單提交請求,認證成功時重定向到該請求的原始訪問路徑或指定路徑,認證失敗時重定向到登錄頁面顯示錯誤信息。

在自定義認證成功和失敗處理器之前,我們先對 defaultSuccessUrl() 和 failureUrl() 方法使用的認證和失敗處理器進行介紹。

defaultSuccessUrl() 方法的處理器

public final T defaultSuccessUrl(String defaultSuccessUrl) {
    return this.defaultSuccessUrl(defaultSuccessUrl, false);
}

public final T defaultSuccessUrl(String defaultSuccessUrl, boolean alwaysUse) {
    // 該 Handler 就是 defaultSuccessUrl() 方法使用的認證成功處理器
    SavedRequestAwareAuthenticationSuccessHandler handler = new SavedRequestAwareAuthenticationSuccessHandler();
    // 設置用戶認證成功后重定向的地址。需要注意,該路徑是用戶直接訪問登錄頁面認證成功后重定向的路徑,
    // 如果是其他路徑跳轉到登錄頁面認證成功后會重定向到原始訪問路徑
    handler.setDefaultTargetUrl(defaultSuccessUrl);
    // 設置用戶認證成功后是否始終重定向到 defaultSuccessUrl
    handler.setAlwaysUseDefaultTargetUrl(alwaysUse);
    this.defaultSuccessHandler = handler;
    return this.successHandler(handler);
}

查看SavedRequestAwareAuthenticationSuccessHandler處理器:

public class SavedRequestAwareAuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
    protected final Log logger = LogFactory.getLog(this.getClass());
    private RequestCache requestCache = new HttpSessionRequestCache();

    public SavedRequestAwareAuthenticationSuccessHandler() {
    }

    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws ServletException, IOException {
        //(1) 從 Session 中獲取 SavedRequest 對象,該對象中存儲着用戶的原始訪問路徑
        SavedRequest savedRequest = this.requestCache.getRequest(request, response);
        if (savedRequest == null) {
            // 不存在原始訪問路徑的處理,重定向指定路徑
            super.onAuthenticationSuccess(request, response, authentication);
        } else {
            // 存在原始訪問路徑
            String targetUrlParameter = this.getTargetUrlParameter();
            if (!this.isAlwaysUseDefaultTargetUrl() && (targetUrlParameter == null || !StringUtils.hasText(request.getParameter(targetUrlParameter)))) {
                //(2) 清除 Session 中名為 SPRING_SECURITY_LAST_EXCEPTION 的屬性,該屬性用於存儲認證錯誤信息
                this.clearAuthenticationAttributes(request);
                //(3) 獲取原始訪問路徑
                String targetUrl = savedRequest.getRedirectUrl();
                this.logger.debug("Redirecting to DefaultSavedRequest Url: " + targetUrl);
                //(4) 重定向到原始訪問路徑
                this.getRedirectStrategy().sendRedirect(request, response, targetUrl);
            } else {
                this.requestCache.removeRequest(request, response);
                super.onAuthenticationSuccess(request, response, authentication);
            }
        }
    }

    public void setRequestCache(RequestCache requestCache) {
        this.requestCache = requestCache;
    }
}

查看上述的(1)過程實現源碼:

public class HttpSessionRequestCache implements RequestCache {
    //...
    private String sessionAttrName;
    //...
    
    public HttpSessionRequestCache() {
        this.requestMatcher = AnyRequestMatcher.INSTANCE;
        this.sessionAttrName = "SPRING_SECURITY_SAVED_REQUEST";
    }
    //...
    public SavedRequest getRequest(HttpServletRequest currentRequest, HttpServletResponse response) {
        HttpSession session = currentRequest.getSession(false);
        // 當未登錄用戶訪問需要認證才能訪問的路徑時,會自動跳轉登錄頁面,要求用戶登錄認證,
        // 並在 Session 中會使用名為 SPRING_SECURITY_SAVED_REQUEST 的屬性存儲該原始訪問路徑;
        
        // 當用戶認證成功后,從 Session 中取出該屬性值,客戶端重定向到原始訪問路徑,
        // 並且認證成功的后續處理會將該屬性從 Session 中移除
        return session != null ? (SavedRequest)session.getAttribute(this.sessionAttrName) : null;
    }
    //...
}

failureUrl() 方法的處理器

public final T failureUrl(String authenticationFailureUrl) {
    // 該 Handler 就是 failureUrl() 方法使用的認證失敗處理器
    T result = this.failureHandler(new SimpleUrlAuthenticationFailureHandler(authenticationFailureUrl));
    this.failureUrl = authenticationFailureUrl;
    return result;
}

查看SimpleUrlAuthenticationFailureHandler處理器:

public class SimpleUrlAuthenticationFailureHandler implements AuthenticationFailureHandler {
    //...
    private boolean forwardToDestination = false;
    private RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();
    //...

    public SimpleUrlAuthenticationFailureHandler(String defaultFailureUrl) {
        // 設置默認重定向的地址,用戶認證失敗后會重定向到該路徑
        this.setDefaultFailureUrl(defaultFailureUrl);
    }
    
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
        if (this.defaultFailureUrl == null) {
            this.logger.debug("No failure URL set, sending 401 Unauthorized error");
            response.sendError(HttpStatus.UNAUTHORIZED.value(), HttpStatus.UNAUTHORIZED.getReasonPhrase());
        } else {
            //(1) 將認證錯誤信息存儲在 Session 中,屬性名為 SPRING_SECURITY_LAST_EXCEPTION,可用於頁面錯誤信息顯示
            this.saveException(request, exception);
            if (this.forwardToDestination) {
                //(2) forwardToDestination 默認值為 false,不使用轉發
                this.logger.debug("Forwarding to " + this.defaultFailureUrl);
                request.getRequestDispatcher(this.defaultFailureUrl).forward(request, response);
            } else {
                this.logger.debug("Redirecting to " + this.defaultFailureUrl);
                //(3) 重定向到 defaultFailureUrl 
                this.redirectStrategy.sendRedirect(request, response, this.defaultFailureUrl);
            }
        }
    }

    protected final void saveException(HttpServletRequest request, AuthenticationException exception) {
        if (this.forwardToDestination) {
            // forwardToDestination 默認值為 false,不使用轉發
            // 轉發,認證錯誤信息保存在 request 域中,屬性名為 SPRING_SECURITY_LAST_EXCEPTION
            request.setAttribute("SPRING_SECURITY_LAST_EXCEPTION", exception);
        } else {
            // 重定向,認證錯誤信息保存在 session 域中,屬性名為 SPRING_SECURITY_LAST_EXCEPTION
            HttpSession session  = request.getSession(false);
            if (session != null || this.allowSessionCreation) {
                request.getSession().setAttribute("SPRING_SECURITY_LAST_EXCEPTION", exception);
            }
        }
    }    
    //...
}

自定義處理器

在自定義認證成功和失敗處理器時,不用完全自己實現,在Spring Security內置的認證成功和失敗處理器基礎上進行功能擴充即可。

☕️ 定義統一返回的 JSON 結構

package com.example.entity;

import lombok.Getter;

@Getter
public class ResultData<T> {
    private T data;
    private int code;
    private String msg;

    /**
     * 若沒有數據返回,默認狀態碼為0,提示信息為:操作成功!
     */
    public ResultData() {
        this.code = 0;
        this.msg = "發布成功!";
    }

    /**
     * 若沒有數據返回,可以人為指定狀態碼和提示信息
     */
    public ResultData(int code, String msg) {
        this.code = code;
        this.msg = msg;
    }

    /**
     * 有數據返回時,狀態碼為0,默認提示信息為:操作成功!
     */
    public ResultData(T data) {
        this.data = data;
        this.code = 0;
        this.msg = "發布成功!";
    }

    /**
     * 有數據返回,狀態碼為0,人為指定提示信息
     */
    public ResultData(T data, String msg) {
        this.data = data;
        this.code = 0;
        this.msg = msg;
    }
}

☕️ 自定義 jackson 配置

package com.example.config;

import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.module.SimpleModule;
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
import java.text.SimpleDateFormat;

@Configuration
public class JacksonConfig {

    @Bean
    @Primary
    public ObjectMapper jacksonObjectMapper(Jackson2ObjectMapperBuilder builder) {
        ObjectMapper objectMapper = builder.createXmlMapper(false).build();
        // 設置日期轉換
        objectMapper.setDateFormat(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));
        // 設置時區
        // objectMapper.setTimeZone(TimeZone.getTimeZone("GMT+8"));

        // 序列化時,值為 null 的屬性不序列化
        // Include.Include.ALWAYS 默認
        // Include.NON_DEFAULT 屬性為默認值不序列化
        // Include.NON_EMPTY 屬性為空("" 或 null)都不序列化
        // Include.NON_NULL 屬性為 null 不序列化
        objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
        // 反序列化時,遇到未知屬性的時候不拋出異常
        objectMapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);

        // 序列化成 json 時,將 Long 轉換成 String(防止 js 丟失精度)
        // Java 的 Long 能表示的范圍比 js 中 number 大,意味着部分數值在 js 會變成不准確的值
        SimpleModule simpleModule = new SimpleModule();
        simpleModule.addSerializer(Long.class, ToStringSerializer.instance);
        simpleModule.addSerializer(Long.TYPE, ToStringSerializer.instance);
        objectMapper.registerModule(simpleModule);
        return objectMapper;
    }
}

☕️ 自定義認證成功處理器 CustomAuthenticationSuccessHandler

package com.example.config.security;

import com.example.entity.ResultData;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler;
import org.springframework.stereotype.Component;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
 * 繼承 SavedRequestAwareAuthenticationSuccessHandler 類,該類是 defaultSuccessUrl() 方法使用的認證成功處理器
 */
@Component
public class CustomAuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler {

    @Autowired
    private ObjectMapper objectMapper;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws ServletException, IOException {
        String xRequestedWith = request.getHeader("x-requested-with");
        // 判斷前端的請求是否為 ajax 請求
        if ("XMLHttpRequest".equals(xRequestedWith)) {
            // 認證成功,響應 JSON 數據
            response.setContentType("application/json;charset=utf-8");
            response.getWriter().write(objectMapper.writeValueAsString(new ResultData<>(0, "認證成功!")));
        }else {
            // 以下配置等同於前文中的 defaultSuccessUrl("/index")
            
            // 認證成功后,如果存在原始訪問路徑,則重定向到該路徑;如果沒有,則重定向 /index
            // 設置默認的重定的路徑
            super.setDefaultTargetUrl("/index");
            // 調用父類的 onAuthenticationSuccess() 方法
            super.onAuthenticationSuccess(request, response, authentication);
        }
    }
}

☕️ 自定義認證失敗處理器 CustomAuthenticationFailureHandler

package com.example.config.security;

import com.example.entity.ResultData;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler;
import org.springframework.stereotype.Component;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
 * 繼承 SimpleUrlAuthenticationFailureHandler 處理器,該類是 failureUrl() 方法使用的認證失敗處理器
 */
@Component
public class CustomAuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {

    @Autowired
    private ObjectMapper objectMapper;

    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException {
        String xRequestedWith = request.getHeader("x-requested-with");
        // 判斷前端的請求是否為 ajax 請求
        if ("XMLHttpRequest".equals(xRequestedWith)) {
            // 認證失敗,響應 JSON 數據
            response.setContentType("application/json;charset=utf-8");
            response.getWriter().write(objectMapper.writeValueAsString(new ResultData<>(1, "認證失敗!")));
        }else {
            // 以下配置等同於前文的 failureUrl("/login/page?error")
            
            // 認證失敗后,重定向到指定地址
            // 設置默認的重定向路徑
            super.setDefaultFailureUrl("/login/page?error");
            // 調用父類的 onAuthenticationFailure() 方法
            super.onAuthenticationFailure(request, response, e);
        }
    }
}

☕️ 修改安全配置類 SpringSecurityConfig,使用自定義認證成功和失敗處理器

@EnableWebSecurity       // 開啟 MVC Security 安全配置
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {
    //...
    @Autowired
    private CustomAuthenticationSuccessHandler authenticationSuccessHandler;

    @Autowired
    private CustomAuthenticationFailureHandler authenticationFailureHandler;
    //...
    
    /**
     * 定制基於 HTTP 請求的用戶訪問控制
     *
     * @param http
     * @throws Exception
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // 啟動 form 表單登錄
        http.formLogin()
                // 設置登錄頁面的訪問路徑,默認為 /login,GET 請求;該路徑不設限訪問
                .loginPage("/login/page")
                // 設置登錄表單提交路徑,默認為 loginPage() 設置的路徑,POST 請求
                .loginProcessingUrl("/login/form")
                // 設置登錄表單中的用戶名參數,默認為 username
                .usernameParameter("name")
                // 設置登錄表單中的密碼參數,默認為 password
                .passwordParameter("pwd")
                // 認證成功處理,如果存在原始訪問路徑,則重定向到該路徑;如果沒有,則重定向 /index
                //.defaultSuccessUrl("/index")
                // 認證失敗處理,重定向到指定地址,默認為 loginPage() + ?error;該路徑不設限訪問
                //.failureUrl("/login/page?error");
                // 不再使用 defaultSuccessUrl() 和 failureUrl() 方法進行認證成功和失敗處理,
                // 使用自定義的認證成功和失敗處理器
                .successHandler(authenticationSuccessHandler)
                .failureHandler(authenticationFailureHandler);
        //...
    }
}

完整的安全配置類 SpringSecurityConfig 如下:

package com.example.config;

import com.example.config.security.CustomAuthenticationFailureHandler;
import com.example.config.security.CustomAuthenticationSuccessHandler;
import com.example.service.CustomUserDetailsService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
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.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;

@EnableWebSecurity       // 開啟 MVC Security 安全配置
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private CustomUserDetailsService userDetailsService;

    @Autowired
    private CustomAuthenticationSuccessHandler authenticationSuccessHandler;

    @Autowired
    private CustomAuthenticationFailureHandler authenticationFailureHandler;

    /**
     * 密碼編碼器,密碼不能明文存儲
     */
    @Bean
    public BCryptPasswordEncoder passwordEncoder() {
        // 使用 BCryptPasswordEncoder 密碼編碼器,該編碼器會將隨機產生的 salt 混入最終生成的密文中
        return new BCryptPasswordEncoder();
    }

    /**
     * 定制用戶認證管理器來實現用戶認證
     */
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        // 采用內存存儲方式,用戶認證信息存儲在內存中
        // auth.inMemoryAuthentication()
        //        .withUser("admin").password(passwordEncoder()
        //        .encode("123456")).roles("ROLE_ADMIN");
        
        // 不再使用內存方式存儲用戶認證信息,而是動態從數據庫中獲取
        auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
    }

    /**
     * 定制基於 HTTP 請求的用戶訪問控制
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // 啟動 form 表單登錄
        http.formLogin()
                // 設置登錄頁面的訪問路徑,默認為 /login,GET 請求;該路徑不設限訪問
                .loginPage("/login/page")
                // 設置登錄表單提交路徑,默認為 loginPage() 設置的路徑,POST 請求
                .loginProcessingUrl("/login/form")
                // 設置登錄表單中的用戶名參數,默認為 username
                .usernameParameter("name")
                // 設置登錄表單中的密碼參數,默認為 password
                .passwordParameter("pwd")
                // 認證成功處理,如果存在原始訪問路徑,則重定向到該路徑;如果沒有,則重定向 /index
                //.defaultSuccessUrl("/index")
                // 認證失敗處理,重定向到指定地址,默認為 loginPage() + ?error;該路徑不設限訪問
                //.failureUrl("/login/page?error");
                // 不再使用 defaultSuccessUrl() 和 failureUrl() 方法進行認證成功和失敗處理,
                // 使用自定義的認證成功和失敗處理器
                .successHandler(authenticationSuccessHandler)
                .failureHandler(authenticationFailureHandler);

        // 開啟基於 HTTP 請求訪問控制
        http.authorizeRequests()
                // 以下訪問不需要任何權限,任何人都可以訪問
                .antMatchers("/login/page").permitAll()
                // 以下訪問需要 ROLE_ADMIN 權限
                .antMatchers("/admin/**").hasRole("ADMIN")
                // 以下訪問需要 ROLE_USER 權限
                .antMatchers("/user/**").hasAuthority("ROLE_USER")
                // 其它任何請求訪問都需要先通過認證
                .anyRequest().authenticated();

        // 關閉 csrf 防護
        http.csrf().disable();
    }

    /**
     * 定制一些全局性的安全配置,例如:不攔截靜態資源的訪問
     */
    @Override
    public void configure(WebSecurity web) throws Exception {
        // 靜態資源的訪問不需要攔截,直接放行
        web.ignoring().antMatchers("/**/*.css", "/**/*.js", "/**/*.png", "/**/*.jpg", "/**/*.jpeg", "/**/*.ico");
    }
}

☕️ 測試

ajax 請求

使用 postman 模擬 ajax 請求進行測試:

返回的 JSON 數據如下:

{
    "code": 0,
    "msg": "認證成功!"
}

表單請求

訪問localhost:8080/login/page,輸入錯誤的用戶名和密碼,重定向到/login/page?error

這里的錯誤信息就是前面源碼分析中提到的存儲在 Session 中認證錯誤信息,用戶認證失敗后,重定向到登錄頁面,從 Session 域中獲取認證錯誤信息並在頁面展示:

<div th:if="${param.error}">
    <span th:text="${session.SPRING_SECURITY_LAST_EXCEPTION.message}" style="color:red">用戶名或密碼錯誤</span>
</div>

Spring Security默認加載 message.properties 英文配置文件,所以顯示的是英文錯誤提示信息。我們可以自定義配置類讓Spring Security加載官方提供的 message_zh_CN.properties 中文配置文件:

package com.example.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.support.ReloadableResourceBundleMessageSource;

/**
 * 加載中文認證信息提示配置
 */
@Configuration
public class ReloadZhMessageConfig {
    @Bean
    public ReloadableResourceBundleMessageSource messageSource() {
        // 加載中文的認證提示信息
        ReloadableResourceBundleMessageSource messageSource = new ReloadableResourceBundleMessageSource();
        // 不需要添加 .properties 后綴
        messageSource.setBasename("classpath:org/springframework/security/messages_zh_CN");
        return messageSource;
    }
}

再次訪問localhost:8080/login/page,輸入錯誤的用戶名和密碼,重定向到/login/page?error


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM