Spring Security6 最新版配置及實(shí)現(xiàn)動(dòng)態(tài)權(quán)限管理
Spring Security 在最近幾個(gè)版本中配置的寫法都有一些變化,很多常見的方法都廢棄了,并且將在未來的 Spring Security7 中移除,因此又補(bǔ)充了一些新的內(nèi)容,重新發(fā)一下,供各位使用 Spring Security 的小伙伴們參考。
接下來,我把從 Spring Security5.7 開始(對(duì)應(yīng) Spring Boot2.7 開始),各種已知的變化都來和小伙伴們梳理一下。
1. WebSecurityConfigurerAdapter
首先第一點(diǎn),就是各位小伙伴最容易發(fā)現(xiàn)的 WebSecurityConfigurerAdapter
過期了,在目前最新的 Spring Security6.1 中,這個(gè)類已經(jīng)完全被移除了,想湊合著用都不行了。
準(zhǔn)確來說,Spring Security 是在 5.7.0-M2 這個(gè)版本中將 WebSecurityConfigurerAdapter
過期的,過期的原因是因?yàn)楣俜较胍膭?lì)各位開發(fā)者使用基于組件的安全配置。
那么什么是基于組件的安全配置呢?我們來舉幾個(gè)例子:
以前我們配置 SecurityFilterChain 的方式是下面這樣:
@Configuration public class SecurityConfiguration extends WebSecurityConfigurerAdapter { @Override protected void configure(HttpSecurity http) throws Exception { http .authorizeHttpRequests((authz) -> authz .anyRequest().authenticated() ) .httpBasic(withDefaults()); } }
那么以后就要改為下面這樣了:
@Configuration public class SecurityConfiguration { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests((authz) -> authz .anyRequest().authenticated() ) .httpBasic(withDefaults()); return http.build(); } }
如果懂之前的寫法的話,下面這個(gè)代碼其實(shí)是很好理解的,我就不做過多解釋了,不過還不懂 Spring Security 基本用法的小伙伴
以前我們配置 WebSecurity 是這樣:
@Configuration public class SecurityConfiguration extends WebSecurityConfigurerAdapter { @Override public void configure(WebSecurity web) { web.ignoring().antMatchers("/ignore1", "/ignore2"); } }
以后就得改成下面這樣了:
@Configuration public class SecurityConfiguration { @Bean public WebSecurityCustomizer webSecurityCustomizer() { return (web) -> web.ignoring().antMatchers("/ignore1", "/ignore2"); } }
另外還有一個(gè)就是關(guān)于 AuthenticationManager 的獲取,以前可以通過重寫父類的方法來獲取這個(gè) Bean,類似下面這樣:
@Configuration public class SecurityConfig extends WebSecurityConfigurerAdapter { @Override @Bean public AuthenticationManager authenticationManagerBean() throws Exception { return super.authenticationManagerBean(); } }
以后就只能自己創(chuàng)建這個(gè) Bean 了,類似下面這樣:
@Configuration public class SecurityConfig { @Autowired UserService userService; @Bean AuthenticationManager authenticationManager() { DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider(); daoAuthenticationProvider.setUserDetailsService(userService); ProviderManager pm = new ProviderManager(daoAuthenticationProvider); return pm; } }
當(dāng)然,也可以從 HttpSecurity 中提取出來 AuthenticationManager,如下:
@Configuration public class SpringSecurityConfiguration { AuthenticationManager authenticationManager; @Autowired UserDetailsService userDetailsService; @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { AuthenticationManagerBuilder authenticationManagerBuilder = http.getSharedObject(AuthenticationManagerBuilder.class); authenticationManagerBuilder.userDetailsService(userDetailsService); authenticationManager = authenticationManagerBuilder.build(); http.csrf().disable().cors().disable().authorizeHttpRequests().antMatchers("/api/v1/account/register", "/api/v1/account/auth").permitAll() .anyRequest().authenticated() .and() .authenticationManager(authenticationManager) .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS); return http.build(); } }
這也是一種辦法。
我們來看一個(gè)具體的例子。
首先我們新建一個(gè) Spring Boot 工程,引入 Web 和 Spring Security 依賴,注意 Spring Boot 選擇最新版。
接下來我們提供一個(gè)簡單的測(cè)試接口,如下:
@RestController public class HelloController { @GetMapping("/hello") public String hello() { return "hello 江南一點(diǎn)雨!"; } }
小伙伴們知道,在 Spring Security 中,默認(rèn)情況下,只要添加了依賴,我們項(xiàng)目的所有接口就已經(jīng)被統(tǒng)統(tǒng)保護(hù)起來了,現(xiàn)在啟動(dòng)項(xiàng)目,訪問 /hello
接口,就需要登錄之后才可以訪問,登錄的用戶名是 user,密碼則是隨機(jī)生成的,在項(xiàng)目的啟動(dòng)日志中。
現(xiàn)在我們的第一個(gè)需求是使用自定義的用戶,而不是系統(tǒng)默認(rèn)提供的,這個(gè)簡單,我們只需要向 Spring 容器中注冊(cè)一個(gè) UserDetailsService 的實(shí)例即可,像下面這樣:
@Configuration public class SecurityConfig { @Bean UserDetailsService userDetailsService() { InMemoryUserDetailsManager users = new InMemoryUserDetailsManager(); users.createUser(User.withUsername("javaboy").password("{noop}123").roles("admin").build()); users.createUser(User.withUsername("江南一點(diǎn)雨").password("{noop}123").roles("admin").build()); return users; } }
這就可以了。
當(dāng)然我現(xiàn)在的用戶是存在內(nèi)存中的,如果你的用戶是存在數(shù)據(jù)庫中,那么只需要提供 UserDetailsService 接口的實(shí)現(xiàn)類并注入 Spring 容器即可,這個(gè)之前在 vhr 視頻中講過多次了(公號(hào)后臺(tái)回復(fù) 666 有視頻介紹),這里就不再贅述了。
但是假如說我希望 /hello
這個(gè)接口能夠匿名訪問,并且我希望這個(gè)匿名訪問還不經(jīng)過 Spring Security 過濾器鏈,要是在以前,我們可以重寫 configure(WebSecurity)
方法進(jìn)行配置,但是現(xiàn)在,得換一種玩法:
@Configuration public class SecurityConfig { @Bean UserDetailsService userDetailsService() { InMemoryUserDetailsManager users = new InMemoryUserDetailsManager(); users.createUser(User.withUsername("javaboy").password("{noop}123").roles("admin").build()); users.createUser(User.withUsername("江南一點(diǎn)雨").password("{noop}123").roles("admin").build()); return users; } @Bean WebSecurityCustomizer webSecurityCustomizer() { return new WebSecurityCustomizer() { @Override public void customize(WebSecurity web) { web.ignoring().antMatchers("/hello"); } }; } }
以前位于 configure(WebSecurity)
方法中的內(nèi)容,現(xiàn)在位于 WebSecurityCustomizer Bean 中,該配置的東西寫在這里就可以了。
那如果我還希望對(duì)登錄頁面,參數(shù)等,進(jìn)行定制呢?繼續(xù)往下看:
@Configuration public class SecurityConfig { @Bean UserDetailsService userDetailsService() { InMemoryUserDetailsManager users = new InMemoryUserDetailsManager(); users.createUser(User.withUsername("javaboy").password("{noop}123").roles("admin").build()); users.createUser(User.withUsername("江南一點(diǎn)雨").password("{noop}123").roles("admin").build()); return users; } @Bean SecurityFilterChain securityFilterChain() { List<Filter> filters = new ArrayList<>(); return new DefaultSecurityFilterChain(new AntPathRequestMatcher("/**"), filters); } }
Spring Security 的底層實(shí)際上就是一堆過濾器,所以我們之前在 configure(HttpSecurity) 方法中的配置,實(shí)際上就是配置過濾器鏈?,F(xiàn)在過濾器鏈的配置,我們通過提供一個(gè) SecurityFilterChain Bean 來配置過濾器鏈,SecurityFilterChain 是一個(gè)接口,這個(gè)接口只有一個(gè)實(shí)現(xiàn)類 DefaultSecurityFilterChain,構(gòu)建 DefaultSecurityFilterChain 的第一個(gè)參數(shù)是攔截規(guī)則,也就是哪些路徑需要攔截,第二個(gè)參數(shù)則是過濾器鏈,這里我給了一個(gè)空集合,也就是我們的 Spring Security 會(huì)攔截下所有的請(qǐng)求,然后在一個(gè)空集合中走一圈就結(jié)束了,相當(dāng)于不攔截任何請(qǐng)求。
此時(shí)重啟項(xiàng)目,你會(huì)發(fā)現(xiàn) /hello
也是可以直接訪問的,就是因?yàn)檫@個(gè)路徑不經(jīng)過任何過濾器。
其實(shí)我覺得目前這中新寫法比以前老的寫法更直觀,更容易讓大家理解到 Spring Security 底層的過濾器鏈工作機(jī)制。
有小伙伴會(huì)說,這寫法跟我以前寫的也不一樣呀!這么配置,我也不知道 Spring Security 中有哪些過濾器,其實(shí),換一個(gè)寫法,我們就可以將這個(gè)配置成以前那種樣子:
@Configuration public class SecurityConfig { @Bean UserDetailsService userDetailsService() { InMemoryUserDetailsManager users = new InMemoryUserDetailsManager(); users.createUser(User.withUsername("javaboy").password("{noop}123").roles("admin").build()); users.createUser(User.withUsername("江南一點(diǎn)雨").password("{noop}123").roles("admin").build()); return users; } @Bean SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http.authorizeRequests() .anyRequest().authenticated() .and() .formLogin() .permitAll() .and() .csrf().disable(); return http.build(); } }
這么寫,就跟以前的寫法其實(shí)沒啥大的差別了。
2. 使用 Lambda
在最新版中,小伙伴們發(fā)現(xiàn),很多常見的方法廢棄了,如下圖:
包括大家熟悉的用來連接各個(gè)配置項(xiàng)的 and() 方法現(xiàn)在也廢棄了,并且按照官方的說法,將在 Spring Security7 中徹底移除該方法。
也就是說,你以后見不到類似下面這樣的配置了:
@Override protected void configure(HttpSecurity http) throws Exception { InMemoryUserDetailsManager users = new InMemoryUserDetailsManager(); users.createUser(User.withUsername("javagirl").password("{noop}123").roles("admin").build()); http.authorizeRequests() .anyRequest().authenticated() .and() .formLogin() .and() .csrf().disable() .userDetailsService(users); http.addFilterAt(loginFilter(), UsernamePasswordAuthenticationFilter.class); }
and() 方法將被移除!
從上面 and 方法的注釋中小伙伴們可以看到,官方現(xiàn)在是在推動(dòng)基于 Lambda 的配置來代替?zhèn)鹘y(tǒng)的鏈?zhǔn)脚渲茫砸院笪覀兊膶懛ň偷酶某上旅孢@樣啦:
@Configuration public class SecurityConfig { @Bean SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(auth -> auth.requestMatchers("/hello").hasAuthority("user").anyRequest().authenticated()) .formLogin(form -> form.loginProcessingUrl("/login").usernameParameter("name").passwordParameter("passwd")) .csrf(csrf -> csrf.disable()) .sessionManagement(session -> session.maximumSessions(1).maxSessionsPreventsLogin(true)); return http.build(); } }
其實(shí),這里的幾個(gè)方法倒不是啥新方法,只不過有的小伙伴可能之前不太習(xí)慣用上面這幾個(gè)方法進(jìn)行配置,習(xí)慣于鏈?zhǔn)脚渲谩?墒峭?,就得慢慢?xí)慣上面這種按照 Lambda 的方式來配置了,配置的內(nèi)容倒很好理解,我覺得沒啥好解釋的。
3. 自定義 JSON 登錄
自定義 JSON 登錄也和之前舊版不太一樣了。
3.1 自定義 JSON 登錄
小伙伴們知道,Spring Security 中默認(rèn)的登錄接口數(shù)據(jù)格式是 key-value 的形式,如果我們想使用 JSON 格式來登錄,那么就必須自定義過濾器或者自定義登錄接口,下面先來和小伙伴們展示一下這兩種不同的登錄形式。
3.1.1 自定義登錄過濾器
Spring Security 默認(rèn)處理登錄數(shù)據(jù)的過濾器是 UsernamePasswordAuthenticationFilter,在這個(gè)過濾器中,系統(tǒng)會(huì)通過 request.getParameter(this.passwordParameter)
的方式將用戶名和密碼讀取出來,很明顯這就要求前端傳遞參數(shù)的形式是 key-value。
如果想要使用 JSON 格式的參數(shù)登錄,那么就需要從這個(gè)地方做文章了,我們自定義的過濾器如下:
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)) { //說明請(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); } } }
看過之前的 Spring Security 系列文章的小伙伴,這段代碼應(yīng)該都是非常熟悉了。
- 首先我們獲取請(qǐng)求頭,根據(jù)請(qǐng)求頭的類型來判斷請(qǐng)求參數(shù)的格式。
- 如果是 JSON 格式的參數(shù),就在 if 中進(jìn)行處理,否則說明是 key-value 形式的參數(shù),那么我們就調(diào)用父類的方法進(jìn)行處理即可。
- JSON 格式的參數(shù)的處理邏輯和 key-value 的處理邏輯是一致的,唯一不同的是參數(shù)的提取方式不同而已。
最后,我們還需要對(duì)這個(gè)過濾器進(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("密碼過期,登錄失敗"); } 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() //任意請(qǐng)求,都要認(rèn)證之后才能訪問 .anyRequest().authenticated() .and() //開啟表單登錄,開啟之后,就會(huì)自動(dòng)配置登錄頁面、登錄接口等信息 .formLogin() //和登錄相關(guān)的 URL 地址都放行 .permitAll() .and() //關(guān)閉 csrf 保護(hù)機(jī)制,本質(zhì)上就是從 Spring Security 過濾器鏈中移除了 CsrfFilter .csrf().disable(); http.addFilterBefore(jsonLoginFilter(), UsernamePasswordAuthenticationFilter.class); return http.build(); } }
這里就是配置一個(gè) JsonLoginFilter 的 Bean,并將之添加到 Spring Security 過濾器鏈中即可。
在 Spring Boot3 之前(Spring Security6 之前),上面這段代碼就可以實(shí)現(xiàn) JSON 登錄了。
但是從 Spring Boot3 開始,這段代碼有點(diǎn)瑕疵了,直接用已經(jīng)無法實(shí)現(xiàn) JSON 登錄了,具體原因下文分析。
3.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ù)通過 JSON 的形式來傳遞。拿到用戶名密碼之后,調(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è)地址可以不用登錄直接訪問 .requestMatchers("/doLogin").permitAll() .anyRequest().authenticated().and() .formLogin() .permitAll() .and() .csrf().disable(); return http.build(); } }
這也算是一種使用 JSON 格式參數(shù)的方案。在 Spring Boot3 之前(Spring Security6 之前),上面這個(gè)方案也是沒有任何問題的。
從 Spring Boot3(Spring Security6) 開始,上面這兩種方案都出現(xiàn)了一些瑕疵。
具體表現(xiàn)就是:當(dāng)你調(diào)用登錄接口登錄成功之后,再去訪問系統(tǒng)中的其他頁面,又會(huì)跳轉(zhuǎn)回登錄頁面,說明訪問登錄之外的其他接口時(shí),系統(tǒng)不知道你已經(jīng)登錄過了。
3.2 原因分析
產(chǎn)生上面問題的原因,主要在于 Spring Security 過濾器鏈中有一個(gè)過濾器發(fā)生變化了:
在 Spring Boot3 之前,Spring Security 過濾器鏈中有一個(gè)名為 SecurityContextPersistenceFilter 的過濾器,這個(gè)過濾器在 Spring Boot2.7.x 中廢棄了,但是還在使用,在 Spring Boot3 中則被從 Spring Security 過濾器鏈中移除了,取而代之的是一個(gè)名為 SecurityContextHolderFilter 的過濾器。
在第一小節(jié)和小伙伴們介紹的兩種 JSON 登錄方案在 Spring Boot2.x 中可以運(yùn)行在 Spring Boot3.x 中無法運(yùn)行,就是因?yàn)檫@個(gè)過濾器的變化導(dǎo)致的。
所以接下來我們就來分析一下這兩個(gè)過濾器到底有哪些區(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)鍵的核心代碼:
- 首先,這個(gè)過濾器位于整個(gè) Spring Security 過濾器鏈的第三個(gè),是非??壳暗?。
- 當(dāng)?shù)卿浾?qǐng)求經(jīng)過這個(gè)過濾器的時(shí)候,首先會(huì)嘗試從 SecurityContextRepository(上文中的 this.repo)中讀取到 SecurityContext 對(duì)象,這個(gè)對(duì)象中保存了當(dāng)前用戶的信息,第一次登錄的時(shí)候,這里實(shí)際上讀取不到任何用戶信息。
- 將讀取到的 SecurityContext 存入到 SecurityContextHolder 中,默認(rèn)情況下,SecurityContextHolder 中通過 ThreadLocal 來保存 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ù)的處理過程中,Spring Security 需要知道當(dāng)前用戶的時(shí)候,會(huì)自動(dòng)去 SecurityContextHolder 中讀取當(dāng)前用戶信息。
這就是 Spring Security 認(rèn)證的一個(gè)大致流程。
然而,到了 Spring Boot3 之后,這個(gè)過濾器被 SecurityContextHolderFilter 取代了,我們來看下 SecurityContextHolderFilter 過濾器的一個(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 的操作。
這下就明白了,用戶登錄成功之后,用戶信息沒有保存到 HttpSession,導(dǎo)致下一次請(qǐng)求到達(dá)的時(shí)候,無法從 HttpSession 中讀取到 SecurityContext 存到 SecurityContextHolder 中,在后續(xù)的執(zhí)行過程中,Spring Security 就會(huì)認(rèn)為當(dāng)前用戶沒有登錄。
這就是問題的原因!
找到原因,那么問題就好解決了。
3.3 問題解決
首先問題出在了過濾器上,直接改過濾器倒也不是不可以,但是,既然 Spring Security 在升級(jí)的過程中拋棄了之前舊的方案,我們又費(fè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)前過濾器中,securityContextRepository 的類型是 RequestAttributeSecurityContextRepository,這個(gè)表示將 SecurityContext 存入到當(dāng)前請(qǐng)求的屬性中,那很明顯,在當(dāng)前請(qǐng)求結(jié)束之后,這個(gè)數(shù)據(jù)就沒了。在 Spring Security 的自動(dòng)化配置類中,將 securityContextRepository 屬性指向了 DelegatingSecurityContextRepository,這是一個(gè)代理的存儲(chǔ)器,代理的對(duì)象是 RequestAttributeSecurityContextRepository 和 HttpSessionSecurityContextRepository,所以在默認(rèn)的情況下,用戶登錄成功之后,在這里就把登錄用戶數(shù)據(jù)存入到 HttpSessionSecurityContextRepository 中了。
當(dāng)我們自定義了登錄過濾器之后,就破壞了自動(dòng)化配置里的方案了,這里使用的 securityContextRepository 對(duì)象就真的是 RequestAttributeSecurityContextRepository 了,所以就導(dǎo)致用戶后續(xù)訪問時(shí)系統(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(); //獲取當(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("密碼過期,登錄失敗"); } 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è)置這個(gè)屬性,是因?yàn)檫@里雖然沒保存最后還是在 SecurityContextPersistenceFilter 過濾器中保存了。
那么對(duì)于自定義登錄接口的問題,解決思路也是類似的:
@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ā)者自己手動(dòng)將數(shù)據(jù)存入到 HttpSession 中,這樣就能確保下個(gè)請(qǐng)求到達(dá)的時(shí)候,能夠從 HttpSession 中讀取到有效的數(shù)據(jù)存入到 SecurityContextHolder 中了。
4. 該如何實(shí)現(xiàn)動(dòng)態(tài)權(quán)限管理
4.1. 權(quán)限開發(fā)思路
先來說權(quán)限開發(fā)的思路,當(dāng)我們?cè)O(shè)計(jì)好 RBAC 權(quán)限之后,具體到代碼層面,我們有兩種實(shí)現(xiàn)思路:
- 直接在接口/Service 層方法上添加權(quán)限注解,這樣做的好處是實(shí)現(xiàn)簡單,但是有一個(gè)問題就是權(quán)限硬編碼,每一個(gè)方法需要什么權(quán)限都是代碼中配置好的,后期如果想通過管理頁面修改是不可能的,要修改某一個(gè)方法所需要的權(quán)限只能改代碼。
- 將請(qǐng)求和權(quán)限的關(guān)系通過數(shù)據(jù)庫來描述,每一個(gè)請(qǐng)求需要什么權(quán)限都在數(shù)據(jù)庫中配置好,當(dāng)請(qǐng)求到達(dá)的時(shí)候,動(dòng)態(tài)查詢,然后判斷權(quán)限是否滿足,這樣做的好處是比較靈活,將來需要修改接口和權(quán)限之間的關(guān)系時(shí),可以通過管理頁面點(diǎn)擊幾下,問題就解決了,不用修改代碼,松哥之前的 vhr 中就是這樣做的。
4.2. 具體實(shí)踐
4.2.1 舊方案回顧
第一個(gè)類是收集權(quán)限元數(shù)據(jù)的類:
@Component public class CustomFilterInvocationSecurityMetadataSource implements FilterInvocationSecurityMetadataSource { @Override public Collection<ConfigAttribute> getAttributes(Object object) throws IllegalArgumentException { //... } @Override public Collection<ConfigAttribute> getAllConfigAttributes() { return null; } @Override public boolean supports(Class<?> clazz) { return true; } }
在 getAttributes 方法中,根據(jù)當(dāng)前請(qǐng)求的 URL 地址(從參數(shù) Object 中可提取出來),然后根據(jù)權(quán)限表中的配置,分析出來當(dāng)前請(qǐng)求需要哪些權(quán)限并返回。
另外我還重寫了一個(gè)決策器,其實(shí)決策器也可以不重寫,就看你自己的需求,如果 Spring Security 自帶的決策器無法滿足你的需求,那么可以自己寫一個(gè)決策器:
@Component public class CustomUrlDecisionManager implements AccessDecisionManager { @Override public void decide(Authentication authentication, Object object, Collection<ConfigAttribute> configAttributes) throws AccessDeniedException, InsufficientAuthenticationException { //... } @Override public boolean supports(ConfigAttribute attribute) { return true; } @Override public boolean supports(Class<?> clazz) { return true; } }
decide 方法就是做決策的地方,第一個(gè)參數(shù)中可以提取出當(dāng)前用戶具備什么權(quán)限,第三個(gè)參數(shù)是當(dāng)前請(qǐng)求需要什么權(quán)限,比較一下就行了,如果當(dāng)前用戶不具備需要的權(quán)限,則直接拋出 AccessDeniedException 異常即可。
最后,通過 Bean 的后置處理器 BeanPostProcessor,將這兩個(gè)配置類放到 Spring Security 的 FilterSecurityInterceptor 攔截器中:
@Override protected void configure(HttpSecurity http) throws Exception { http.authorizeRequests() .withObjectPostProcessor(new ObjectPostProcessor<FilterSecurityInterceptor>() { @Override public <O extends FilterSecurityInterceptor> O postProcess(O object) { object.setAccessDecisionManager(customUrlDecisionManager); object.setSecurityMetadataSource(customFilterInvocationSecurityMetadataSource); return object; } }) .and() //... }
大致上的邏輯就是如此
4.2.2 新方案
不過以上代碼在目前最新的 Spring Security6 中用不了了,不是因?yàn)轭愡^期了,而是因?yàn)轭惐灰瞥耍∧膫€(gè)類被移除了?FilterSecurityInterceptor。
FilterSecurityInterceptor 這個(gè)過濾器以前是做權(quán)限處理的,但是在新版的 Spring Security6 中,這個(gè)攔截器被 AuthorizationFilter 代替了。
老實(shí)說,新版的方案其實(shí)更合理一些,傳統(tǒng)的方案感覺帶有很多前后端不分的影子,現(xiàn)在就往更純粹的前后端分離奔去。
由于新版中連 FilterSecurityInterceptor 都不用了,所以舊版的方案顯然行不通了,新版的方案實(shí)際上更加簡單。
雖然新舊寫法不同,但是核心思路是一模一樣。
我們來看下新版的配置:
@Bean SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http.authorizeHttpRequests(register -> register.anyRequest().access((authentication, object) -> { //表示請(qǐng)求的 URL 地址和數(shù)據(jù)庫的地址是否匹配上了 boolean isMatch = false; //獲取當(dāng)前請(qǐng)求的 URL 地址 String requestURI = object.getRequest().getRequestURI(); List<MenuWithRoleVO> menuWithRole = menuService.getMenuWithRole(); for (MenuWithRoleVO m : menuWithRole) { if (antPathMatcher.match(m.getUrl(), requestURI)) { isMatch = true; //說明找到了請(qǐng)求的地址了 //這就是當(dāng)前請(qǐng)求需要的角色 List<Role> roles = m.getRoles(); //獲取當(dāng)前登錄用戶的角色 Collection<? extends GrantedAuthority> authorities = authentication.get().getAuthorities(); for (GrantedAuthority authority : authorities) { for (Role role : roles) { if (authority.getAuthority().equals(role.getName())) { //說明當(dāng)前登錄用戶具備當(dāng)前請(qǐng)求所需要的角色 return new AuthorizationDecision(true); } } } } } if (!isMatch) { //說明請(qǐng)求的 URL 地址和數(shù)據(jù)庫的地址沒有匹配上,對(duì)于這種請(qǐng)求,統(tǒng)一只要登錄就能訪問 if (authentication.get() instanceof AnonymousAuthenticationToken) { return new AuthorizationDecision(false); } else { //說明用戶已經(jīng)認(rèn)證了 return new AuthorizationDecision(true); } } return new AuthorizationDecision(false); })) .formLogin(form -> //... ) .csrf(csrf -> //... ) .exceptionHandling(e -> //... ) .logout(logout -> //... ); return http.build(); }
核心思路還是和之前一樣,只不過現(xiàn)在的工作都在 access 方法中完成。
access 方法的回調(diào)中有兩個(gè)參數(shù),第一個(gè)參數(shù)是 authentication,很明顯,這就是當(dāng)前登錄成功的用戶對(duì)象,從這里我們就可以提取出來當(dāng)前用戶所具備的權(quán)限。
第二個(gè)參數(shù) object 實(shí)際上是一個(gè) RequestAuthorizationContext,從這個(gè)里邊可以提取出來當(dāng)前請(qǐng)求對(duì)象 HttpServletRequest,進(jìn)而提取出來當(dāng)前請(qǐng)求的 URL 地址,然后依據(jù)權(quán)限表中的信息,判斷出當(dāng)前請(qǐng)求需要什么權(quán)限,再和 authentication 中提取出來的當(dāng)前用戶所具備的權(quán)限進(jìn)行對(duì)比即可。
如果當(dāng)前登錄用戶具備請(qǐng)求所需要的權(quán)限,則返回 new AuthorizationDecision(true);
,否則返回 new AuthorizationDecision(false);
即可。
其實(shí)無論什么框架,只要能把其中一個(gè)版本掌握個(gè) 70%,以后無論它怎么升級(jí),你都能快速上手!
到此這篇關(guān)于Spring Security6 最新版配置及實(shí)現(xiàn)動(dòng)態(tài)權(quán)限管理的文章就介紹到這了,更多相關(guān)Spring Security6動(dòng)態(tài)權(quán)限管理內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
Java?jar打包成exe應(yīng)用程序的詳細(xì)步驟
本文主要介紹了Java?jar打包成exe應(yīng)用程序的詳細(xì)步驟,文中通過示例代碼介紹的非常詳細(xì),具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2022-01-01Spring?Service中的@Service注解的使用小結(jié)
本文主要介紹了Spring?Service中的@Service注解的使用小結(jié),文中通過示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2024-11-11SpringBoot實(shí)現(xiàn)OneDrive文件上傳的詳細(xì)步驟
這篇文章主要介紹了SpringBoot實(shí)現(xiàn)OneDrive文件上傳的詳細(xì)步驟,文中通過代碼示例和圖文講解的非常詳細(xì),對(duì)大家實(shí)現(xiàn)OneDrive文件上傳有一定的幫助,需要的朋友可以參考下2024-02-02Java日常練習(xí)題,每天進(jìn)步一點(diǎn)點(diǎn)(26)
下面小編就為大家?guī)硪黄狫ava基礎(chǔ)的幾道練習(xí)題(分享)。小編覺得挺不錯(cuò)的,現(xiàn)在就分享給大家,也給大家做個(gè)參考。一起跟隨小編過來看看吧,希望可以幫到你2021-07-07Mybatis-Plus通過SQL注入器實(shí)現(xiàn)批量插入的實(shí)踐
本文主要介紹了Mybatis-Plus通過SQL注入器實(shí)現(xiàn)批量插入的實(shí)踐,文中通過示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2022-08-08詳談Java枚舉、靜態(tài)導(dǎo)入、自動(dòng)拆裝箱、增強(qiáng)for循環(huán)、可變參數(shù)
下面小編就為大家?guī)硪黄斦凧ava枚舉、靜態(tài)導(dǎo)入、自動(dòng)拆裝箱、增強(qiáng)for循環(huán)、可變參數(shù)。小編覺得挺不錯(cuò)的,現(xiàn)在就分享給大家,也給大家做個(gè)參考。一起跟隨小編過來看看吧2017-08-08