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 自動化地幫助我們完成以下三件事件:
-
開啟 FormLogin 登錄認證模式;
我們使用瀏覽器請求介面 /:
//localhost:9999/
會發現請求會被重定向至頁面 /login:
//localhost:9999/login
提示使用用戶名和密碼登錄:
-
生成用於登錄的用戶名和密碼;
用戶名為 user,密碼會輸出到應用的啟動日誌:
Using generated security password: 3ef27867-e938-4fa4-b5da-5015f0deab7b
每一次應用啟動,密碼都會重新隨機生成。
-
註冊用於認證和鑒權的過濾器;
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 已經為我們預置了兩種常見的存儲介質實現:
- InMemoryUserDetailsManager,基於記憶體的實現
- JdbcUserDetailsManager,基於資料庫的實現
InMemoryUserDetailsManager 和 JdbcUserDetailsManager 均實現介面 UserDetailsManager,本質就是對於 UserDetails 的 CRUD。我們先介紹 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;
}
}
- 創建用戶資訊實例 user,用戶名為 userA,密碼為 123456(使用 Bcrypt 演算法加密);認證並需要角色參與,但 roles 必須被設置,這裡指定為 USER;
- 創建 InMemoryUserDetailsManager 實例 manager;
- 使用 createUser 方法 將 user 存儲至 manager;相當於把用戶資訊存儲至記憶體介質中;
- 返回 manager;
使用 @Bean 將 InMemoryUserDetailsManager 實例注入 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 的特性,可以支援角色之間的 包含 操作。
使用這個特性要特別注意兩個地方:
- 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 方法。
- 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。前文所述 authorizeRequests 和 RoleHierarchy 結合使用的方法是結合網路搜索和自身實踐得出的,僅供參考。
另外,authorizeHttpRequests 和 RoleHierarchy 結合是沒有效果的,authorizeRequests 和 authorizeHttpRequests 兩者之間的區別可以分別參考 Authorize HttpServletRequests with AuthorizationFilter 和 Authorize 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 自定義認證
前文介紹兩種認證方式:FormLogin 和 HttpBasic,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);
};
}
認證過濾器核心實現流程:
-
利用 Http 請求(servletRequest)中的資訊完成自定義認證過程(省略),可能的情況:
- 檢查請求中的用戶名和密碼是否匹配
- 檢查請求中的 Token 是否有效
- 其他
如果認證成功,則繼續下一步;認證失敗,則可以拋出異常,或者跳過後續步驟;
-
從 Http 請求中提取 username(用戶名),使用已注入的 UserDetailsService 實例,載入 UserDetails(用戶資訊)(省略);
簡單起見,模擬創建一個用戶資訊實例 user;因為到這一步時,用戶已是認證成功的,用戶名和密碼可以隨意設置,實際只有角色是必須的,我們設置已認證用戶的角色為 USER。 -
創建用戶認證標識;
Spring Security 內部是依靠 Authentication.isAuthenticated() 來判斷用戶是否已認證過的,UsernamePasswordAuthenticationToken 是 Authentication 的一種具體實現,需要注意創建實例時使用的構造方法和參數,構造方法內部會調用 Authentication.setAuthenticated(true)。 -
創建並設置環境上下文 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 自身包含的內容很多,官方文檔也不能很好的講述清楚每個功能特性的使用方法,很多時候需要我們自己根據文檔、示例、源碼以及他人的分享,儘可能多的實踐,逐步加深理解。