SpringSecurity6自定義JSON登錄的實現(xiàn)
目前最新版的 Spring Boot 已經(jīng)到了 3.0.5 了,隨之而來 Spring Security 目前的版本也到了 6.0.2 了,最近幾次的版本升級,Spring Security 寫法的變化特別多。
最近有小伙伴在 Spring Security 中自定義 JSON 登錄的時候就遇到問題了,松哥看了下,感覺這個問題還特別典型,因此我拎出來和各位小伙伴一起來聊一聊這個話題。
1. 自定義 JSON 登錄
小伙伴們知道,Spring Security 中默認的登錄接口數(shù)據(jù)格式是 key-value 的形式,如果我們想使用 JSON 格式來登錄,那么就必須自定義過濾器或者自定義登錄接口,下面松哥先來和小伙伴們展示一下這兩種不同的登錄形式。
1.1 自定義登錄過濾器
Spring Security 默認處理登錄數(shù)據(jù)的過濾器是 UsernamePasswordAuthenticationFilter,在這個過濾器中,系統(tǒng)會通過 request.getParameter(this.passwordParameter)的方式將用戶名和密碼讀取出來,很明顯這就要求前端傳遞參數(shù)的形式是 key-value。
如果想要使用 JSON 格式的參數(shù)登錄,那么就需要從這個地方做文章了,我們自定義的過濾器如下:
public class JsonLoginFilter extends UsernamePasswordAuthenticationFilter {
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
//獲取請求頭,據(jù)此判斷請求參數(shù)類型
String contentType = request.getContentType();
if (MediaType.APPLICATION_JSON_VALUE.equalsIgnoreCase(contentType) || MediaType.APPLICATION_JSON_UTF8_VALUE.equalsIgnoreCase(contentType)) {
//說明請求參數(shù)是 JSON
if (!request.getMethod().equals("POST")) {
throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
}
String username = null;
String password = null;
try {
//解析請求體中的 JSON 參數(shù)
User user = new ObjectMapper().readValue(request.getInputStream(), User.class);
username = user.getUsername();
username = (username != null) ? username.trim() : "";
password = user.getPassword();
password = (password != null) ? password : "";
} catch (IOException e) {
throw new RuntimeException(e);
}
//構(gòu)建登錄令牌
UsernamePasswordAuthenticationToken authRequest = UsernamePasswordAuthenticationToken.unauthenticated(username,
password);
// Allow subclasses to set the "details" property
setDetails(request, authRequest);
//執(zhí)行真正的登錄操作
Authentication auth = this.getAuthenticationManager().authenticate(authRequest);
return auth;
} else {
return super.attemptAuthentication(request, response);
}
}
}
看過松哥之前的 Spring Security 系列文章的小伙伴,這段代碼應(yīng)該都是非常熟悉了。
首先我們獲取請求頭,根據(jù)請求頭的類型來判斷請求參數(shù)的格式。
如果是 JSON 格式的參數(shù),就在 if 中進行處理,否則說明是 key-value 形式的參數(shù),那么我們就調(diào)用父類的方法進行處理即可。
JSON 格式的參數(shù)的處理邏輯和 key-value 的處理邏輯是一致的,唯一不同的是參數(shù)的提取方式不同而已。
最后,我們還需要對這個過濾器進行配置:
@Configuration
public class SecurityConfig {
@Autowired
UserService userService;
@Bean
JsonLoginFilter jsonLoginFilter() {
JsonLoginFilter filter = new JsonLoginFilter();
filter.setAuthenticationSuccessHandler((req,resp,auth)->{
resp.setContentType("application/json;charset=utf-8");
PrintWriter out = resp.getWriter();
//獲取當前登錄成功的用戶對象
User user = (User) auth.getPrincipal();
user.setPassword(null);
RespBean respBean = RespBean.ok("登錄成功", user);
out.write(new ObjectMapper().writeValueAsString(respBean));
});
filter.setAuthenticationFailureHandler((req,resp,e)->{
resp.setContentType("application/json;charset=utf-8");
PrintWriter out = resp.getWriter();
RespBean respBean = RespBean.error("登錄失敗");
if (e instanceof BadCredentialsException) {
respBean.setMessage("用戶名或者密碼輸入錯誤,登錄失敗");
} else if (e instanceof DisabledException) {
respBean.setMessage("賬戶被禁用,登錄失敗");
} else if (e instanceof CredentialsExpiredException) {
respBean.setMessage("密碼過期,登錄失敗");
} else if (e instanceof AccountExpiredException) {
respBean.setMessage("賬戶過期,登錄失敗");
} else if (e instanceof LockedException) {
respBean.setMessage("賬戶被鎖定,登錄失敗");
}
out.write(new ObjectMapper().writeValueAsString(respBean));
});
filter.setAuthenticationManager(authenticationManager());
filter.setFilterProcessesUrl("/login");
return filter;
}
@Bean
AuthenticationManager authenticationManager() {
DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();
daoAuthenticationProvider.setUserDetailsService(userService);
ProviderManager pm = new ProviderManager(daoAuthenticationProvider);
return pm;
}
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
//開啟過濾器的配置
http.authorizeHttpRequests()
//任意請求,都要認證之后才能訪問
.anyRequest().authenticated()
.and()
//開啟表單登錄,開啟之后,就會自動配置登錄頁面、登錄接口等信息
.formLogin()
//和登錄相關(guān)的 URL 地址都放行
.permitAll()
.and()
//關(guān)閉 csrf 保護機制,本質(zhì)上就是從 Spring Security 過濾器鏈中移除了 CsrfFilter
.csrf().disable();
http.addFilterBefore(jsonLoginFilter(), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
這里就是配置一個 JsonLoginFilter 的 Bean,并將之添加到 Spring Security 過濾器鏈中即可。
在 Spring Boot3 之前(Spring Security6 之前),上面這段代碼就可以實現(xiàn) JSON 登錄了。
但是從 Spring Boot3 開始,這段代碼有點瑕疵了,直接用已經(jīng)無法實現(xiàn) JSON 登錄了,具體原因松哥下文分析。
1.2 自定義登錄接口
另外一種自定義 JSON 登錄的方式是直接自定義登錄接口,如下:
@RestController
public class LoginController {
@Autowired
AuthenticationManager authenticationManager;
@PostMapping("/doLogin")
public String doLogin(@RequestBody User user) {
UsernamePasswordAuthenticationToken unauthenticated = UsernamePasswordAuthenticationToken.unauthenticated(user.getUsername(), user.getPassword());
try {
Authentication authenticate = authenticationManager.authenticate(unauthenticated);
SecurityContextHolder.getContext().setAuthentication(authenticate);
return "success";
} catch (AuthenticationException e) {
return "error:" + e.getMessage();
}
}
}
這里直接自定義登錄接口,請求參數(shù)通過 JSON 的形式來傳遞。拿到用戶名密碼之后,調(diào)用 AuthenticationManager#authenticate 方法進行認證即可。認證成功之后,將認證后的用戶信息存入到 SecurityContextHolder 中。
最后再配一下登錄接口就行了:
@Configuration
public class SecurityConfig {
@Autowired
UserService userService;
@Bean
AuthenticationManager authenticationManager() {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setUserDetailsService(userService);
ProviderManager pm = new ProviderManager(provider);
return pm;
}
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests()
//表示 /doLogin 這個地址可以不用登錄直接訪問
.requestMatchers("/doLogin").permitAll()
.anyRequest().authenticated().and()
.formLogin()
.permitAll()
.and()
.csrf().disable();
return http.build();
}
}
這也算是一種使用 JSON 格式參數(shù)的方案。在 Spring Boot3 之前(Spring Security6 之前),上面這個方案也是沒有任何問題的。
從 Spring Boot3(Spring Security6) 開始,上面這兩種方案都出現(xiàn)了一些瑕疵。
具體表現(xiàn)就是:當你調(diào)用登錄接口登錄成功之后,再去訪問系統(tǒng)中的其他頁面,又會跳轉(zhuǎn)回登錄頁面,說明訪問登錄之外的其他接口時,系統(tǒng)不知道你已經(jīng)登錄過了。
2. 原因分析
產(chǎn)生上面問題的原因,主要在于 Spring Security 過濾器鏈中有一個過濾器發(fā)生變化了:
在 Spring Boot3 之前,Spring Security 過濾器鏈中有一個名為 SecurityContextPersistenceFilter 的過濾器,這個過濾器在 Spring Boot2.7.x 中廢棄了,但是還在使用,在 Spring Boot3 中則被從 Spring Security 過濾器鏈中移除了,取而代之的是一個名為 SecurityContextHolderFilter 的過濾器。
在第一小節(jié)和小伙伴們介紹的兩種 JSON 登錄方案在 Spring Boot2.x 中可以運行在 Spring Boot3.x 中無法運行,就是因為這個過濾器的變化導致的。
所以接下來我們就來分析一下這兩個過濾器到底有哪些區(qū)別。
先來看 SecurityContextPersistenceFilter 的核心邏輯:
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpRequestResponseHolder holder = new HttpRequestResponseHolder(request, response);
SecurityContext contextBeforeChainExecution = this.repo.loadContext(holder);
try {
SecurityContextHolder.setContext(contextBeforeChainExecution);
chain.doFilter(holder.getRequest(), holder.getResponse());
}
finally {
SecurityContext contextAfterChainExecution = SecurityContextHolder.getContext();
SecurityContextHolder.clearContext();
this.repo.saveContext(contextAfterChainExecution, holder.getRequest(), holder.getResponse());
}
}
我這里只貼出來了一些關(guān)鍵的核心代碼:
首先,這個過濾器位于整個 Spring Security 過濾器鏈的第三個,是非常靠前的。
當?shù)卿浾埱蠼?jīng)過這個過濾器的時候,首先會嘗試從 SecurityContextRepository(上文中的 this.repo)中讀取到 SecurityContext 對象,這個對象中保存了當前用戶的信息,第一次登錄的時候,這里實際上讀取不到任何用戶信息。
將讀取到的 SecurityContext 存入到 SecurityContextHolder 中,默認情況下,SecurityContextHolder 中通過 ThreadLocal 來保存 SecurityContext 對象,也就是當前請求在后續(xù)的處理流程中,只要在同一個線程里,都可以直接從 SecurityContextHolder 中提取到當前登錄用戶信息。
請求繼續(xù)向后執(zhí)行。
在 finally 代碼塊中,當前請求已經(jīng)結(jié)束了,此時再次獲取到 SecurityContext,并清空 SecurityContextHolder 防止內(nèi)存泄漏,然后調(diào)用
this.repo.saveContext方法保存當前登錄用戶對象(實際上是保存到 HttpSession 中)。以后其他請求到達的時候,執(zhí)行前面第 2 步的時候,就讀取到當前用戶的信息了,在請求后續(xù)的處理過程中,Spring Security 需要知道當前用戶的時候,會自動去 SecurityContextHolder 中讀取當前用戶信息。
這就是 Spring Security 認證的一個大致流程。
然而,到了 Spring Boot3 之后,這個過濾器被 SecurityContextHolderFilter 取代了,我們來看下 SecurityContextHolderFilter 過濾器的一個關(guān)鍵邏輯:
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
Supplier<SecurityContext> deferredContext = this.securityContextRepository.loadDeferredContext(request);
try {
this.securityContextHolderStrategy.setDeferredContext(deferredContext);
chain.doFilter(request, response);
}
finally {
this.securityContextHolderStrategy.clearContext();
request.removeAttribute(FILTER_APPLIED);
}
}
小伙伴們看到,前面的邏輯基本上還是一樣的,不一樣的是 finally 中的代碼,finally 中少了一步向 HttpSession 保存 SecurityContext 的操作。
這下就明白了,用戶登錄成功之后,用戶信息沒有保存到 HttpSession,導致下一次請求到達的時候,無法從 HttpSession 中讀取到 SecurityContext 存到 SecurityContextHolder 中,在后續(xù)的執(zhí)行過程中,Spring Security 就會認為當前用戶沒有登錄。
這就是問題的原因!
找到原因,那么問題就好解決了。
3. 問題解決
首先問題出在了過濾器上,直接改過濾器倒也不是不可以,但是,既然 Spring Security 在升級的過程中拋棄了之前舊的方案,我們又費勁的把之前舊的方案寫回來,好像也不合理。
其實,Spring Security 提供了另外一個修改的入口,在 org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter#successfulAuthentication 方法中,源碼如下:
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
Authentication authResult) throws IOException, ServletException {
SecurityContext context = this.securityContextHolderStrategy.createEmptyContext();
context.setAuthentication(authResult);
this.securityContextHolderStrategy.setContext(context);
this.securityContextRepository.saveContext(context, request, response);
this.rememberMeServices.loginSuccess(request, response, authResult);
if (this.eventPublisher != null) {
this.eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(authResult, this.getClass()));
}
this.successHandler.onAuthenticationSuccess(request, response, authResult);
}
這個方法是當前用戶登錄成功之后的回調(diào)方法,小伙伴們看到,在這個回調(diào)方法中,有一句 this.securityContextRepository.saveContext(context, request, response);,這就表示將當前登錄成功的用戶信息存入到 HttpSession 中。
在當前過濾器中,securityContextRepository 的類型是 RequestAttributeSecurityContextRepository,這個表示將 SecurityContext 存入到當前請求的屬性中,那很明顯,在當前請求結(jié)束之后,這個數(shù)據(jù)就沒了。在 Spring Security 的自動化配置類中,將 securityContextRepository 屬性指向了 DelegatingSecurityContextRepository,這是一個代理的存儲器,代理的對象是 RequestAttributeSecurityContextRepository 和 HttpSessionSecurityContextRepository,所以在默認的情況下,用戶登錄成功之后,在這里就把登錄用戶數(shù)據(jù)存入到 HttpSessionSecurityContextRepository 中了。
當我們自定義了登錄過濾器之后,就破壞了自動化配置里的方案了,這里使用的 securityContextRepository 對象就真的是 RequestAttributeSecurityContextRepository 了,所以就導致用戶后續(xù)訪問時系統(tǒng)以為用戶未登錄。
那么解決方案很簡單,我們只需要為自定義的過濾器指定 securityContextRepository 屬性的值就可以了,如下:
@Bean
JsonLoginFilter jsonLoginFilter() {
JsonLoginFilter filter = new JsonLoginFilter();
filter.setAuthenticationSuccessHandler((req,resp,auth)->{
resp.setContentType("application/json;charset=utf-8");
PrintWriter out = resp.getWriter();
//獲取當前登錄成功的用戶對象
User user = (User) auth.getPrincipal();
user.setPassword(null);
RespBean respBean = RespBean.ok("登錄成功", user);
out.write(new ObjectMapper().writeValueAsString(respBean));
});
filter.setAuthenticationFailureHandler((req,resp,e)->{
resp.setContentType("application/json;charset=utf-8");
PrintWriter out = resp.getWriter();
RespBean respBean = RespBean.error("登錄失敗");
if (e instanceof BadCredentialsException) {
respBean.setMessage("用戶名或者密碼輸入錯誤,登錄失敗");
} else if (e instanceof DisabledException) {
respBean.setMessage("賬戶被禁用,登錄失敗");
} else if (e instanceof CredentialsExpiredException) {
respBean.setMessage("密碼過期,登錄失敗");
} else if (e instanceof AccountExpiredException) {
respBean.setMessage("賬戶過期,登錄失敗");
} else if (e instanceof LockedException) {
respBean.setMessage("賬戶被鎖定,登錄失敗");
}
out.write(new ObjectMapper().writeValueAsString(respBean));
});
filter.setAuthenticationManager(authenticationManager());
filter.setFilterProcessesUrl("/login");
filter.setSecurityContextRepository(new HttpSessionSecurityContextRepository());
return filter;
}
小伙伴們看到,最后調(diào)用 setSecurityContextRepository 方法設(shè)置一下就行。
Spring Boot3.x 之前之所以不用設(shè)置這個屬性,是因為這里雖然沒保存最后還是在 SecurityContextPersistenceFilter 過濾器中保存了。
那么對于自定義登錄接口的問題,解決思路也是類似的:
@RestController
public class LoginController {
@Autowired
AuthenticationManager authenticationManager;
@PostMapping("/doLogin")
public String doLogin(@RequestBody User user, HttpSession session) {
UsernamePasswordAuthenticationToken unauthenticated = UsernamePasswordAuthenticationToken.unauthenticated(user.getUsername(), user.getPassword());
try {
Authentication authenticate = authenticationManager.authenticate(unauthenticated);
SecurityContextHolder.getContext().setAuthentication(authenticate);
session.setAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, SecurityContextHolder.getContext());
return "success";
} catch (AuthenticationException e) {
return "error:" + e.getMessage();
}
}
}
小伙伴們看到,在登錄成功之后,開發(fā)者自己手動將數(shù)據(jù)存入到 HttpSession 中,這樣就能確保下個請求到達的時候,能夠從 HttpSession 中讀取到有效的數(shù)據(jù)存入到 SecurityContextHolder 中了。
到此這篇關(guān)于SpringSecurity6自定義JSON登錄的實現(xiàn)的文章就介紹到這了,更多相關(guān)SpringSecurity6自定義JSON登錄內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
- SpringSecurity多表多端賬戶登錄的實現(xiàn)
- SpringSecurity集成第三方登錄過程詳解(最新推薦)
- springsecurity實現(xiàn)用戶登錄認證快速使用示例代碼(前后端分離項目)
- SpringSecurity自動登錄流程與實現(xiàn)詳解
- SpringSecurity6.x多種登錄方式配置小結(jié)
- 如何使用JWT的SpringSecurity實現(xiàn)前后端分離
- SpringSecurity+Redis+Jwt實現(xiàn)用戶認證授權(quán)
- SpringSecurity角色權(quán)限控制(SpringBoot+SpringSecurity+JWT)
- SpringBoot3.0+SpringSecurity6.0+JWT的實現(xiàn)
- springSecurity之如何添加自定義過濾器
- springSecurity自定義登錄接口和JWT認證過濾器的流程
相關(guān)文章
Springboot FeignClient調(diào)用Method has too m
本文主要介紹了Springboot FeignClient微服務(wù)間調(diào)用Method has too many Body parameters 解決,文中通過示例代碼介紹的非常詳細,具有一定的參考價值,感興趣的小伙伴們可以參考一下2021-12-12
MyBatis模糊查詢報錯:ParserException: not supported.pos&n
本文主要介紹了MyBatis模糊查詢報錯:ParserException: not supported.pos 問題解決,文中通過示例代碼介紹的非常詳細,對大家的學習或者工作具有一定的參考學習價值,需要的朋友們下面隨著小編來一起學習學習吧2025-04-04
Java實現(xiàn)幾十萬條數(shù)據(jù)插入實例教程(30萬條數(shù)據(jù)插入MySQL僅需13秒)
這篇文章主要給大家介紹了關(guān)于Java如何實現(xiàn)幾十萬條數(shù)據(jù)插入的相關(guān)資料,30萬條數(shù)據(jù)插入MySQL僅需13秒,文中通過實例代碼介紹的非常詳細,對大家的學習或者工作具有一定的參考學習價值,需要的朋友可以參考下2023-04-04
JSP服務(wù)器端和前端出現(xiàn)亂碼問題解決方案
這篇文章主要介紹了JSP服務(wù)器端和前端出現(xiàn)亂碼問題解決方案,文中通過示例代碼介紹的非常詳細,對大家的學習或者工作具有一定的參考學習價值,需要的朋友可以參考下2020-02-02
解決java.lang.IllegalStateException:Duplicate key異常問題
java.lang.IllegalStateException:Duplicatekey異常在將List轉(zhuǎn)換為Map時出現(xiàn),解決方法是使用toMap()的重載方法,如果已經(jīng)存在則不再修改,直接使用上一個數(shù)據(jù)2025-03-03

