Spring Security 介面認證鑒權入門實踐指南

前言

Web API 介面服務場景里,用戶的認證和鑒權是很常見的需求,Spring Security 據說是這個領域裡事實上的標準,實踐下來整體涉及上確實有不少可圈可點之處,也在一定程度上印證了小伙們經常提到的 「太複雜了」 的說法也是很有道理的。

本文以一個簡單的 SpringBoot Web 應用為例,重點介紹以下內容:

  • 演示 Spring Security 介面認證和鑒權的配置方法;
  • 以記憶體和資料庫為例,介紹認證和鑒權數據的存儲和讀取機制;
  • 若干模組的自定義實現,包括:認證過濾器、認證或鑒權失敗處理器等。

SpringBoot 示例

創建 SpringBoot 示例,用於演示 Spring Security 在 SpringBoot 環境下的應用,簡要介紹四部分內容:pom.xml、application.yml、IndexController 和 HelloController。

SpringBoot pom.xml

  ...
  <artifactId>boot-example</artifactId>
  
  <dependencies>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-logging</artifactId>
    </dependency>
  </dependencies>

boot-example 是用於演示的 SpringBoot 項目子模組(Module)。

註: 依賴項的版本已在項目 pom.xml dependencyManagement 中聲明。

SpringBoot application.yml

spring:
  application:
    name: example

server:
  port: 9999

logging:
  level:
    root: info

SpringBoot 應用名稱為 example,實例埠為 9999

SpringBoot IndexController

@RestController
@RequestMapping("/")
public class IndexController {
  @GetMapping
  public String index() {
    return "index";
  }
}

IndexController 實現一個介面:/。

SpringBoot HelloController

@RestController
@RequestMapping("/hello")
public class HelloController {
  @GetMapping("/world")
  public String world() {
    return "hello world";
  }

  @GetMapping("/name")
  public String name() {
    return "hello name";
  }
}

HelloController 實現兩個介面:/hello/world 和 /hello/name。

編譯啟動 SpringBoot 應用,通過瀏覽器請求介面,請求路徑和響應結果:

//localhost:9999
index

//localhost:9999/hello/world
hello world

//localhost:9999/hello/name
hello name

SpringBoot 示例準備完成。

SpringBoot 集成 Spring Security

SpringBoot 集成 Spring Security 僅需要在 pom.xml 中添加相應的依賴:spring-boot-starter-security,如下:

  <dependencies>
    ...

    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
  </dependencies>

編譯啟動應用,相對於普通的 SpringBoot 應用,我們可以在命令行終端看到特別的兩行日誌:

2022-01-09 16:05:57.437  INFO 87581 --- [           main] .s.s.UserDetailsServiceAutoConfiguration :

Using generated security password: 3ef27867-e938-4fa4-b5da-5015f0deab7b

2022-01-09 16:05:57.525  INFO 87581 --- [           main] o.s.s.web.DefaultSecurityFilterChain     : Will secure any request with [org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter@11e355ca, org.springframework.security.web.context.SecurityContextPersistenceFilter@5114b7c7, org.springframework.security.web.header.HeaderWriterFilter@24534cb0, org.springframework.security.web.csrf.CsrfFilter@77c233af, org.springframework.security.web.authentication.logout.LogoutFilter@5853ca50, org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter@6d074b14, org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter@3206174f, org.springframework.security.web.authentication.ui.DefaultLogoutPageGeneratingFilter@70d63e05, org.springframework.security.web.authentication.www.BasicAuthenticationFilter@5115f590, org.springframework.security.web.savedrequest.RequestCacheAwareFilter@767f6ee7, org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter@7b6c6e70, org.springframework.security.web.authentication.AnonymousAuthenticationFilter@e11ecfa, org.springframework.security.web.session.SessionManagementFilter@106d77da, org.springframework.security.web.access.ExceptionTranslationFilter@7b66322e, org.springframework.security.web.access.intercept.FilterSecurityInterceptor@3e5fd2b1]

表示 Spring Security 已在 SpringBoot 應用中生效。默認情況下,Spring Security 自動化地幫助我們完成以下三件事件:

  1. 開啟 FormLogin 登錄認證模式;

    我們使用瀏覽器請求介面 /:

    //localhost:9999/
    

    會發現請求會被重定向至頁面 /login:

    //localhost:9999/login
    

    提示使用用戶名和密碼登錄:

  2. 生成用於登錄的用戶名和密碼;

    用戶名為 user,密碼會輸出到應用的啟動日誌:

    Using generated security password: 3ef27867-e938-4fa4-b5da-5015f0deab7b
    

    每一次應用啟動,密碼都會重新隨機生成。

  3. 註冊用於認證和鑒權的過濾器;

    Spring Security 本質就是通過 過濾器過濾器(鏈) 實現的,每一個介面請求都會按順序經過這些過濾器的「過濾」,每個過濾器承擔的各自的職責,組合起來共同完成認證和鑒權。
    根據配置的不同,註冊的過濾器也會有所不同,默認情況下,載入的過濾器列表可以參考啟動日誌:

    WebAsyncManagerIntegrationFilter
    SecurityContextPersistenceFilter
    HeaderWriterFilter
    CsrfFilter
    LogoutFilter
    UsernamePasswordAuthenticationFilter
    DefaultLoginPageGeneratingFilter
    DefaultLogoutPageGeneratingFilter
    BasicAuthenticationFilter
    RequestCacheAwareFilter
    SecurityContextHolderAwareRequestFilter
    AnonymousAuthenticationFilter
    SessionManagementFilter
    ExceptionTranslationFilter
    FilterSecurityInterceptor
    

使用 Spring Security 默認為我們生成的用戶名和密碼進行登錄(Sign in),成功之後會自動重定向至 / :

index

之後我們就可以通過瀏覽器正常請求 /hello/world 和 /hello/name。

默認情況下,Spring Security 僅支援基於 FormLogin 方式的認證,只能使用固定的用戶名和隨機生成的密碼,且不支援鑒權。如果想要使用更豐富的安全特性:

  • 其他認證方式,如:HttpBasic
  • 自定義用戶名和密碼
  • 鑒權
    則需要我們自定義配置 Spring Security。自定義配置可以通過兩種方式實現:
  • Java Configuration:使用 Java 程式碼的方式配置
  • Security NameSpace Configuration:使用 XML 文件的方式配置

本文以 Java Configuration 的方式為例進行介紹,需要我們提供一個繼承自 WebSecurityConfigurerAdapter 配置類,然後通過重寫若干方法進而實現自定義配置。

import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
  @Override
  protected void configure(HttpSecurity http) throws Exception {
    
  }
}

SecurityConfig 使用 @Configuration 註解(配置類),繼承自 WebSecurityConfigurerAdapter,本文通過重寫 configure 方法實現自定義配置。

需要注意:WebSecurityConfigurerAdapter 中有多個名稱為 configure 的重載方法,這裡使用的是參數類型為 HttpSecurity 的方法。

註: Spring Security 默認自動化配置參考 Spring Boot Auto Configuration

Spring Security 使用 HttpBasic 認證

  protected void configure(HttpSecurity http) throws Exception {
    http
            .authorizeHttpRequests(authorize ->
                    authorize
                            .anyRequest()
                            .authenticated())
            .httpBasic();
  }

http.authorizeHttpRequests()

用以指定哪些請求需要什麼樣的認證或授權,這裡使用 anyRequest()authenticated() 表示所有的請求均需要認證。

http.authorizeHttpRequests()

表示我們使用 HttpBasic 認證。

編譯啟動應用,會發現終端仍會輸出密碼:

Using generated security password: e2c77467-8c46-4fe1-ab32-eb87558b8c0e

因為,我們僅僅改變的是認證方式。

為方便演示,我們使用 CURL 直接請求介面:

curl //localhost:9999

{
	"timestamp": "2022-01-10T02:47:20.820+00:00",
	"status": 401,
	"error": "Unauthorized",
	"path": "/"
}

會提示我們 Unauthorized,即:沒有認證。

我們按照 HttpBasic 要求添加請求頭部參數 Authorization,它的值:

Basic Base64(user:e2c77467-8c46-4fe1-ab32-eb87558b8c0e)

即:

Basic dXNlcjplMmM3NzQ2Ny04YzQ2LTRmZTEtYWIzMi1lYjg3NTU4YjhjMGU=

再次請求介面:

curl -H "Authorization: Basic dXNlcjplMmM3NzQ2Ny04YzQ2LTRmZTEtYWIzMi1lYjg3NTU4YjhjMGU=" //localhost:9999

index

認證成功,介面正常響應。

Spring Security 自定義用戶名和密碼

使用默認用戶名和隨機密碼的方式不夠靈活,大部分場景都需要我們支援多個用戶,且分別為他們設置相應的密碼,這就涉及到兩個問題:

  • 用戶名和密碼如何讀取(查詢)
  • 用戶名和密碼如何存儲(增加/刪除/修改)

對於 讀取,Spring Security 設計了 UserDetailsService 介面:

public interface UserDetailsService {
    UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}

loadUserByUsername

實現按照用戶名(username)從某個存儲介質中載入相對應的用戶資訊(UserDetails)。

username

用戶名,客戶端發送請求時寫入的用於用戶名。

UserDetails

用戶資訊,包括用戶名、密碼、許可權等相關資訊。

注意:用戶資訊不只用戶名和用戶密碼。

對於 存儲,Spring Security 設計了 UserDetailsManager 介面:

public interface UserDetailsManager extends UserDetailsService {
    void createUser(UserDetails user);

    void updateUser(UserDetails user);

    void deleteUser(String username);

    void changePassword(String oldPassword, String newPassword);

    boolean userExists(String username);
}

createUser

創建用戶資訊

updateUser

修改用戶資訊

deleteUser

刪除用戶資訊

changePassword

修改當前用戶的密碼

userExists

檢查用戶是否存在

注意UserDetailsManager 繼承自 UserDetailsService

也就是說,我們可以通過提供一個已實現介面 UserDetailsManager* 的類,並重寫其中的若干方法,基於某種存儲介質,定義用戶名、密碼等資訊的存儲和讀取邏輯;然後將這個類的實例以 Bean 的形式注入 Spring Security,就可以實現用戶名和密碼的自定義。

實際上,Spring Security 僅關心如何 讀取存儲 可以由業務系統自行實現;相當於,只實現介面 UserDetailsService 即可。

Spring Security 已經為我們預置了兩種常見的存儲介質實現:

InMemoryUserDetailsManagerJdbcUserDetailsManager 均實現介面 UserDetailsManager,本質就是對於 UserDetailsCRUD。我們先介紹 UserDetails,然後再分別介紹基於記憶體和資料庫的實現。

UserDetails

UserDetails 是用戶資訊的抽象介面:

public interface UserDetails extends Serializable {
    Collection<? extends GrantedAuthority> getAuthorities();

    String getPassword();

    String getUsername();

    boolean isAccountNonExpired();

    boolean isAccountNonLocked();

    boolean isCredentialsNonExpired();

    boolean isEnabled();

}

getUsername

獲取用戶名。

getPassword

獲取密碼。

getAuthorities

獲取許可權,可以簡單理解為角色名稱(字元串),用於實現介面基於角色的授權訪問,詳情見後文。

其他

獲取用戶是否可用,或用戶/密碼是否過期或鎖定。

Spring Security 提供了一個 UserDetails 的實現類 User,用於用戶資訊的實例表示。另外,User 提供 Builder 模式的對象構建方式。

UserDetails user = User.builder()
    .username("user")
    .password("{bcrypt}$2a$10$GRLdNijSQMUvl/au9ofL.eDwmoohzzS7.rmNSJZ.0FxO/BTk76klW")
    .roles("USER")
    .build();

username

設置用戶名稱。

password

設置密碼,Spring Security 不建議使用明文字元串存儲密碼,密碼格式:

{id}encodedPassword

其中,id 為加密演算法標識,encodedPassword 為密碼加密後的字元串。這裡以加密演算法 bcrypt 為例,詳細內容可參考 Password Storage

roles

設置角色,支援多個。

UserDetails 實例創建完成之後,就可以使用 UserDetailsManager 的具體實現進行存儲和讀取。

In Memory

InMemoryUserDetailsManager 是 Spring Security 為我們提供的基於記憶體實現的 UserDetailsManager

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
  ...

  @Bean
  public UserDetailsManager users() {
    UserDetails user = User.builder()
            .username("userA")
            .password("{bcrypt}$2a$10$CrPsv1X3hM" +
                    ".giwVZyNsrKuaRvpJZyGQycJg78xT7Dm68K4DWN/lxS")
            .roles("USER")
            .build();

    InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager();
    manager.createUser(user);

    return manager;
  }
}
  1. 創建用戶資訊實例 user,用戶名為 userA,密碼為 123456(使用 Bcrypt 演算法加密);認證並需要角色參與,但 roles 必須被設置,這裡指定為 USER;
  2. 創建 InMemoryUserDetailsManager 實例 manager;
  3. 使用 createUser 方法 將 user 存儲至 manager;相當於把用戶資訊存儲至記憶體介質中;
  4. 返回 manager;

使用 @BeanInMemoryUserDetailsManager 實例注入 Spring Security。

創建 InMemoryUserDetailsManager 實例之後,並不是必須立即調用 createUser 添加用戶資訊,也可以在業務系統的其它地方獲取已注入的 InMemoryUserDetailsManager 動態存儲 UserDetails 實例。

編譯啟動應用,使用我們自己創建的用戶名和密碼(userA/123456)訪問介面:

curl -H "Authorization: Basic dXNlckE6MTIzNDU2" //localhost:9999

index

基於記憶體介質自定義的用戶名和密碼已生效,介面正常響應。

JDBC

JdbcUserDetailsManager 是 Spring Security 為我們提供的基於資料庫實現的 UserDetailsManager,相較於 InMemoryUserDetailsManager 使用略複雜,需要我們創建數據表,並準備好資料庫連接需要的數據源(DataSource), JdbcUserDetailsManager 實例的創建依賴於數據源。

JdbcUserDetailsManager 可以與業務系統共用一個資料庫數據源實例,本文不討論數據源的相關配置。

MySQL 為例,創建數據表語句:

create table users(
    username varchar(50) not null primary key,
    password varchar(500) not null,
    enabled boolean not null
);

create table authorities (
    username varchar(50) not null,
    authority varchar(50) not null,
    constraint fk_authorities_users foreign key(username) references users(username)
);

create unique index ix_auth_username on authorities (username,authority);

其他資料庫語句可參考 User Schema

JdbcUserDetailsManager 實例的創建與注入,除

  • 獲取已注入的數據源實例 dataSource;
  • 創建實例時需要傳入數據源實例 dataSource;

之外,整體流程與 InMemoryUserDetailsManager 類似,不再贅述。

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
  ......

  @Autowired
  private DataSource dataSource;

  @Bean
  public UserDetailsManager users() {
    UserDetails user = User.builder()
            .username("user")
            .password("{bcrypt}$2a$10$CrPsv1X3hM" +
                    ".giwVZyNsrKuaRvpJZyGQycJg78xT7Dm68K4DWN/lxS")
            .roles("USER")
            .build();

    JdbcUserDetailsManager manager = new JdbcUserDetailsManager(dataSource);
    manager.createUser(user);

    return manager;
  }
}

在業務系統中獲取已注入的 JdbcUserDetailsManager 實例,可以動態存儲 UserDetails 實例。

編譯啟動應用,使用我們自己創建的用戶名和密碼(userA/123456)訪問介面:

curl -H "Authorization: Basic dXNlckE6MTIzNDU2" //localhost:9999

index

基於資料庫介質自定義的用戶名和密碼已生效,介面正常響應。

Spring Security 鑒權

Spring Security 可以提供基於角色的許可權控制:

  • 不同的用戶可以屬於不同的角色
  • 不同的角色可以訪問不同的介面

假設,存在兩個角色 USER(普通用戶) 和 ADMIN(管理員),

角色 USER 可以訪問介面 /hello/name,
角色 ADMIN 可以訪問介面 /hello/world,
所有用戶認證後可以訪問介面 /。

我們需要按上述需求重新設置 HttpSecurity

  protected void configure(HttpSecurity http) throws Exception {
    http
            .authorizeHttpRequests(authorize ->
                    authorize
                            .mvcMatchers("/hello/name").hasRole("USER")
                            .mvcMatchers("/hello/world").hasRole("ADMIN")
                            .anyRequest().authenticated())
            .httpBasic();
  }

mvcMatchers(“/hello/name”).hasRole(“USER”)

設置角色 USER 可以訪問介面 /hello/name。

mvcMatchers(“/hello/world”).hasRole(“ADMIN”)

設置角色 ADMIN 可以訪問介面 /hello/world。

anyRequest().authenticated()

設置其他介面認證後即可訪問。

mvcMatchers 支援使用通配符。

創建屬於角色 USER 和 ADMIN 的用戶:

用戶名:userA,密碼:123456,角色:USER
用戶名:userB,密碼:abcdef,角色:ADMIN

  @Bean
  public UserDetailsManager users() {
    UserDetails userA = User.builder()
            .username("userA")
            .password("{bcrypt}$2a$10$CrPsv1X3hM.giwVZyNsrKuaRvpJZyGQycJg78xT7Dm68K4DWN/lxS")
            .roles("USER")
            .build();

    UserDetails userB = User.builder()
            .username("userB")
            .password("{bcrypt}$2a$10$PES8fUdtRrQ9OxLqf4CofOfcXBLQ3lkY2TSIcs1E9A0z2wECmZigG")
            .roles("ADMIN")
            .build();

    JdbcUserDetailsManager manager = new JdbcUserDetailsManager(dataSource);

    manager.createUser(userA);
    manager.createUser(userB);

    return manager;
  }

對於用戶 userA

使用用戶 userA 的用戶名和密碼訪問介面 /:

curl -H "Authorization: Basic dXNlckE6MTIzNDU2" //localhost:9999

index

認證通過,可正常訪問。

使用用戶 userA 的用戶名和密碼訪問介面 /hello/name:

curl -H "Authorization: Basic dXNlckE6MTIzNDU2" //localhost:9999/hello/name

hello name

認證通過,鑒權通過,可正常訪問。

使用用戶 userA 的用戶名和密碼訪問介面 /hello/world:

curl -H "Authorization: Basic dXNlckE6MTIzNDU2" //localhost:9999/hello/world

{
	"timestamp": "2022-01-10T13:11:18.032+00:00",
	"status": 403,
	"error": "Forbidden",
	"path": "/hello/world"
}

認證通過,用戶 userA 不屬於角色 ADMIN,禁止訪問。

使用用戶 userA 的用戶名和密碼訪問介面 /:

curl -H "Authorization: Basic dXNlckE6MTIzNDU2" //localhost:9999

index

認證通過,可正常訪問。

對於用戶 userB

使用用戶 userB 的用戶名和密碼訪問介面 /:

curl -H "Authorization: Basic dXNlckI6YWJjZGVm" //localhost:9999

index

認證通過,可正常訪問。

使用用戶 userB 的用戶名和密碼訪問介面 /hello/world:

curl -H "Authorization: Basic dXNlckI6YWJjZGVm" //localhost:9999/hello/world

hello world

認證通過,鑒權通過,可正常訪問。

使用用戶 userB 的用戶名和密碼訪問介面 /hello/name:

curl -H "Authorization: Basic dXNlckI6YWJjZGVm" //localhost:9999/hello/name

{
	"timestamp": "2022-01-10T13:18:29.461+00:00",
	"status": 403,
	"error": "Forbidden",
	"path": "/hello/name"
}

認證通過,用戶 userB 不屬於角色 USER,禁止訪問。

這裡可能會有一點奇怪,一般情況下我們會認為 管理員 應該擁有 普通用戶 的全部許可權,即普通用戶 可以訪問介面 /hello/name,那麼 管理員 應該也是可以訪問介面 /hello/name 的。如何實現呢?

方式一,設置用戶 userB 同時擁有角色 USER 和 ADMIN;

    UserDetails userB = User.builder()
            .username("userB")
            .password("{bcrypt}$2a$10$PES8fUdtRrQ9OxLqf4CofOfcXBLQ3lkY2TSIcs1E9A0z2wECmZigG")
            .roles("USER", "ADMIN")
            .build();

這種方式有點不夠「優雅」。

方式二,設置角色 ADMIN 包含 USER;

Spring Security 有一個 Hierarchical Roles 的特性,可以支援角色之間的 包含 操作。

使用這個特性要特別注意兩個地方:

  1. authorizeRequests
  @Override
  protected void configure(HttpSecurity http) throws Exception {
    http
            .authorizeRequests(authorize ->
                    authorize
                            .mvcMatchers("/hello/name").hasRole("USER")
                            .mvcMatchers("/hello/world").hasRole("ADMIN")
                            .mvcMatchers("/").authenticated())
            .httpBasic();
  }

前文使用的是 HttpSecurity.authorizeHttpRequests 方法,此處需要變更為 HttpSecurity.authorizeRequests 方法。

  1. RoleHierarchy
  @Bean
  RoleHierarchy hierarchy() {
    RoleHierarchyImpl hierarchy = new RoleHierarchyImpl();
    hierarchy.setHierarchy("ROLE_ADMIN > ROLE_USER");

    return hierarchy;
  }

使用 RoleHierarchy 以 Bean 的方式定義角色之間的 層級關係;其中,「ROLE_」 是 Spring Security 要求的固定前綴。

編譯啟動應用,使用用戶 userB 的用戶名和密碼訪問介面 /hello/name:

curl -H "Authorization: Basic dXNlckI6YWJjZGVm" //localhost:9999/hello/name

hello name

認證通過,鑒權通過,可正常訪問。

如果開啟 Spring Security 的 debug 日誌級別,訪問介面時可以看到如下的日誌輸出:

From the roles [ROLE_ADMIN] one can reach [ROLE_USER, ROLE_ADMIN] in zero or more steps.

可以看出,Spring Security 可以從角色 ADMIN 推導出用戶實際擁有 USER 和 ADMIN 兩個角色。

特別說明

Hierarchical Roles 文檔中的示例有明顯錯誤:

@Bean
AccessDecisionVoter hierarchyVoter() {
    RoleHierarchy hierarchy = new RoleHierarchyImpl();
    hierarchy.setHierarchy("ROLE_ADMIN > ROLE_STAFF\n" +
            "ROLE_STAFF > ROLE_USER\n" +
            "ROLE_USER > ROLE_GUEST");
    return new RoleHierarcyVoter(hierarchy);
}

介面 RoleHierarchy 中並不存在方法 setHierarchy。前文所述 authorizeRequestsRoleHierarchy 結合使用的方法是結合網路搜索和自身實踐得出的,僅供參考。

另外,authorizeHttpRequestsRoleHierarchy 結合是沒有效果的,authorizeRequestsauthorizeHttpRequests 兩者之間的區別可以分別參考 Authorize HttpServletRequests with AuthorizationFilterAuthorize HttpServletRequest with FilterSecurityInterceptor

鑒權的前提需要認證通過;認證不通過的狀態碼為401,鑒權不通過的狀態碼為403,兩者是不同的。

Spring Security 異常處理器

Spring Security 異常主要分為兩種:認證失敗異常和鑒權失敗異常,發生異常時會分別使用相應的默認異常處理器進行處理,即:認證失敗異常處理器和鑒權失敗異常處理器。

使用的認證或鑒權實現機制不同,可能使用的默認異常處理器也不相同。

認證失敗異常處理器

Spring Security 認證失敗異常處理器:

public interface AuthenticationEntryPoint {
  void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException;
}

如前文所述,認證失敗時,Spring Security 使用默認的認證失敗處理器實現返回:

{
	"timestamp": "2022-01-10T02:47:20.820+00:00",
	"status": 401,
	"error": "Unauthorized",
	"path": "/"
}

如果想要自定義返回內容,則可以通過自定義認證失敗處理器實現:

  AuthenticationEntryPoint authenticationEntryPoint() {
    return (request, response, authException) -> response
            .getWriter()
            .print("401");
  }
  
  @Override
  protected void configure(HttpSecurity http) throws Exception {
    http
            ...
            .httpBasic()
            .authenticationEntryPoint(authenticationEntryPoint());
  }

authenticationEntryPoint() 會創建返回一個自定義的 AuthenticationEntryPoint 實例;其中,使用 HttpServletResponse.getWriter().print() 寫入我們想要返回的內容:401。

httpBasic().authenticationEntryPoint(authenticationEntryPoint()) 使用我們自定義的 AuthenticationEntryPoint 替換 HttpBasic 默認的 BasicAuthenticationEntryPoint

編譯啟動應用,使用不正確的用戶名和密碼訪問介面 /:

curl -H "Authorization: Basic error" //localhost:9999

401

認證不通過,使用我們自定義的內容 401 返回。

鑒權失敗異常處理器

Spring Security 鑒權失敗異常處理器:

public interface AccessDeniedHandler {
  void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException;
}

如前文所述,認證失敗時,Spring Security 使用默認的認證失敗處理器實現返回:

{
	"timestamp": "2022-01-10T13:18:29.461+00:00",
	"status": 403,
	"error": "Forbidden",
	"path": "/hello/name"
}

如果想要自定義返回內容,則可以通過自定義鑒權失敗處理器實現:

  AccessDeniedHandler accessDeniedHandler() {
    return (request, response, accessDeniedException) -> response
            .getWriter()
            .print("403");
  }
  
  @Override
  protected void configure(HttpSecurity http) throws Exception {
    http
            ...
            .httpBasic()
            .authenticationEntryPoint(authenticationEntryPoint())
            .and()
            .exceptionHandling()
            .accessDeniedHandler(accessDeniedHandler());
  }

自定義鑒權失敗處理器與認證失敗處理器過程類似,不再贅述。

編譯啟動應用,使用用戶 userA 的用戶名和密碼訪問介面 /hello/world:

curl -H "Authorization: Basic dXNlckE6MTIzNDU2" //localhost:9999/hello/world

403

鑒權不通過,使用我們自定義的內容 403 返回。

特別注意

exceptionHandling() 也是有一個 authenticationEntryPoint() 方法的;對於 HttpBasic 而言,使用 exceptionHandling().authenticationEntryPoint() 設置自定義認證失敗處理器是不生效的,具體原因需要大家自行研究。

Spring Security 自定義認證

前文介紹兩種認證方式:FormLoginHttpBasic,Spring Security 還提供其他若干種認證方式,詳情可參考 Authentication Mechanisms

如果我們想實現自己的認證方式,也是比較簡單的。Spring Security 本質就是 過濾器,我們可以實現自己的認證過濾器,然後加入到 Spring Security 中即可。

  Filter preAuthenticatedFilter() {
    return (servletRequest, servletResponse, filterChain) -> {
      ...
      UserDetails user = User
              .builder()
              .username("xxx")
              .password("xxx")
              .roles("USER")
              .build();

      UsernamePasswordAuthenticationToken token =
              new UsernamePasswordAuthenticationToken(
                      user,
                      user.getPassword(),
                      user.getAuthorities());

      SecurityContext context =
              SecurityContextHolder.createEmptyContext();
      context.setAuthentication(token);

      SecurityContextHolder.setContext(context);

      filterChain.doFilter(servletRequest, servletResponse);
    };
  }

認證過濾器核心實現流程:

  1. 利用 Http 請求(servletRequest)中的資訊完成自定義認證過程(省略),可能的情況:

    • 檢查請求中的用戶名和密碼是否匹配
    • 檢查請求中的 Token 是否有效
    • 其他
      如果認證成功,則繼續下一步;認證失敗,則可以拋出異常,或者跳過後續步驟;
  2. 從 Http 請求中提取 username(用戶名),使用已注入的 UserDetailsService 實例,載入 UserDetails(用戶資訊)(省略);
    簡單起見,模擬創建一個用戶資訊實例 user;因為到這一步時,用戶已是認證成功的,用戶名和密碼可以隨意設置,實際只有角色是必須的,我們設置已認證用戶的角色為 USER

  3. 創建用戶認證標識;
    Spring Security 內部是依靠 Authentication.isAuthenticated() 來判斷用戶是否已認證過的,UsernamePasswordAuthenticationTokenAuthentication 的一種具體實現,需要注意創建實例時使用的構造方法和參數,構造方法內部會調用 Authentication.setAuthenticated(true)

  4. 創建並設置環境上下文 SecurityContext;
    環境上下文中保存著用戶認證標識:context.setAuthentication(token)

特別注意

除去拋出異常的情況外,filterChain.doFilter(servletRequest, servletResponse); 是必須保證被執行的。

理解認證過濾器涉及的概念會比較多,詳情參考 Servlet Authentication Architecture

認證過濾器創建完成之後,就可以加入到 Spring Security 中:

  @Override
  protected void configure(HttpSecurity http) throws Exception {
    http
            ......
            .addFilterBefore(preAuthenticatedFilter(),
                    ExceptionTranslationFilter.class)
            .exceptionHandling()
            .authenticationEntryPoint(authenticationEntryPoint())
            .accessDeniedHandler(accessDeniedHandler());
  }

Spring Security 根據我們配置的不同,會為我們自動按照一定的次序組裝一條 過濾器鏈,通過這條鏈上的若干過濾器完成認證鑒權的。我們需要把自定義的認證過濾器加到這個鏈的合適位置,這是選取的位置是在 ExceptionTranslationFilter 的前面。

過濾器鏈的順序可以參考 Security Filters
ExceptionTranslationFilter 的作用可以參考 Handling Security Exceptions

特別注意

使用自定義認證過濾器時,自定義認證失敗異常處理器和鑒權失敗異常處理器的設置方法。

編譯啟動應用,我們會發現可以在不填入任何認證資訊的情況下直接訪問介面 / 和 /hello/name,因為模擬用戶已認證且角色為 USER;訪問介面 /hello/world 時會出現提示 403。

結語

Spring Security 自身包含的內容很多,官方文檔也不能很好的講述清楚每個功能特性的使用方法,很多時候需要我們自己根據文檔、示例、源碼以及他人的分享,儘可能多的實踐,逐步加深理解。