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中的相应配置。
本文链接://www.dmpip.com//www.dmpip.com/showinfo-26-80872-0.htmlSpringBoot动态权限校验:从零到一实现高效、优雅的解决方案
声明:本网页内容旨在传播知识,若有侵权等问题请及时与本网联系,我们将在第一时间删除处理。邮件:2376512515@qq.com
上一篇: 一篇解决单页面应用首屏调优问题
下一篇: C++中时间相关函数用法详解