SpringSecurity6自定義JSON登錄的實(shí)現(xiàn)
目前最新版的 Spring Boot 已經(jīng)到了 3.0.5 了,隨之而來(lái) Spring Security 目前的版本也到了 6.0.2 了,最近幾次的版本升級(jí),Spring Security 寫法的變化特別多。
最近有小伙伴在 Spring Security 中自定義 JSON 登錄的時(shí)候就遇到問(wèn)題了,松哥看了下,感覺(jué)這個(gè)問(wèn)題還特別典型,因此我拎出來(lái)和各位小伙伴一起來(lái)聊一聊這個(gè)話題。
1. 自定義 JSON 登錄
小伙伴們知道,Spring Security 中默認(rèn)的登錄接口數(shù)據(jù)格式是 key-value 的形式,如果我們想使用 JSON 格式來(lái)登錄,那么就必須自定義過(guò)濾器或者自定義登錄接口,下面松哥先來(lái)和小伙伴們展示一下這兩種不同的登錄形式。
1.1 自定義登錄過(guò)濾器
Spring Security 默認(rèn)處理登錄數(shù)據(jù)的過(guò)濾器是 UsernamePasswordAuthenticationFilter,在這個(gè)過(guò)濾器中,系統(tǒng)會(huì)通過(guò) request.getParameter(this.passwordParameter)
的方式將用戶名和密碼讀取出來(lái),很明顯這就要求前端傳遞參數(shù)的形式是 key-value。
如果想要使用 JSON 格式的參數(shù)登錄,那么就需要從這個(gè)地方做文章了,我們自定義的過(guò)濾器如下:
public class JsonLoginFilter extends UsernamePasswordAuthenticationFilter { @Override public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { //獲取請(qǐng)求頭,據(jù)此判斷請(qǐng)求參數(shù)類型 String contentType = request.getContentType(); if (MediaType.APPLICATION_JSON_VALUE.equalsIgnoreCase(contentType) || MediaType.APPLICATION_JSON_UTF8_VALUE.equalsIgnoreCase(contentType)) { //說(shuō)明請(qǐng)求參數(shù)是 JSON if (!request.getMethod().equals("POST")) { throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod()); } String username = null; String password = null; try { //解析請(qǐng)求體中的 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); } } }
看過(guò)松哥之前的 Spring Security 系列文章的小伙伴,這段代碼應(yīng)該都是非常熟悉了。
首先我們獲取請(qǐng)求頭,根據(jù)請(qǐng)求頭的類型來(lái)判斷請(qǐng)求參數(shù)的格式。
如果是 JSON 格式的參數(shù),就在 if 中進(jìn)行處理,否則說(shuō)明是 key-value 形式的參數(shù),那么我們就調(diào)用父類的方法進(jìn)行處理即可。
JSON 格式的參數(shù)的處理邏輯和 key-value 的處理邏輯是一致的,唯一不同的是參數(shù)的提取方式不同而已。
最后,我們還需要對(duì)這個(gè)過(guò)濾器進(jìn)行配置:
@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(); //獲取當(dāng)前登錄成功的用戶對(duì)象 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("用戶名或者密碼輸入錯(cuò)誤,登錄失敗"); } else if (e instanceof DisabledException) { respBean.setMessage("賬戶被禁用,登錄失敗"); } else if (e instanceof CredentialsExpiredException) { respBean.setMessage("密碼過(guò)期,登錄失敗"); } else if (e instanceof AccountExpiredException) { respBean.setMessage("賬戶過(guò)期,登錄失敗"); } 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 { //開(kāi)啟過(guò)濾器的配置 http.authorizeHttpRequests() //任意請(qǐng)求,都要認(rèn)證之后才能訪問(wèn) .anyRequest().authenticated() .and() //開(kāi)啟表單登錄,開(kāi)啟之后,就會(huì)自動(dòng)配置登錄頁(yè)面、登錄接口等信息 .formLogin() //和登錄相關(guān)的 URL 地址都放行 .permitAll() .and() //關(guān)閉 csrf 保護(hù)機(jī)制,本質(zhì)上就是從 Spring Security 過(guò)濾器鏈中移除了 CsrfFilter .csrf().disable(); http.addFilterBefore(jsonLoginFilter(), UsernamePasswordAuthenticationFilter.class); return http.build(); } }
這里就是配置一個(gè) JsonLoginFilter 的 Bean,并將之添加到 Spring Security 過(guò)濾器鏈中即可。
在 Spring Boot3 之前(Spring Security6 之前),上面這段代碼就可以實(shí)現(xiàn) JSON 登錄了。
但是從 Spring Boot3 開(kāi)始,這段代碼有點(diǎn)瑕疵了,直接用已經(jīng)無(wú)法實(shí)現(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(); } } }
這里直接自定義登錄接口,請(qǐng)求參數(shù)通過(guò) JSON 的形式來(lái)傳遞。拿到用戶名密碼之后,調(diào)用 AuthenticationManager#authenticate 方法進(jìn)行認(rèn)證即可。認(rèn)證成功之后,將認(rèn)證后的用戶信息存入到 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 這個(gè)地址可以不用登錄直接訪問(wèn) .requestMatchers("/doLogin").permitAll() .anyRequest().authenticated().and() .formLogin() .permitAll() .and() .csrf().disable(); return http.build(); } }
這也算是一種使用 JSON 格式參數(shù)的方案。在 Spring Boot3 之前(Spring Security6 之前),上面這個(gè)方案也是沒(méi)有任何問(wèn)題的。
從 Spring Boot3(Spring Security6) 開(kāi)始,上面這兩種方案都出現(xiàn)了一些瑕疵。
具體表現(xiàn)就是:當(dāng)你調(diào)用登錄接口登錄成功之后,再去訪問(wèn)系統(tǒng)中的其他頁(yè)面,又會(huì)跳轉(zhuǎn)回登錄頁(yè)面,說(shuō)明訪問(wèn)登錄之外的其他接口時(shí),系統(tǒng)不知道你已經(jīng)登錄過(guò)了。
2. 原因分析
產(chǎn)生上面問(wèn)題的原因,主要在于 Spring Security 過(guò)濾器鏈中有一個(gè)過(guò)濾器發(fā)生變化了:
在 Spring Boot3 之前,Spring Security 過(guò)濾器鏈中有一個(gè)名為 SecurityContextPersistenceFilter 的過(guò)濾器,這個(gè)過(guò)濾器在 Spring Boot2.7.x 中廢棄了,但是還在使用,在 Spring Boot3 中則被從 Spring Security 過(guò)濾器鏈中移除了,取而代之的是一個(gè)名為 SecurityContextHolderFilter 的過(guò)濾器。
在第一小節(jié)和小伙伴們介紹的兩種 JSON 登錄方案在 Spring Boot2.x 中可以運(yùn)行在 Spring Boot3.x 中無(wú)法運(yùn)行,就是因?yàn)檫@個(gè)過(guò)濾器的變化導(dǎo)致的。
所以接下來(lái)我們就來(lái)分析一下這兩個(gè)過(guò)濾器到底有哪些區(qū)別。
先來(lái)看 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()); } }
我這里只貼出來(lái)了一些關(guān)鍵的核心代碼:
首先,這個(gè)過(guò)濾器位于整個(gè) Spring Security 過(guò)濾器鏈的第三個(gè),是非常靠前的。
當(dāng)?shù)卿浾?qǐng)求經(jīng)過(guò)這個(gè)過(guò)濾器的時(shí)候,首先會(huì)嘗試從 SecurityContextRepository(上文中的 this.repo)中讀取到 SecurityContext 對(duì)象,這個(gè)對(duì)象中保存了當(dāng)前用戶的信息,第一次登錄的時(shí)候,這里實(shí)際上讀取不到任何用戶信息。
將讀取到的 SecurityContext 存入到 SecurityContextHolder 中,默認(rèn)情況下,SecurityContextHolder 中通過(guò) ThreadLocal 來(lái)保存 SecurityContext 對(duì)象,也就是當(dāng)前請(qǐng)求在后續(xù)的處理流程中,只要在同一個(gè)線程里,都可以直接從 SecurityContextHolder 中提取到當(dāng)前登錄用戶信息。
請(qǐng)求繼續(xù)向后執(zhí)行。
在 finally 代碼塊中,當(dāng)前請(qǐng)求已經(jīng)結(jié)束了,此時(shí)再次獲取到 SecurityContext,并清空 SecurityContextHolder 防止內(nèi)存泄漏,然后調(diào)用
this.repo.saveContext
方法保存當(dāng)前登錄用戶對(duì)象(實(shí)際上是保存到 HttpSession 中)。以后其他請(qǐng)求到達(dá)的時(shí)候,執(zhí)行前面第 2 步的時(shí)候,就讀取到當(dāng)前用戶的信息了,在請(qǐng)求后續(xù)的處理過(guò)程中,Spring Security 需要知道當(dāng)前用戶的時(shí)候,會(huì)自動(dòng)去 SecurityContextHolder 中讀取當(dāng)前用戶信息。
這就是 Spring Security 認(rèn)證的一個(gè)大致流程。
然而,到了 Spring Boot3 之后,這個(gè)過(guò)濾器被 SecurityContextHolderFilter 取代了,我們來(lái)看下 SecurityContextHolderFilter 過(guò)濾器的一個(gè)關(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 的操作。
這下就明白了,用戶登錄成功之后,用戶信息沒(méi)有保存到 HttpSession,導(dǎo)致下一次請(qǐng)求到達(dá)的時(shí)候,無(wú)法從 HttpSession 中讀取到 SecurityContext 存到 SecurityContextHolder 中,在后續(xù)的執(zhí)行過(guò)程中,Spring Security 就會(huì)認(rèn)為當(dāng)前用戶沒(méi)有登錄。
這就是問(wèn)題的原因!
找到原因,那么問(wèn)題就好解決了。
3. 問(wèn)題解決
首先問(wèn)題出在了過(guò)濾器上,直接改過(guò)濾器倒也不是不可以,但是,既然 Spring Security 在升級(jí)的過(guò)程中拋棄了之前舊的方案,我們又費(fèi)勁的把之前舊的方案寫回來(lái),好像也不合理。
其實(shí),Spring Security 提供了另外一個(gè)修改的入口,在 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); }
這個(gè)方法是當(dāng)前用戶登錄成功之后的回調(diào)方法,小伙伴們看到,在這個(gè)回調(diào)方法中,有一句 this.securityContextRepository.saveContext(context, request, response);
,這就表示將當(dāng)前登錄成功的用戶信息存入到 HttpSession 中。
在當(dāng)前過(guò)濾器中,securityContextRepository 的類型是 RequestAttributeSecurityContextRepository,這個(gè)表示將 SecurityContext 存入到當(dāng)前請(qǐng)求的屬性中,那很明顯,在當(dāng)前請(qǐng)求結(jié)束之后,這個(gè)數(shù)據(jù)就沒(méi)了。在 Spring Security 的自動(dòng)化配置類中,將 securityContextRepository 屬性指向了 DelegatingSecurityContextRepository,這是一個(gè)代理的存儲(chǔ)器,代理的對(duì)象是 RequestAttributeSecurityContextRepository 和 HttpSessionSecurityContextRepository,所以在默認(rèn)的情況下,用戶登錄成功之后,在這里就把登錄用戶數(shù)據(jù)存入到 HttpSessionSecurityContextRepository 中了。
當(dāng)我們自定義了登錄過(guò)濾器之后,就破壞了自動(dòng)化配置里的方案了,這里使用的 securityContextRepository 對(duì)象就真的是 RequestAttributeSecurityContextRepository 了,所以就導(dǎo)致用戶后續(xù)訪問(wèn)時(shí)系統(tǒng)以為用戶未登錄。
那么解決方案很簡(jiǎn)單,我們只需要為自定義的過(guò)濾器指定 securityContextRepository 屬性的值就可以了,如下:
@Bean JsonLoginFilter jsonLoginFilter() { JsonLoginFilter filter = new JsonLoginFilter(); filter.setAuthenticationSuccessHandler((req,resp,auth)->{ resp.setContentType("application/json;charset=utf-8"); PrintWriter out = resp.getWriter(); //獲取當(dāng)前登錄成功的用戶對(duì)象 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("用戶名或者密碼輸入錯(cuò)誤,登錄失敗"); } else if (e instanceof DisabledException) { respBean.setMessage("賬戶被禁用,登錄失敗"); } else if (e instanceof CredentialsExpiredException) { respBean.setMessage("密碼過(guò)期,登錄失敗"); } else if (e instanceof AccountExpiredException) { respBean.setMessage("賬戶過(guò)期,登錄失敗"); } 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è)置這個(gè)屬性,是因?yàn)檫@里雖然沒(méi)保存最后還是在 SecurityContextPersistenceFilter 過(guò)濾器中保存了。
那么對(duì)于自定義登錄接口的問(wèn)題,解決思路也是類似的:
@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(); } } }
小伙伴們看到,在登錄成功之后,開(kāi)發(fā)者自己手動(dòng)將數(shù)據(jù)存入到 HttpSession 中,這樣就能確保下個(gè)請(qǐng)求到達(dá)的時(shí)候,能夠從 HttpSession 中讀取到有效的數(shù)據(jù)存入到 SecurityContextHolder 中了。
到此這篇關(guān)于SpringSecurity6自定義JSON登錄的實(shí)現(xiàn)的文章就介紹到這了,更多相關(guān)SpringSecurity6自定義JSON登錄內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
- SpringSecurity多表多端賬戶登錄的實(shí)現(xiàn)
- SpringSecurity集成第三方登錄過(guò)程詳解(最新推薦)
- springsecurity實(shí)現(xiàn)用戶登錄認(rèn)證快速使用示例代碼(前后端分離項(xiàng)目)
- SpringSecurity自動(dòng)登錄流程與實(shí)現(xiàn)詳解
- SpringSecurity6.x多種登錄方式配置小結(jié)
- 如何使用JWT的SpringSecurity實(shí)現(xiàn)前后端分離
- SpringSecurity+Redis+Jwt實(shí)現(xiàn)用戶認(rèn)證授權(quán)
- SpringSecurity角色權(quán)限控制(SpringBoot+SpringSecurity+JWT)
- SpringBoot3.0+SpringSecurity6.0+JWT的實(shí)現(xiàn)
- springSecurity之如何添加自定義過(guò)濾器
- springSecurity自定義登錄接口和JWT認(rèn)證過(guò)濾器的流程
相關(guān)文章
SpringMVC基于注解方式實(shí)現(xiàn)上傳下載
本文主要介紹了SpringMVC基于注解方式實(shí)現(xiàn)上傳下載,文中通過(guò)示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來(lái)一起學(xué)習(xí)學(xué)習(xí)吧2022-04-04java實(shí)現(xiàn)網(wǎng)上購(gòu)物車程序
這篇文章主要介紹了java實(shí)現(xiàn)網(wǎng)上購(gòu)物車程序,具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2018-01-01Day14基礎(chǔ)不牢地動(dòng)山搖-Java基礎(chǔ)
這篇文章主要給大家介紹了關(guān)于Java中方法使用的相關(guān)資料,文中通過(guò)示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來(lái)一起學(xué)習(xí)學(xué)習(xí)吧2021-08-08在SpringBoot接口中正確地序列化時(shí)間字段的方法
文章主要介紹在 Spring Boot 接口中正確序列化時(shí)間字段的方法,包括 Java 中Date和LocalDateTime類型的區(qū)別,JSON 序列化和請(qǐng)求參數(shù)中時(shí)間字段的處理,如時(shí)間字符串的格式配置、時(shí)間戳的使用及相關(guān)配置,還提到了在 Swagger UI 中的類型設(shè)置,需要的朋友可以參考下2024-11-11OutOfMemoryError內(nèi)存不足和StackOverflowError堆棧溢出示例詳解
這篇文章主要為大家介紹了OutOfMemoryError內(nèi)存不足和StackOverflowError堆棧溢出示例詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2023-09-09SpringBoot沒(méi)有讀取到application.yml問(wèn)題及解決
這篇文章主要介紹了SpringBoot沒(méi)有讀取到application.yml問(wèn)題及解決方案,具有很好的參考價(jià)值,希望對(duì)大家有所幫助,如有錯(cuò)誤或未考慮完全的地方,望不吝賜教2023-12-12