SpringSecurity提供的注解權限校驗適合的場景是系統中僅有固定的幾個角色,且角色的憑證不可修改(如果修改需要改動代碼)。
@PreAuthorize("hasAuthority('ROLE_TELLER')") public Account post(Account account, double amount);
注:ROLE_TELLER是寫死的。
后端系統的訪問請求有以下幾種類型:
<!--springSecurity安全框架--><dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> <version>2.3.4.RELEASE</version></dependency><!-- 默認通過SESSIONId改為通過請求頭與redis配合驗證session --><dependency> <groupId>org.springframework.session</groupId> <artifactId>spring-session-data-redis</artifactId> <version>2.3.1.RELEASE</version></dependency><!--redis支持--><dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> <version>2.3.4.RELEASE</version></dependency>
注:springBoot版本也是2.3.4.RELEASE,如果有版本對應問題,自行解決。有用到swagger,為了便于測試。
WebSecurityConfig作為springSecurity的主配置文件。
@Configuration@EnableWebSecuritypublic class WebSecurityConfig extends WebSecurityConfigurerAdapter { /** * Swagger等靜態資源不進行攔截 */ @Override public void configure(WebSecurity web) { web.ignoring().antMatchers( "/*.html", "/favicon.ico", "/**/*.html", "/**/*.css", "/**/*.js", "/error", "/webjars/**", "/resources/**", "/swagger-ui.html", "/swagger-resources/**", "/v2/api-docs"); } @Override protected void configure(HttpSecurity http) throws Exception { http.authorizeRequests() //配置一些不需要登錄就可以訪問的接口 .antMatchers("/demo/**", "/about/**").permitAll() //任何尚未匹配的URL只需要用戶進行身份驗證 .anyRequest().authenticated() .and() .formLogin()//允許用戶進行基于表單的認證 .loginPage("/mylogin"); }}
注:證明可以訪問靜態資源不會被攔截
我們需要自定義:
需要實現 AuthenticationSuccessHandler
@Componentpublic class CustomAuthenticationSuccessHandler implements AuthenticationSuccessHandler { private static final Logger LOGGER = LoggerFactory.getLogger(CustomAuthenticationSuccessHandler.class); @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException { response.setContentType(MediaType.APPLICATION_JSON_VALUE); response.setCharacterEncoding(StandardCharsets.UTF_8.toString()); //登錄成功返回的認證體,具體格式在后面的登錄認證管理器中 String responseJson = JackJsonUtil.object2String(ResponseFactory.success(authentication)); if (LOGGER.isDebugEnabled()) { LOGGER.debug("登錄成功!"); } response.getWriter().write(responseJson); }}
實現 AuthenticationFailureHandler
@Componentpublic class CustomAuthenticationFailureHandler implements AuthenticationFailureHandler { private static final Logger LOGGER = LoggerFactory.getLogger(CustomAuthenticationFailureHandler.class); @Override public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException { String errorMsg; if (StringUtils.isNotBlank(e.getMessage())) { errorMsg = e.getMessage(); } else { errorMsg = CodeMsgEnum.LOG_IN_FAIL.getMsg(); } response.setContentType(MediaType.APPLICATION_JSON_VALUE); response.setCharacterEncoding(StandardCharsets.UTF_8.toString()); String responseJson = JackJsonUtil.object2String(ResponseFactory.fail(CodeMsgEnum.LOG_IN_FAIL,errorMsg)); if (LOGGER.isDebugEnabled()) { LOGGER.debug("認證失敗!"); } response.getWriter().write(responseJson); }}
實現 AuthenticationProvider ,負責具體的身份認證(一般數據庫認證,在登錄過濾器過濾掉請求后傳入)
@Componentpublic class UserVerifyAuthenticationProvider implements AuthenticationProvider { private PasswordEncoder passwordEncoder; @Autowired private UserService userService; @Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { String userName = (String) authentication.getPrincipal(); // Principal 主體,一般指用戶名 String passWord = (String) authentication.getCredentials(); //Credentials 網絡憑證,一般指密碼 //通過賬號去數據庫查詢用戶以及用戶擁有的角色信息 UserRoleVo userRoleVo = userService.findUserRoleByAccount(userName); //數據庫密碼 String encodedPassword = userRoleVo.getPassWord(); //credentials憑證即為前端傳入密碼,因為前端一般用Base64加密過所以需要解密。 String credPassword = new String(Base64Utils.decodeFromString(passWord), StandardCharsets.UTF_8); // 驗證密碼:前端明文,數據庫密文 passwordEncoder = new MD5Util(); if (!passwordEncoder.matches(credPassword, encodedPassword)) { throw new AuthenticationServiceException("賬號或密碼錯誤!"); } //ps:GrantedAuthority對認證主題的應用層面的授權,含當前用戶的權限信息,通常使用角色表示 List<GrantedAuthority> roles = new LinkedList<>(); List<Role> roleList = userRoleVo.getRoleList(); roleList.forEach(role -> { SimpleGrantedAuthority roleId = new SimpleGrantedAuthority(role.getRoleId().toString()); roles.add(roleId); }); UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(userName, passWord, roles); token.setDetails(userRoleVo);//這里可以放用戶的詳細信息 return token; } @Override public boolean supports(Class<?> authentication) { return false; }}
LoginFilter.java繼承UsernamePasswordAuthenticationFilter,負責過濾登錄請求并交由登錄認證管理器進行具體的認證。
public class LoginFilter extends UsernamePasswordAuthenticationFilter { private UserVerifyAuthenticationProvider authenticationManager; /** * @param authenticationManager 認證管理器 * @param successHandler 認證成功處理類 * @param failureHandler 認證失敗處理類 */ public LoginFilter(UserVerifyAuthenticationProvider authenticationManager, CustomAuthenticationSuccessHandler successHandler, CustomAuthenticationFailureHandler failureHandler) { //設置認證管理器(對登錄請求進行認證和授權) this.authenticationManager = authenticationManager; //設置認證成功后的處理類 this.setAuthenticationSuccessHandler(successHandler); //設置認證失敗后的處理類 this.setAuthenticationFailureHandler(failureHandler); //可以自定義登錄請求的url super.setFilterProcessesUrl("/myLogin"); } @Override public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { try { //轉換請求入參 UserDTO loginUser = new ObjectMapper().readValue(request.getInputStream(), UserDTO.class); //入參傳入認證管理器進行認證 return authenticationManager.authenticate( new UsernamePasswordAuthenticationToken(loginUser.getUserName(), loginUser.getPassWord()) ); } catch (IOException e) { e.printStackTrace(); return null; } }}
最后配置到WebSecurityConfig中:
@Configuration@EnableWebSecuritypublic class WebSecurityConfig extends WebSecurityConfigurerAdapter { @Autowired private UserVerifyAuthenticationProvider authenticationManager;//認證用戶類 @Autowired private CustomAuthenticationSuccessHandler successHandler;//登錄認證成功處理類 @Autowired private CustomAuthenticationFailureHandler failureHandler;//登錄認證失敗處理類 /** * Swagger等靜態資源不進行攔截 */ @Override public void configure(WebSecurity web) { web.ignoring().antMatchers( "/*.html", "/favicon.ico", "/**/*.html", "/**/*.css", "/**/*.js", "/error", "/webjars/**", "/resources/**", "/swagger-ui.html", "/swagger-resources/**", "/v2/api-docs"); } @Override protected void configure(HttpSecurity http) throws Exception { http.authorizeRequests() //配置一些不需要登錄就可以訪問的接口 .antMatchers("/demo/**", "/about/**").permitAll() //任何尚未匹配的URL只需要用戶進行身份驗證 .anyRequest().authenticated() .and() //配置登錄過濾器 .addFilter(new LoginFilter(authenticationManager, successHandler, failureHandler)) .csrf().disable(); }}
訪問登錄請求:
成功進入LoginFilter
圖片
session: store-type: redis redis: namespace: spring:session:admin # session 無操作失效時間 30 分鐘 timeout: 1800
設置token放入返回的header中需要在WebSecurityConfig中加入
/** * 配置 HttpSessionIdResolver Bean * 登錄之后將會在 Response Header x-auth-token 中 返回當前 sessionToken * 將token存儲在前端 每次調用的時候 Request Header x-auth-token 帶上 sessionToken */@Beanpublic HttpSessionIdResolver httpSessionIdResolver() { return HeaderHttpSessionIdResolver.xAuthToken();}
關于安全頭信息可以參考:
安全請求頭需要設置WebSecurityConfig中加入
protected void configure(HttpSecurity http) throws Exception { http.authorizeRequests() //配置一些不需要登錄就可以訪問的接口 .antMatchers("/demo/**", "/about/**").permitAll() //任何尚未匹配的URL只需要用戶進行身份驗證 .anyRequest().authenticated() .and() //配置登錄過濾器 .addFilter(new LoginFilter(authenticationManager, successHandler, failureHandler)) .csrf().disable(); //配置頭部 http.headers() .contentTypeOptions() .and() .xssProtection() .and() //禁用緩存 .cacheControl() .and() .httpStrictTransportSecurity() .and() //禁用頁面鑲嵌frame劫持安全協議 // 防止iframe 造成跨域 .frameOptions().disable(); }
進行登錄測試,驗證結果:
圖片
注:響應中有token
查看redis。成功保存進了redis
圖片
Spring Security使用FilterSecurityInterceptor過濾器來進行URL權限校驗,實際使用流程大致如下:
正常情況的接口權限判斷:
1、定義一個MyFilterInvocationSecurityMetadataSource實現FilterInvocationSecurityMetadataSource類,重寫getAttributes方法。
方法的作用是:返回哪些角色可以訪問當前url,這個肯定是從數據庫中獲取。要注意的是對于PathVariable傳參的url,數據庫中存的是這樣的:/getUserByName/{name}。但實際訪問的url中name是具體的值。類似的/user/getUserById 也可以匹配 /user/getUserById?1。
package com.aliyu.security.provider;import com.aliyu.service.role.RoleService;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.security.access.ConfigAttribute;import org.springframework.security.access.SecurityConfig;import org.springframework.security.web.FilterInvocation;import org.springframework.security.web.access.intercept.FilterInvocationSecurityMetadataSource;import org.springframework.security.web.util.matcher.AntPathRequestMatcher;import org.springframework.stereotype.Component;import javax.servlet.http.HttpServletRequest;import java.util.Collection;import java.util.List;import java.util.Map;/** *@create: *@description: 第一步:數據庫查詢所有權限出來: * 之所以要所有權限,因為數據庫url和實際請求url并不能直接匹配需要。比方:/user/getUserById 匹配 /user/getUserById?1 * 第二步:通過httpUrl匹配器找出允許訪問當前請求的角色列表(哪些角色可以訪問此請求) */@Componentpublic class MyFilterInvocationSecurityMetadataSource implements FilterInvocationSecurityMetadataSource { @Autowired private RoleService roleService; /** * 返回當前URL允許訪問的角色列表 * @param object * @return * @throws IllegalArgumentException */ @Override public Collection<ConfigAttribute> getAttributes(Object object) throws IllegalArgumentException { //入參轉為HttpServletRequest FilterInvocation fi = (FilterInvocation) object; HttpServletRequest request = fi.getRequest(); //從數據庫中查詢系統所有的權限,格式為<"權限url","能訪問url的逗號分隔的roleid"> List<Map<String, String>> allUrlRoleMap = roleService.getAllUrlRoleMap(); for (Map<String, String> urlRoleMap : allUrlRoleMap) { String url = urlRoleMap.get("url"); String roles = urlRoleMap.get("roles"); //new AntPathRequestMatcher創建httpUrl匹配器:里面url匹配規則已經給我們弄好了, // 能夠支持校驗PathVariable傳參的url(例如:/getUserByName/{name}) // 也能支持 /user/getUserById 匹配 /user/getUserById?1 AntPathRequestMatcher matcher = new AntPathRequestMatcher(url); if (matcher.matches(request)){ //當前請求與httpUrl匹配器進行匹配 return SecurityConfig.createList(roles.split(",")); } } return null; } @Override public Collection<ConfigAttribute> getAllConfigAttributes() { return null; } @Override public boolean supports(Class<?> clazz) { return FilterInvocation.class.isAssignableFrom(clazz); }}
注:
1. 方案一是初始化的時候加載所有權限,一次就好了。
2. 方案二每次請求都會去重新加載系統所有權限,好處就是不用擔心權限修改的問題。(本次實現方案)
3. 方案三利用Redis緩存
定義一個MyAccessDecisionManager:通過實現AccessDecisionManager接口自定義一個決策管理器,判斷是否有訪問權限。上一步MyFilterInvocationSecurityMetadataSource中返回的當前請求可以訪問角色列表會傳到這里的decide方法里面(如果沒有角色的話,不會進入decide方法。
正常情況你訪問的url必然和某個角色關聯,如果沒有關聯就不應該可以訪問)。decide方法傳了當前登錄用戶擁有的角色,通過判斷用戶擁有的角色中是否有一個角色和當前url可以訪問的角色匹配。如果匹配,權限校驗通過。
package com.aliyu.security.provider;import org.apache.commons.lang3.StringUtils;import org.springframework.security.access.AccessDecisionManager;import org.springframework.security.access.AccessDeniedException;import org.springframework.security.access.ConfigAttribute;import org.springframework.security.authentication.AnonymousAuthenticationToken;import org.springframework.security.authentication.InsufficientAuthenticationException;import org.springframework.security.core.Authentication;import org.springframework.security.core.GrantedAuthority;import org.springframework.security.web.FilterInvocation;import org.springframework.stereotype.Component;import java.util.Collection;import java.util.Iterator;/** *@create: *@description: 接口權限判斷(根據MyFilterInvocationSecurityMetadataSource獲取到的請求需要的角色 * 和當前登錄人的角色進行比較) */@Componentpublic class MyAccessDecisionManager implements AccessDecisionManager { @Override public void decide(Authentication authentication, Object object, Collection<ConfigAttribute> configAttributes) throws AccessDeniedException, InsufficientAuthenticationException { //循環請求需要的角色,只要當前用戶擁有的角色中包含請求需要的角色中的一個,就算通過。 Iterator<ConfigAttribute> iterator = configAttributes.iterator(); while(iterator.hasNext()){ ConfigAttribute configAttribute = iterator.next(); String needCode = configAttribute.getAttribute(); //獲取到了登錄用戶的所有角色 Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities(); for (GrantedAuthority authority : authorities) { if (StringUtils.equals(authority.getAuthority(), needCode)) { return; } } } throw new AccessDeniedException("當前訪問沒有權限"); } @Override public boolean supports(ConfigAttribute attribute) { return false; } @Override public boolean supports(Class<?> clazz) { return FilterInvocation.class.isAssignableFrom(clazz); }}
1、定義一個CustomAuthenticationEntryPoint實現AuthenticationEntryPoint處理匿名用戶訪問無權限資源(可以理解為未登錄的用戶訪問,確實有些接口是可以不登錄也能訪問的,比較少,我們在WebSecurityConfig已經配置過了。如果多的話,需要另外考慮從數據庫中獲取,并且權限需要加一個標志它為匿名用戶可訪問)。
package com.aliyu.security.handler;import com.aliyu.common.util.JackJsonUtil;import com.aliyu.entity.common.vo.ResponseFactory;import com.aliyu.security.constant.MessageConstant;import org.slf4j.Logger;import org.slf4j.LoggerFactory;import org.springframework.http.MediaType;import org.springframework.security.core.AuthenticationException;import org.springframework.security.web.AuthenticationEntryPoint;import javax.servlet.http.HttpServletRequest;import javax.servlet.http.HttpServletResponse;import java.io.IOException;import java.nio.charset.StandardCharsets;import static com.aliyu.entity.common.exception.CodeMsgEnum.MOVED_PERMANENTLY;/** * 未登錄重定向處理器 * <p> * 未登錄狀態下訪問需要登錄的接口 * * @author */public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint { private static final Logger LOGGER = LoggerFactory.getLogger(CustomAuthenticationEntryPoint.class); @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException { response.setContentType(MediaType.APPLICATION_JSON_VALUE); response.setCharacterEncoding(StandardCharsets.UTF_8.toString()); //原來不需要登錄的接口,現在需要登錄了,所以叫永久移動 String message = JackJsonUtil.object2String( ResponseFactory.fail(MOVED_PERMANENTLY, MessageConstant.NOT_LOGGED_IN) ); if (LOGGER.isDebugEnabled()) { LOGGER.debug("未登錄重定向!"); } response.getWriter().write(message); }}
2、定義一個CustomAccessDeniedHandler 實現AccessDeniedHandler處理登陸認證過的用戶訪問無權限資源。
package com.aliyu.security.handler;import com.aliyu.common.util.JackJsonUtil;import com.aliyu.entity.common.exception.CodeMsgEnum;import com.aliyu.entity.common.vo.ResponseFactory;import com.aliyu.security.constant.MessageConstant;import org.slf4j.Logger;import org.slf4j.LoggerFactory;import org.springframework.http.MediaType;import org.springframework.security.access.AccessDeniedException;import org.springframework.security.web.access.AccessDeniedHandler;import javax.servlet.http.HttpServletRequest;import javax.servlet.http.HttpServletResponse;import java.io.IOException;import java.nio.charset.StandardCharsets;/** * 拒絕訪問處理器(登錄狀態下,訪問沒有權限的方法時會進入此處理器) * * @author */public class CustomAccessDeniedHandler implements AccessDeniedHandler { private static final Logger LOGGER = LoggerFactory.getLogger(CustomAccessDeniedHandler.class); @Override public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException e) throws IOException { response.setContentType(MediaType.APPLICATION_JSON_VALUE); response.setCharacterEncoding(StandardCharsets.UTF_8.toString()); String message = JackJsonUtil.object2String( ResponseFactory.fail(CodeMsgEnum.UNAUTHORIZED, MessageConstant.NO_ACCESS) ); if(LOGGER.isDebugEnabled()){ LOGGER.debug("沒有權限訪問!"); } response.getWriter().write(message); }}
配置到WebSecurityConfig
package com.aliyu.security.config;import com.aliyu.filter.LoginFilter;import com.aliyu.security.handler.*;import com.aliyu.security.provider.MyAccessDecisionManager;import com.aliyu.security.provider.MyFilterInvocationSecurityMetadataSource;import com.aliyu.security.provider.UserVerifyAuthenticationProvider;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;import org.springframework.security.config.annotation.ObjectPostProcessor;import org.springframework.security.config.annotation.web.builders.HttpSecurity;import org.springframework.security.config.annotation.web.builders.WebSecurity;import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;import org.springframework.security.crypto.password.PasswordEncoder;import org.springframework.security.web.access.intercept.FilterSecurityInterceptor;import org.springframework.session.web.http.HeaderHttpSessionIdResolver;import org.springframework.session.web.http.HttpSessionIdResolver;@Configuration@EnableWebSecuritypublic class WebSecurityConfig extends WebSecurityConfigurerAdapter { @Autowired private UserVerifyAuthenticationProvider authenticationManager;//認證用戶類 @Autowired private CustomAuthenticationSuccessHandler successHandler;//登錄認證成功處理類 @Autowired private CustomAuthenticationFailureHandler failureHandler;//登錄認證失敗處理類 @Autowired private MyFilterInvocationSecurityMetadataSource securityMetadataSource;//返回當前URL允許訪問的角色列表 @Autowired private MyAccessDecisionManager accessDecisionManager;//除登錄登出外所有接口的權限校驗 /** * 密碼加密 * @return */ @Bean @ConditionalOnMissingBean(PasswordEncoder.class) public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } /** * 配置 HttpSessionIdResolver Bean * 登錄之后將會在 Response Header x-auth-token 中 返回當前 sessionToken * 將token存儲在前端 每次調用的時候 Request Header x-auth-token 帶上 sessionToken */ @Bean public HttpSessionIdResolver httpSessionIdResolver() { return HeaderHttpSessionIdResolver.xAuthToken(); } /** * Swagger等靜態資源不進行攔截 */ @Override public void configure(WebSecurity web) { web.ignoring().antMatchers( "/*.html", "/favicon.ico", "/**/*.html", "/**/*.css", "/**/*.js", "/error", "/webjars/**", "/resources/**", "/swagger-ui.html", "/swagger-resources/**", "/v2/api-docs"); } @Override protected void configure(HttpSecurity http) throws Exception { http.authorizeRequests() //配置一些不需要登錄就可以訪問的接口 .antMatchers("/demo/**", "/about/**").permitAll() //任何尚未匹配的URL只需要用戶進行身份驗證 .anyRequest().authenticated() //登錄后的接口權限校驗 .withObjectPostProcessor(new ObjectPostProcessor<FilterSecurityInterceptor>() { @Override public <O extends FilterSecurityInterceptor> O postProcess(O object) { object.setAccessDecisionManager(accessDecisionManager); object.setSecurityMetadataSource(securityMetadataSource); return object; } }) .and() //配置登出處理 .logout().logoutUrl("/logout") .logoutSuccessHandler(new CustomLogoutSuccessHandler()) .clearAuthentication(true) .and() //用來解決匿名用戶訪問無權限資源時的異常 .exceptionHandling().authenticationEntryPoint(new CustomAuthenticationEntryPoint()) //用來解決登陸認證過的用戶訪問無權限資源時的異常 .accessDeniedHandler(new CustomAccessDeniedHandler()) .and() //配置登錄過濾器 .addFilter(new LoginFilter(authenticationManager, successHandler, failureHandler)) .csrf().disable(); //配置頭部 http.headers() .contentTypeOptions() .and() .xssProtection() .and() //禁用緩存 .cacheControl() .and() .httpStrictTransportSecurity() .and() //禁用頁面鑲嵌frame劫持安全協議 // 防止iframe 造成跨域 .frameOptions().disable(); }}
特別的,我們認為如果一個接口屬于當前系統,那么它就應該有對應可以訪問的角色。這樣的接口才會被我們限制住。如果一個接口只是在當前系統定義了,而沒有指明它的角色,這樣的接口是不會被我們限制的。
注意點
下面的代碼,本意是想配置一些不需要登錄也可以訪問的接口。
圖片
但是測試的時候發現,任何接口的調用都會進入這里MyFilterInvocationSecurityMetadataSource getAttriButes方法,包括我webSecurityConfig里配置的不需要登錄的url。結果就是不需要登錄的url和沒有配置角色的接口權限一樣待遇,要么都能訪問,要么都不能訪問!!!
所以如上圖,我在這里配置了不需要登錄的接口(因為不知道如何從webSercurityConfig中獲取,干脆就配置在這里了),去掉了webSercurityConfig中的相應配置。
本文鏈接:http://www.www897cc.com/showinfo-26-80872-0.htmlSpringBoot動態權限校驗:從零到一實現高效、優雅的解決方案
聲明:本網頁內容旨在傳播知識,若有侵權等問題請及時與本網聯系,我們將在第一時間刪除處理。郵件:2376512515@qq.com
上一篇: 一篇解決單頁面應用首屏調優問題
下一篇: C++中時間相關函數用法詳解