本篇文章环境:Spring Boot + Mybatis + Spring Security + Redis + JWT
数据库设计
Web 的安全控制一般分为两个部分,一个是认证,一个是授权。认证即判断是否为合法用户,简单的说就是登录。用户名和密码匹配成功即认证成功。授权是基于已认证的前提下,根据用户的不同权限,开放不同的资源(本文简单化处理,认为资源就是 API,实际的资源可能包括菜单、静态图片等)。一般 RBAC 权限控制有三层,即:用户<–>角色<–>权限,用户与角色是多对多,角色和权限也是多对多,最后由权限控制资源(URL)的访问。本文为了便于处理,在数据库设计时对表内的字段进行了简化。
在这里我们先暂时不考虑权限,只考虑用户<–>角色<–>资源。(源码被注释的部分中含有用户、角色、权限三层控制)
认证管理
在这一步中,我们需要自定义 UserDetailsService ,将用户信息和权限注入进来,为后面的授权做准备。
在实现UserDetailsService之后,需要重写 loadUserByUsername 方法,参数是用户输入的用户名。返回值是UserDetails,这是一个接口,一般使用它的子类org.springframework.security.core.userdetails.User,它有三个参数,分别是用户名、密码和权限集。(实际开发中,我们可以将实体类中的 User 继承org.springframework.security.core.userdetails.User以满足更多需求)
并且实际应用中,为了减少对数据库的访问次数,我们通常会将权限集放入缓存中,下次可以直接从缓存中获取,可以有效提高效率。
UserDetailsService 实现类
package com.security.service; import com.security.mapper.APIMapper; import com.security.mapper.AuthoritiesMapper; import com.security.mapper.RoleMapper; import com.security.mapper.UserMapper; import com.security.pojo.SysUser; import com.security.utils.RedisUtils; import org.apache.commons.lang3.StringUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.AuthorityUtils; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.User; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import java.util.ArrayList; import java.util.List; @Service("userDetailsService") public class UserDetailServiceImpl implements UserDetailsService { @Autowired private PasswordEncoder passwordEncoder; @Autowired private UserMapper userMapper; @Autowired private RoleMapper roleMapper; @Autowired private AuthoritiesMapper authoritiesMapper; @Autowired private RedisUtils redisUtils; //自定义的登录逻辑 @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { SysUser user = userMapper.queryUserByUsername(username); //根据用户名去数据库进行查询,如不存在则抛出异常 if (user == null){ throw new UsernameNotFoundException("用户不存在"); } List<GrantedAuthority> authorities = new ArrayList<>(); //方法一:使用用户、角色、资源建立关系,直接使用角色控制权限 List<String> codeList = roleMapper.queryUserRole(user.getUsername()); //添加权限信息进入缓存 redisUtils.set(username, StringUtils.join(codeList,","),60 * 60); //方法二:添加权限(资源表),通过建立用户、角色、权限、资源之间的关系,使用"权限"实现按钮级别的权限控制 // List<String> codeList = authoritiesMapper.queryAuthoritiesList(user.getUsername()); codeList.forEach(code ->{ SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority(code); authorities.add(simpleGrantedAuthority); }); return new User(username, user.getPassword(), authorities); } }
部分 mapper 接口和具体实现
package com.security.mapper; import com.security.pojo.Role; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import org.springframework.stereotype.Repository; import java.util.List; @Mapper @Repository public interface RoleMapper { List<String> queryUserRole(@Param("username")String username); List<Role> selectListByUrl(String url); } ———————————————— package com.security.mapper; import com.security.pojo.SysUser; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import org.springframework.stereotype.Repository; @Mapper @Repository public interface UserMapper { SysUser queryUserByUsername(@Param("username")String username); }
<?xml version="1.0" encoding="UTF-8" ?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> <mapper namespace="com.security.mapper.RoleMapper"> <select id="queryUserRole" resultType="String"> select r.code from user_role ur left join sys_user u on ur.user_id = u.id left join sys_role r on ur.role_id = r.id where u.username = #{username} </select> <select id="selectListByUrl" resultType="Role"> SELECT r.* FROM sys_role r LEFT JOIN sys_role_api ra ON ra.role_id = r.id LEFT JOIN sys_api sa ON sa.id = ra.api_id WHERE sa.api_url = #{url} </select> </mapper> ———————————————— <?xml version="1.0" encoding="UTF-8" ?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> <mapper namespace="com.security.mapper.UserMapper"> <select id="queryUserByUsername" resultType="SysUser"> select * from sys_user where username = #{username} </select> </mapper> ————————————————
接着,创建一个配置类并继承自 WebSecurityConfigurerAdapter,并重写 configure(AuthenticationManagerBuilder auth) 方法就可以完成简单的登录认证了。在这一步里,我们需要自定义两个处理器,分别是成功处理器和失败处理器,以及统一的前后端通信消息体格式。
成功处理器
package com.security.config; import com.security.common.ResponseBody; import com.security.utils.JwtUtils; import com.security.utils.ResponseBodyUtil; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.core.Authentication; import org.springframework.security.web.authentication.AuthenticationSuccessHandler; import org.springframework.stereotype.Component; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; @Component public class AjaxAuthenticationSuccessHandler extends JSONAuthentication implements AuthenticationSuccessHandler { //JWT处理工具类 @Autowired private JwtUtils jwtUtils; @Override public void onAuthenticationSuccess(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Authentication authentication) throws IOException, ServletException { ResponseBody responseBody = ResponseBodyUtil.success(); //生成jwt Token String jwt = jwtUtils.generateToken(authentication.getName()); httpServletResponse.setHeader(jwtUtils.getHeader(), jwt); //继承封装的输出JSON格式类,并调用父类方法即可 this.WriteJSON(httpServletRequest,httpServletResponse,responseBody); } }
失败处理器
package com.security.config; import com.security.common.ResponseBody; import com.security.common.ResponseCode; import com.security.utils.ResponseBodyUtil; import org.springframework.security.authentication.*; import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.authentication.AuthenticationFailureHandler; import org.springframework.stereotype.Component; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; @Component public class AjaxAuthenticationFailureHandler extends JSONAuthentication implements AuthenticationFailureHandler { @Override public void onAuthenticationFailure(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException { ResponseBody responseBody = null; if (e instanceof AccountExpiredException) { //账号过期 responseBody = ResponseBodyUtil.fail(ResponseCode.USER_ACCOUNT_EXPIRED); } else if (e instanceof BadCredentialsException) { //密码错误 responseBody = ResponseBodyUtil.fail(ResponseCode.USER_CREDENTIALS_ERROR); } else if (e instanceof CredentialsExpiredException) { //密码过期 responseBody = ResponseBodyUtil.fail(ResponseCode.USER_CREDENTIALS_EXPIRED); } else if (e instanceof DisabledException) { //账号不可用 responseBody = ResponseBodyUtil.fail(ResponseCode.USER_ACCOUNT_DISABLE); } else if (e instanceof LockedException) { //账号锁定 responseBody = ResponseBodyUtil.fail(ResponseCode.USER_ACCOUNT_LOCKED); } else if (e instanceof InternalAuthenticationServiceException) { //用户不存在 responseBody = ResponseBodyUtil.fail(ResponseCode.USER_ACCOUNT_NOT_EXIST); }else{ //其他错误 responseBody = ResponseBodyUtil.fail(ResponseCode.COMMON_FAIL); } //继承封装的输出JSON格式类,并调用父类方法即可 this.WriteJSON(httpServletRequest,httpServletResponse,responseBody); } }
封装的 JSONAuthentication 抽象类
该抽象类主要是对处理器内都需要实现的一些功能的一个封装
package com.security.config; import com.alibaba.fastjson.JSON; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.io.PrintWriter; public abstract class JSONAuthentication { /** * 输出JSON格式数据 * @param httpServletRequest * @param httpServletResponse * @param obj * @throws IOException * @throws ServletException */ protected void WriteJSON(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object obj) throws IOException, ServletException { //设置编码格式 httpServletResponse.setContentType("text/json;charset=utf-8"); //处理跨域问题 httpServletResponse.setHeader("Access-Control-Allow-Origin", "*"); httpServletResponse.setHeader("Access-Control-Allow-Methods", "POST, GET"); //输出JSON PrintWriter out = httpServletResponse.getWriter(); out.write(JSON.toJSONString(obj)); out.flush(); out.close(); } }
封装的消息体
package com.security.common; import lombok.Data; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; import java.io.Serializable; @Data @Getter @Setter @NoArgsConstructor public class ResponseBody<T> implements Serializable { private Boolean success; private Integer statusCode; private String msg; private T data; public ResponseBody(boolean success) { this.success = success; this.statusCode = success ? ResponseCode.SUCCESS.getCode() : ResponseCode.COMMON_FAIL.getCode(); this.msg = success ? ResponseCode.SUCCESS.getMessage() : ResponseCode.COMMON_FAIL.getMessage(); } public ResponseBody(boolean success, ResponseCode resultEnum) { this.success = success; this.statusCode = success ? ResponseCode.SUCCESS.getCode() : (resultEnum == null ? ResponseCode.COMMON_FAIL.getCode() : resultEnum.getCode()); this.msg = success ? ResponseCode.SUCCESS.getMessage() : (resultEnum == null ? ResponseCode.COMMON_FAIL.getMessage() : resultEnum.getMessage()); } public ResponseBody(boolean success, T data) { this.success = success; this.statusCode = success ? ResponseCode.SUCCESS.getCode() : ResponseCode.COMMON_FAIL.getCode(); this.msg = success ? ResponseCode.SUCCESS.getMessage() : ResponseCode.COMMON_FAIL.getMessage(); this.data = data; } public ResponseBody(boolean success, ResponseCode resultEnum, T data) { this.success = success; this.statusCode = success ? ResponseCode.SUCCESS.getCode() : (resultEnum == null ? ResponseCode.COMMON_FAIL.getCode() : resultEnum.getCode()); this.msg = success ? ResponseCode.SUCCESS.getMessage() : (resultEnum == null ? ResponseCode.COMMON_FAIL.getMessage() : resultEnum.getMessage()); this.data = data; } } ———————————————— package com.security.common; /** * 状态码定义 * #1001~1999 区间表示参数错误 * #2001~2999 区间表示用户错误 * #3001~3999 区间表示接口异常 */ public enum ResponseCode { /* 成功 */ SUCCESS(200, "成功"), /* 默认失败 */ COMMON_FAIL(999, "失败"), /* 参数错误:1000~1999 */ PARAM_NOT_VALID(1001, "参数无效"), PARAM_IS_BLANK(1002, "参数为空"), PARAM_TYPE_ERROR(1003, "参数类型错误"), PARAM_NOT_COMPLETE(1004, "参数缺失"), /* 用户错误 */ USER_NOT_LOGIN(2001, "用户未登录"), USER_ACCOUNT_EXPIRED(2002, "账号已过期"), USER_CREDENTIALS_ERROR(2003, "密码错误"), USER_CREDENTIALS_EXPIRED(2004, "密码过期"), USER_ACCOUNT_DISABLE(2005, "账号不可用"), USER_ACCOUNT_LOCKED(2006, "账号被锁定"), USER_ACCOUNT_NOT_EXIST(2007, "账号不存在"), USER_ACCOUNT_ALREADY_EXIST(2008, "账号已存在"), USER_ACCOUNT_USE_BY_OTHERS(2009, "账号多点登录,账号下线"), USER_SESSION_INVALID(2010,"登录超时"), /* 业务错误 */ NO_PERMISSION(3001, "没有权限"); private Integer code; private String message; ResponseCode(Integer code, String message){ this.code = code; this.message = message; } public Integer getCode() { return code; } public void setCode(Integer code) { this.code = code; } public String getMessage() { return message; } public void setMessage(String message) { this.message = message; } /** * 根据code获取message * * @param code * @return */ public static String getMessageByCode(Integer code) { for (ResponseCode ele : values()) { if (ele.getCode().equals(code)) { return ele.getMessage(); } } return null; } } ————————————————
WebSecurityConfigurerAdapter
package com.security.config; import com.security.common.CaptchaFilter; import com.security.common.JwtAuthenticationFilter; import org.springframework.beans.factory.annotation.Autowired; 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.authentication.builders.AuthenticationManagerBuilder; import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.core.userdetails.UserDetailsService; 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.security.web.authentication.UsernamePasswordAuthenticationFilter; @Configuration public class SecurityConfig extends WebSecurityConfigurerAdapter { @Bean public PasswordEncoder passwordEncoder(){ return new BCryptPasswordEncoder(); } @Autowired private UserDetailsService userDetailsService; /** * 登录成功的处理器 */ @Autowired private AjaxAuthenticationSuccessHandler ajaxAuthenticationSuccessHandler; /** * 登录失败的处理器 */ @Autowired private AjaxAuthenticationFailureHandler ajaxAuthenticationFailureHandler; @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { //自定义数据库登录逻辑 auth.userDetailsService(userDetailsService) //设置加密方式 .passwordEncoder(passwordEncoder()); } }
至此,我们已经可以完成一个非常简单的认证。
注意:在配置完成 Spring Security 之后, Spring 会赠送 “/login”以及 "/logout"接口,无需自己实现。
但是,我们实际应用中不可能每次访问资源都重新登录,而且,前文中我们明确拒绝使用 cookie,那用什么方式实现身份验证呢?答案是 JWT。
JWT 过滤器
在这一步中,我们需要通过继承 BasicAuthenticationFilter 类并重写 doFilterInternal 方法来实现 JWT 解析、身份验证和自动登录。
要做到这一步,前端发送的所有请求的请求头必须带有 JWT,整体的流程是在第一次登录成功后,将 JWT 写入响应头,前端接收到之后将其存储,并在之后的每一次请求中,都将 JWT 写入请求头之中。并且由于之前登录时已经将权限信息写入缓存,所以在校验JWT 通过之后,应该先从缓存中取出权限,若缓存中没有权限才重新查询数据库。
package com.security.common; import cn.hutool.core.util.StrUtil; import com.security.mapper.APIMapper; import com.security.mapper.RoleMapper; import com.security.utils.JwtUtils; import com.security.utils.RedisUtils; import io.jsonwebtoken.Claims; import io.jsonwebtoken.JwtException; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.web.authentication.www.BasicAuthenticationFilter; import javax.servlet.FilterChain; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; import java.util.List; /** * JWT过滤器,使用token换取权限信息 */ public class JwtAuthenticationFilter extends BasicAuthenticationFilter { @Autowired private JwtUtils jwtUtils; @Autowired private APIMapper apiMapper; @Autowired private RoleMapper roleMapper; @Autowired private RedisUtils redisUtils; public JwtAuthenticationFilter(AuthenticationManager authenticationManager) { super(authenticationManager); } @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException { //获取jwt String jwt = request.getHeader(jwtUtils.getHeader()); if (StrUtil.isBlankOrUndefined(jwt)){ //如果jwt为空,则过滤链继续往下执行 chain.doFilter(request,response); return; } Claims claims = jwtUtils.parseToken(jwt); //判断jwt是否被篡改、是否解析异常 if (claims == null){ throw new JwtException("token 异常"); } //判断jwt是否过期 if (jwtUtils.isTokenExpired(claims)){ throw new JwtException("token 过期"); } String username = claims.getSubject(); /** * 获取角色(权限) * 从缓存中获取权限,若缓存中没有则才正常从数据库中获取 * 注意:如果用户权限发生改变时,需要将缓存中的数据删除 */ List<String> codeList = null; if (redisUtils.hashKey(username)){ String value = redisUtils.get(username).toString(); codeList = Arrays.asList(value.split(",")); }else { //用户、角色、资源方案:使用角色控制权限 codeList = roleMapper.queryUserRole(username); //用户、角色、权限、资源方案:使用“权限”控制权限 //codeList = authoritiesMapper.queryAuthoritiesList(user.getUsername()); } List<GrantedAuthority> authorities = new ArrayList<>(); codeList.forEach(code ->{ SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority(code); authorities.add(simpleGrantedAuthority); }); UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(username, null, authorities); //根据上下文,获取用户的权限,实现自动登录 SecurityContextHolder.getContext().setAuthentication(token); chain.doFilter(request,response); } }
如果,认证在稍微复杂一点,加上验证码认证呢?
验证码
验证码的整体工作流程是:当前端调用验证码的 API 时,后端在生成随机字符 code 的同时,生成一个随机码 key,然后将随机码和验证码写入缓存,并将随机码和根据验证码生成的图片验证码返回给前端。前端登陆时,不仅返回用户名、密码、验证码,还需要将随机码 key 一同返回,后端收到随机码 key 之后,就可以从缓存中取出随机码 code,之后只需要将缓存中的 code 与用户输入的 code 进行比对即可。
/*获取验证码,借助hutool的验证码生成工具类*/ @GetMapping("/getCaptcha") public ResponseBody getCode() throws IOException { //生成随机码,作为验证码的key值,传给前端(方便验证时,根据key从redis中取出正确的验证码value) String key = UUID.randomUUID().toString(); // 随机生成宽200、高100的 4 位验证码 ShearCaptcha captcha = CaptchaUtil.createShearCaptcha(200, 100, 4, 4); String code = captcha.getCode(); System.out.println("key:"+key); System.out.println(code); //写入到流中 BufferedImage bufferedImage = captcha.getImage(); ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); ImageIO.write(bufferedImage,"jpg", outputStream); //进行base64编码 BASE64Encoder base64Encoder = new BASE64Encoder(); //编码前缀 String str = "data:image/jpeg;base64,"; //使用hutool自己提供的方法,直接获取base64编码 //String base64 = str + captcha.getImageBase64(); String base64Image = str + base64Encoder.encode(outputStream.toByteArray()); //将验证码和对应的随机key值写入缓存数据库 redisUtils.set(key,code,600); return ResponseBodyUtil.success( MapUtil.builder() .put("key",key) .put("base64Image", base64Image) .build() );
由于Spring Security 本身并没有自带验证码过滤器,所以,我们可以通过继承 OncePerRequestFilter抽象类实现验证码过滤器 ,并且将该过滤器设置在用户名、密码、权限过滤器之前。这样每次访问接口都会经过此过滤器,我们可以获取请求路径,并判定当请求路径为/login时进入验证码验证流程。
package com.security.common; import com.security.config.AjaxAuthenticationFailureHandler; import com.security.exception.CaptchaException; import com.security.utils.RedisUtils; import org.apache.commons.lang3.StringUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; import javax.servlet.FilterChain; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; /** * 验证码过滤器 */ @Component public class CaptchaFilter extends OncePerRequestFilter { @Autowired private RedisUtils redisUtils; @Autowired private AjaxAuthenticationFailureHandler ajaxAuthenticationFailureHandler; @Override protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, FilterChain filterChain) throws ServletException, IOException { String url = httpServletRequest.getRequestURI(); //当url为登录路径且请求方式为post时,进入该过滤器处理 if ("/login".equals(url) && "POST".equals(httpServletRequest.getMethod())){ try { validate(httpServletRequest); }catch (CaptchaException captchaException){ //如果不正确,扑获到验证码异常就交给认证失败处理器 ajaxAuthenticationFailureHandler.onAuthenticationFailure(httpServletRequest,httpServletResponse,captchaException); } } //验证成功,则过滤链继续往下执行 filterChain.doFilter(httpServletRequest,httpServletResponse); } //验证码校验逻辑 private void validate(HttpServletRequest httpServletRequest) { String code = httpServletRequest.getParameter("code"); String key = httpServletRequest.getParameter("key"); //判断是否为空 if (StringUtils.isBlank(code) || StringUtils.isBlank(key)){ throw new CaptchaException("验证码错误"); } if (!code.equals(redisUtils.get(key))){ throw new CaptchaException("验证码错误"); } //删除缓存,一次性使用 redisUtils.del(key); } } ———————————————— 由于上一步抛出了验证码异常,所以,我们需要实现该异常处理 package com.security.exception; import org.springframework.security.core.AuthenticationException; /** * 验证码校验异常 */ public class CaptchaException extends AuthenticationException { public CaptchaException(String msg) { super(msg); } } ————————————————
修改 WebSecurityConfigurerAdapter
添加 JWT 与验证码过滤器之后的 Security 配置类:
package com.security.config; import com.security.common.CaptchaFilter; import com.security.common.JwtAuthenticationFilter; import org.springframework.beans.factory.annotation.Autowired; 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.authentication.builders.AuthenticationManagerBuilder; import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.core.userdetails.UserDetailsService; 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.security.web.authentication.UsernamePasswordAuthenticationFilter; @Configuration public class SecurityConfig extends WebSecurityConfigurerAdapter { @Bean public PasswordEncoder passwordEncoder(){ return new BCryptPasswordEncoder(); } @Autowired private UserDetailsService userDetailsService; /** * 登录成功的处理器 */ @Autowired private AjaxAuthenticationSuccessHandler ajaxAuthenticationSuccessHandler; /** * 登录失败的处理器 */ @Autowired private AjaxAuthenticationFailureHandler ajaxAuthenticationFailureHandler; /** * 自定义验证码过滤器 */ @Autowired private CaptchaFilter captchaFilter; /** * jwt过滤器 * @return * @throws Exception */ @Bean JwtAuthenticationFilter jwtAuthenticationFilter() throws Exception { JwtAuthenticationFilter jwtAuthenticationFilter = new JwtAuthenticationFilter(authenticationManager()); return jwtAuthenticationFilter; } @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { //自定义数据库登录逻辑 auth.userDetailsService(userDetailsService) //设置加密方式 .passwordEncoder(passwordEncoder()); } @Override protected void configure(HttpSecurity http) throws Exception { //添加自定义过滤器,并设置在用户名密码权限过滤器之前 http.addFilterBefore(captchaFilter, UsernamePasswordAuthenticationFilter.class) .addFilter(jwtAuthenticationFilter()); //授权 http.authorizeRequests() //放行不需要拦截的请求 .antMatchers("/login","/getCaptcha").permitAll() //所有请求必须认证才能访问 .anyRequest().authenticated() //注销 .and() .logout().permitAll() //注销后,删除cookie .deleteCookies("JSESSIONID") .and() //设置登录方式为表单提交 .formLogin().permitAll() //前后端分离:登录成功处理器,前端通过json数据进行页面跳转 .successHandler(ajaxAuthenticationSuccessHandler) .failureHandler(ajaxAuthenticationFailureHandler) ; //关闭CSRF跨域 http.csrf().disable(); } }
认证通过后,用户的权限信息会封装成一个User(此 User 非彼 User,而是 Spring Security 的 USer)放到 Spring 的全局缓存 SecurityContextHolder 中,以备后面访问资源时使用。
授权管理
在授权管理中,用户可以访问什么资源(API)取决于用户具有什么角色/权限。授权的流程如图所示:
AbstractSecurityInterceptor 权限拦截器
我们首先需要继承 AbstractSecurityInterceptor
资源管理拦截器抽象类,并实现 servler 的 Filter 接口,从而实现过滤 URL 并拦截请求。
package com.security.config; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.access.SecurityMetadataSource; import org.springframework.security.access.intercept.AbstractSecurityInterceptor; import org.springframework.security.access.intercept.InterceptorStatusToken; import org.springframework.security.web.FilterInvocation; import org.springframework.security.web.access.intercept.FilterInvocationSecurityMetadataSource; import org.springframework.stereotype.Component; import org.springframework.stereotype.Service; import javax.servlet.*; import java.io.IOException; /** * 权限拦截器 */ @Component public class UrlAbstractSecurityInterceptor extends AbstractSecurityInterceptor implements Filter { @Autowired private FilterInvocationSecurityMetadataSource securityMetadataSource; @Autowired public void setUrlAccessDecisionManager(UrlAccessDecisionManager urlAccessDecisionManager) { super.setAccessDecisionManager(urlAccessDecisionManager); } @Override public Class<?> getSecureObjectClass() { return FilterInvocation.class; } @Override public SecurityMetadataSource obtainSecurityMetadataSource() { return this.securityMetadataSource; } @Override public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException { FilterInvocation fi = new FilterInvocation(servletRequest, servletResponse, filterChain); invoke(fi); } private void invoke(FilterInvocation fi) throws IOException, ServletException { //FilterInvocation里面有一个被拦截的url //里面调用InvocationSecurityMetadataSource实现类的getAttributes(Object object)这个方法获取fi对应的所有权限 //再调用AccessDecisionManager实现类的decide方法来校验用户的权限是否足够 InterceptorStatusToken token = super.beforeInvocation(fi); try { //执行下一个拦截器 fi.getChain().doFilter(fi.getRequest(), fi.getResponse()); } finally { super.afterInvocation(token, null); } } }
FilterInvocationSecurityMetadataSource 实现类
该类的主要功能就是通过当前的请求地址获取该地址需要的用户角色,并将访问该URL 所需要的角色权限信息传给决策器,由决策器进行表决。
package com.security.config; import com.security.mapper.APIMapper; import com.security.mapper.AuthoritiesMapper; import com.security.mapper.RoleAPIMapper; import com.security.mapper.RoleMapper; import com.security.pojo.API; import com.security.pojo.Authorities; import com.security.pojo.Role; import com.security.pojo.RoleAPI; 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.stereotype.Component; import org.springframework.util.CollectionUtils; import java.util.Collection; import java.util.LinkedList; import java.util.List; /** * 获取该url所需要的用户角色权限信息 */ @Component public class UrlFilterInvocationSecurityMetadataSource implements FilterInvocationSecurityMetadataSource { @Autowired private RoleMapper roleMapper; @Autowired private AuthoritiesMapper authoritiesMapper; @Override public Collection<ConfigAttribute> getAttributes(Object object) throws IllegalArgumentException { /* // TODO 忽略url请放在此处进行过滤放行 if ("/login".equals(requestUrl) || requestUrl.contains("logout")) { return null; } */ //获取请求地址 String requestUrl = ((FilterInvocation) object).getRequestUrl(); //用户、角色、资源方案:查询具体某个接口的权限 List<Role> roleList = roleMapper.selectListByUrl(requestUrl); //用户、角色、权限、资源方案:查询某个具体的权限 //List<Authorities> authoritiesList = authoritiesMapper.queryAuthoritiesByUrl(requestUrl); if(roleList == null || roleList.size() == 0){ //请求路径没有配置权限,表明该请求接口可以任意访问 return null; } String[] attributes = new String[roleList.size()]; for(int i = 0;i<roleList.size();i++){ attributes[i] = roleList.get(i).getCode(); } return SecurityConfig.createList(attributes); } @Override public Collection<ConfigAttribute> getAllConfigAttributes() { return null; } /** * 改为true,否则注入时报错 * @param aClass * @return */ @Override public boolean supports(Class<?> aClass) { return true; } }
在这个实现类中,getAttributes(Object o) 方法返回的集合最终会来到 AccessDecisionManager 类。
AccessDecisionManager 实现类
授权管理器会通过spring的全局缓存 SecurityContextHolder 获取用户的权限信息,还会获取被拦截的 URL 和被拦截 URL 所需的全部权限,然后根据所配的策略进行判定。我们需要重写授权管理器的 decide() 方法,对访问的 URL 进行权限认证处理。decide() 方法接收的三个参数,第一个参数保存了当前登录用户的角色信息,第二个参数是请求的 URL,第三个参数是 getAttributes() 方法返回的权限集合。而当前请求所需的权限和当前用户具有的权限有一个符合即可正常访问。
package com.security.config; import org.springframework.security.access.AccessDecisionManager; import org.springframework.security.access.AccessDeniedException; import org.springframework.security.access.ConfigAttribute; import org.springframework.security.authentication.InsufficientAuthenticationException; import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; import org.springframework.stereotype.Component; import java.util.Collection; import java.util.Iterator; /** * 对访问url进行权限认证处理 */ @Component public class UrlAccessDecisionManager implements AccessDecisionManager { /** * * @param authentication 当前登录用户的角色信息 * @param o 请求的url * @param collection 由UrlFilterInvocationSecurityMetadataSource中的getAttributes方法传来的,表示当前请求需要的角色(可能有多个) * @throws AccessDeniedException * @throws InsufficientAuthenticationException */ @Override public void decide(Authentication authentication, Object o, Collection<ConfigAttribute> collection) throws AccessDeniedException, InsufficientAuthenticationException { Iterator<ConfigAttribute> iterator = collection.iterator(); while (iterator.hasNext()) { ConfigAttribute ca = iterator.next(); //当前请求需要的权限 String needRole = ca.getAttribute(); //当前用户所具有的权限 Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities(); for (GrantedAuthority authority : authorities) { //只要符合一个即可访问 if (authority.getAuthority().equals(needRole)) { return; } } } throw new AccessDeniedException("权限不足!"); } /** * 以下两个都要改为true,否则,注入的时候会报错 * @param configAttribute * @return */ @Override public boolean supports(ConfigAttribute configAttribute) { return true; } @Override public boolean supports(Class<?> aClass) { return true; } }
由于我们上一步在用户权限与请求权限不相符合时抛出了AccessDeniedException
异常,所以,我们需要自定义实现该异常。
package com.security.config; import com.security.common.ResponseBody; import com.security.common.ResponseCode; import com.security.utils.ResponseBodyUtil; import org.springframework.security.access.AccessDeniedException; import org.springframework.security.web.access.AccessDeniedHandler; import org.springframework.stereotype.Component; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; /** * 权限不足处理逻辑 */ @Component public class AjaxAccessDeniedHandler extends JSONAuthentication implements AccessDeniedHandler { @Override public void handle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AccessDeniedException e) throws IOException, ServletException { ResponseBody responseBody = ResponseBodyUtil.fail(ResponseCode.NO_PERMISSION); this.WriteJSON(httpServletRequest,httpServletResponse,responseBody); } }
匿名访问处理器
为了处理匿名访问 API,自定义实现匿名访问处理器
package com.security.config; import com.security.common.ResponseBody; import com.security.common.ResponseCode; import com.security.utils.ResponseBodyUtil; import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.stereotype.Component; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; /** * 自定义匿名访问无权访问资源处理逻辑 */ @Component public class AjaxAuthenticationEntryPoint extends JSONAuthentication implements AuthenticationEntryPoint { @Override public void commence(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException { ResponseBody responseBody = ResponseBodyUtil.fail(ResponseCode.USER_NOT_LOGIN); this.WriteJSON(httpServletRequest,httpServletResponse,responseBody); } }
修改 WebSecurityConfigurerAdapter
正常的业务还需要添加会话过期处理器、多点登录处理器、注销成功/失败处理器等,本文不做一一阐述,添加完所有处理器后的完整的 WebSecurityConfigurerAdapter
如下:
package com.security.config; import com.security.common.CaptchaFilter; import com.security.common.JwtAuthenticationFilter; import org.springframework.beans.factory.annotation.Autowired; 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.authentication.builders.AuthenticationManagerBuilder; import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.core.userdetails.UserDetailsService; 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.security.web.authentication.UsernamePasswordAuthenticationFilter; @Configuration public class SecurityConfig extends WebSecurityConfigurerAdapter { @Bean public PasswordEncoder passwordEncoder(){ return new BCryptPasswordEncoder(); } @Autowired private UserDetailsService userDetailsService; /** * 登录成功的处理器 */ @Autowired private AjaxAuthenticationSuccessHandler ajaxAuthenticationSuccessHandler; /** * 登录失败的处理器 */ @Autowired private AjaxAuthenticationFailureHandler ajaxAuthenticationFailureHandler; /** * 匿名访问无权访问资源的处理器 */ @Autowired private AjaxAuthenticationEntryPoint ajaxAuthenticationEntryPoint; /** * 权限不足处理器 */ @Autowired private AjaxAccessDeniedHandler ajaxAccessDeniedHandler; /** * 注销操作处理器 */ @Autowired private AjaxLogoutHandler ajaxLogoutHandler; /** * 注销成功处理器 */ @Autowired private AjaxLogoutSuccessHandler ajaxLogoutSuccessHandler; /** * 会话过期处理器 */ @Autowired private AjaxInvalidSessionStrategy ajaxInvalidSessionStrategy; /** * 多点登录处理器 */ @Autowired private AjaxSessionInformationExpiredStrategy ajaxSessionInformationExpiredStrategy; /** * 自定义验证码过滤器 */ @Autowired private CaptchaFilter captchaFilter; /** * 获取访问url所需要的角色信息 */ @Autowired private UrlFilterInvocationSecurityMetadataSource urlFilterInvocationSecurityMetadataSource; /** * 认证权限处理,将之前所获的角色权限与当前登录用户的角色对比,若包含其中一个即可正常访问 */ @Autowired private UrlAccessDecisionManager urlAccessDecisionManager; @Autowired private UrlAbstractSecurityInterceptor urlAbstractSecurityInterceptor; /** * jwt过滤器 * @return * @throws Exception */ @Bean JwtAuthenticationFilter jwtAuthenticationFilter() throws Exception { JwtAuthenticationFilter jwtAuthenticationFilter = new JwtAuthenticationFilter(authenticationManager()); return jwtAuthenticationFilter; } @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { //自定义数据库登录逻辑 auth.userDetailsService(userDetailsService) //设置加密方式 .passwordEncoder(passwordEncoder()); } @Override protected void configure(HttpSecurity http) throws Exception { //添加自定义过滤器,并设置在用户名密码权限过滤器之前 http.addFilterBefore(captchaFilter, UsernamePasswordAuthenticationFilter.class) .addFilter(jwtAuthenticationFilter()) .addFilterBefore(urlAbstractSecurityInterceptor, FilterSecurityInterceptor.class); //授权 http.authorizeRequests() .withObjectPostProcessor(new ObjectPostProcessor<FilterSecurityInterceptor>() { @Override public <O extends FilterSecurityInterceptor> O postProcess(O o) { //决策管理器 o.setAccessDecisionManager(urlAccessDecisionManager); //安全元数据源 o.setSecurityMetadataSource(urlFilterInvocationSecurityMetadataSource); return o; } }) //放行不需要拦截的请求 .antMatchers("/login","/getCaptcha").permitAll() //所有请求必须认证才能访问 .anyRequest().authenticated() //注销 .and() .logout().permitAll() .addLogoutHandler(ajaxLogoutHandler) //注销成功 .logoutSuccessHandler(ajaxLogoutSuccessHandler) //注销后,删除cookie .deleteCookies("JSESSIONID") .and() //设置登录方式为表单提交 .formLogin().permitAll() .successHandler(ajaxAuthenticationSuccessHandler) .failureHandler(ajaxAuthenticationFailureHandler) .and() .exceptionHandling() //权限不足处理 .accessDeniedHandler(ajaxAccessDeniedHandler) //未登录,访问资源的异常 .authenticationEntryPoint(ajaxAuthenticationEntryPoint) //会话管理 .and() .sessionManagement() //会话过期策略 .invalidSessionStrategy(ajaxInvalidSessionStrategy) //最大允许登录数 .maximumSessions(1) //达到最大登录数后,是否允许继续登录(否会挤掉已经登录的账户) .maxSessionsPreventsLogin(false) //多点登录处理方式 .expiredSessionStrategy(ajaxSessionInformationExpiredStrategy) ; //关闭CSRF跨域 http.csrf().disable(); //前后端分离是无状态的,所以使用STATELESS策略 http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS); } }
SpringBoot+SpringSecurity前后端分离+Jwt的权限认证(--------)
一般来说,我们用SpringSecurity默认的话是前后端整在一起的,比如thymeleaf或者Freemarker,SpringSecurity还自带login登录页,还让你配置登出页,错误页。
但是现在前后端分离才是正道,前后端分离的话,那就需要将返回的页面换成Json格式交给前端处理了
SpringSecurity默认的是采用Session来判断请求的用户是否登录的,但是不方便分布式的扩展,虽然SpringSecurity也支持采用SpringSession来管理分布式下的用户状态,不过现在分布式的还是无状态的Jwt比较主流。
所以下面说下怎么让SpringSecurity变成前后端分离,采用Jwt来做认证的
一、五个handler一个filter两个User
5个handler,分别是
实现AuthenticationEntryPoint接口,当匿名请求需要登录的接口时,拦截处理
实现AuthenticationSuccessHandler接口,当登录成功后,该处理类的方法被调用
实现AuthenticationFailureHandler接口,当登录失败后,该处理类的方法被调用
实现AccessDeniedHandler接口,当登录后,访问接口没有权限的时候,该处理类的方法被调用
实现LogoutSuccessHandler接口,注销的时候调用
1.1 AuthenticationEntryPoint
匿名未登录的时候访问,遇到需要登录认证的时候被调用
SpringBoot+SpringSecurity前后端分离+Jwt的权限认证
/** * 匿名未登录的时候访问,需要登录的资源的调用类 * @author zzzgd */ @Component public class CustomerAuthenticationEntryPoint implements AuthenticationEntryPoint { @Override public void commence(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException { //设置response状态码,返回错误信息等 ... ResponseUtil.out(401, ResultUtil.failure(ErrorCodeConstants.REQUIRED_LOGIN_ERROR)); } }
1.2 AuthenticationSuccessHandler
这里是我们输入的用户名和密码登录成功后,调用的方法
简单的说就是获取用户信息,使用JWT生成token,然后返回token
/** * 登录成功处理类,登录成功后会调用里面的方法 * @author Exrickx */ @Slf4j @Component public class CustomerAuthenticationSuccessHandler implements AuthenticationSuccessHandler { @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { //简单的说就是获取当前用户,拿到用户名或者userId,创建token,返回 log.info("登陆成功..."); CustomerUserDetails principal = (CustomerUserDetails) authentication.getPrincipal(); //颁发token Map<String,Object> emptyMap = new HashMap<>(4); emptyMap.put(UserConstants.USER_ID,principal.getId()); String token = JwtTokenUtil.generateToken(principal.getUsername(), emptyMap); ResponseUtil.out(ResultUtil.success(token)); } }
1.3 AuthenticationFailureHandler
有登陆成功就有登录失败
登录失败的时候调用这个方法,可以在其中做登录错误限制或者其他操作,我这里直接就是设置响应头的状态码为401,返回
/** * 登录账号密码错误等情况下,会调用的处理类 * @author Exrickx */ @Slf4j @Component public class CustomerAuthenticationFailHandler implements AuthenticationFailureHandler { @Override public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException { //设置response状态码,返回错误信息等 .... ResponseUtil.out(401, ResultUtil.failure(ErrorCodeConstants.LOGIN_UNMATCH_ERROR)); } }
1.4 LogoutSuccessHandler
登出注销的时候调用,Jwt有个缺点就是无法主动控制失效,可以采用Jwt+session的方式,比如删除存储在Redis的token
这里需要注意,如果将SpringSecurity的session配置为无状态,或者不保存session,这里authentication为null!!,注意空指针问题。(详情见下面的配置WebSecurityConfigurerAdapter)
/** * 登出成功的调用类 * @author zzzgd */ @Component public class CustomerLogoutSuccessHandler implements LogoutSuccessHandler { @Override public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { ResponseUtil.out(ResultUtil.success("Logout Success!")); } }
1.5 AccessDeniedHandler
登录后,访问缺失权限的资源会调用。
/** * 没有权限,被拒绝访问时的调用类 * @author Exrickx */ @Component @Slf4j public class CustomerRestAccessDeniedHandler implements AccessDeniedHandler { @Override public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) { ResponseUtil.out(403, ResultUtil.failure(ErrorCodeConstants.PERMISSION_DENY)); } }
1.6 一个过滤器OncePerRequestFilter
这里算是一个小重点。
上面我们在登录成功后,返回了一个token,那怎么使用这个token呢?
前端发起请求的时候将token放在请求头中,在过滤器中对请求头进行解析。
如果有accessToken的请求头(可以自已定义名字),取出token,解析token,解析成功说明token正确,将解析出来的用户信息放到SpringSecurity的上下文中
如果有accessToken的请求头,解析token失败(无效token,或者过期失效),取不到用户信息,放行
没有accessToken的请求头,放行
这里可能有人会疑惑,为什么token失效都要放行呢?
这是因为SpringSecurity会自己去做登录的认证和权限的校验,靠的就是我们放在SpringSecurity上下文中的SecurityContextHolder.getContext().setAuthentication(authentication);,没有拿到authentication,放行了,SpringSecurity还是会走到认证和校验,这个时候就会发现没有登录没有权限。
旧版本, 最新在底部
/** * 过滤器,在请求过来的时候,解析请求头中的token,再解析token得到用户信息,再存到SecurityContextHolder中 * @author zzzgd */ @Component @Slf4j public class CustomerJwtAuthenticationTokenFilter extends OncePerRequestFilter { @Autowired CustomerUserDetailService customerUserDetailService; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { //请求头为 accessToken //请求体为 Bearer token String authHeader = request.getHeader(SecurityConstants.HEADER); if (authHeader != null && authHeader.startsWith(SecurityConstants.TOKEN_SPLIT)) { final String authToken = authHeader.substring(SecurityConstants.TOKEN_SPLIT.length()); String username = JwtTokenUtil.parseTokenGetUsername(authToken); if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) { UserDetails userDetails = customerUserDetailService.loadUserByUsername(username); if (userDetails != null) { UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, userDetails.getPassword(), userDetails.getAuthorities()); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authentication); } } } chain.doFilter(request, response); } }
1.7 实现UserDetails扩充字段
这个接口表示的用户信息,SpringSecurity默认实现了一个User,不过字段寥寥无几,只有username,password这些,而且后面获取用户信息的时候也是获取的UserDetail。
于是我们将自己的数据库的User作为拓展,自己实现这个接口。继承的是数据库对应的User,而不是SpringSecurity的User
/** * CustomerUserDetails * * @author zgd * @date 2019/7/17 15:29 */ public class CustomerUserDetails extends User implements UserDetails { private Collection<? extends GrantedAuthority> authorities; public CustomerUserDetails(User user){ this.setId(user.getId()); this.setUsername(user.getUsername()); this.setPassword(user.getPassword()); this.setRoles(user.getRoles()); this.setStatus(user.getStatus()); } public void setAuthorities(Collection<? extends GrantedAuthority> authorities) { this.authorities = authorities; } /** * 添加用户拥有的权限和角色 * @return */ @Override public Collection<? extends GrantedAuthority> getAuthorities() { return this.authorities; } /** * 账户是否过期 * @return */ @Override public boolean isAccountNonExpired() { return true; } /** * 是否禁用 * @return */ @Override public boolean isAccountNonLocked() { return true; } /** * 密码是否过期 * @return */ @Override public boolean isCredentialsNonExpired() { return true; } /** * 是否启用 * @return */ @Override public boolean isEnabled() { return UserConstants.USER_STATUS_NORMAL.equals(this.getStatus()); } } -----------------------------------
1.8 实现UserDetailsService
SpringSecurity在登录的时候,回去数据库(或其他来源),根据username获取正确的user信息,就会根据这个service类,拿到用户的信息和权限。我们自己实现
/** * @author zgd * @date 2019/1/16 16:27 * @description 自己实现UserDetailService,用与SpringSecurity获取用户信息 */ @Service @Slf4j public class CustomerUserDetailService implements UserDetailsService { @Autowired private IUserService userService; /** * 获取用户信息,然后交给spring去校验权限 * @param username * @return * @throws UsernameNotFoundException */ @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { //获取用户信息 User user = userService.getUserRoleByUserName(username); if(user == null){ throw new UsernameNotFoundException("用户名不存在"); } CustomerUserDetails customerUserDetails = new CustomerUserDetails(user); List<SimpleGrantedAuthority> authorities = new ArrayList<>(); //用于添加用户的权限。只要把用户权限添加到authorities 就万事大吉。 if (CollectionUtils.isNotEmpty(user.getRoles())){ user.getRoles().forEach(r -> authorities.add(new SimpleGrantedAuthority("ROLE_"+r.getRoleName()))); } customerUserDetails.setAuthorities(authorities); log.info("authorities:{}", JSON.toJSONString(authorities)); //这里返回的是我们自己定义的UserDetail return customerUserDetails; } }
二、配置WebSecurityConfigurerAdapter
我们需要将上面定义的handler和filter,注册到SpringSecurity。同时配置一些放行的url
这里有一点需要注意:如果配置了下面的SessionCreationPolicy.STATELESS,则SpringSecurity不会保存session会话,在/logout登出的时候会拿不到用户实体对象。
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
如果登出注销不依赖SpringSecurity,并且session交给redis的token来管理的话,可以按上面的配置。
/** * @Author: zgd * @Date: 2019/1/15 17:42 * @Description: */ @Configuration @EnableWebSecurity @EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true)// 控制@Secured权限注解 public class WebSecurityConfig extends WebSecurityConfigurerAdapter { /** * 这里需要交给spring注入,而不是直接new */ @Autowired private PasswordEncoder passwordEncoder; @Autowired private CustomerUserDetailService customerUserDetailService; @Autowired private CustomerAuthenticationFailHandler customerAuthenticationFailHandler; @Autowired private CustomerAuthenticationSuccessHandler customerAuthenticationSuccessHandler; @Autowired private CustomerJwtAuthenticationTokenFilter customerJwtAuthenticationTokenFilter; @Autowired private CustomerRestAccessDeniedHandler customerRestAccessDeniedHandler; @Autowired private CustomerLogoutSuccessHandler customerLogoutSuccessHandler; @Autowired private CustomerAuthenticationEntryPoint customerAuthenticationEntryPoint; /** * 该方法定义认证用户信息获取的来源、密码校验的规则 * * @param auth * @throws Exception */ @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { //auth.authenticationProvider(myauthenticationProvider) 自定义密码校验的规则 //如果需要改变认证的用户信息来源,我们可以实现UserDetailsService auth.userDetailsService(customerUserDetailService).passwordEncoder(passwordEncoder); } @Override protected void configure(HttpSecurity http) throws Exception { /** * antMatchers: ant的通配符规则 * ? 匹配任何单字符 * * 匹配0或者任意数量的字符,不包含"/" * ** 匹配0或者更多的目录,包含"/" */ http .headers() .frameOptions().disable(); http //登录后,访问没有权限处理类 .exceptionHandling().accessDeniedHandler(customerRestAccessDeniedHandler) //匿名访问,没有权限的处理类 .authenticationEntryPoint(customerAuthenticationEntryPoint) ; //使用jwt的Authentication,来解析过来的请求是否有token http .addFilterBefore(customerJwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class); http .authorizeRequests() //这里表示"/any"和"/ignore"不需要权限校验 .antMatchers("/ignore/**", "/login", "/**/register/**").permitAll() .anyRequest().authenticated() // 这里表示任何请求都需要校验认证(上面配置的放行) .and() //配置登录,检测到用户未登录时跳转的url地址,登录放行 .formLogin() //需要跟前端表单的action地址一致 .loginProcessingUrl("/login") .successHandler(customerAuthenticationSuccessHandler) .failureHandler(customerAuthenticationFailHandler) .permitAll() //配置取消session管理,又Jwt来获取用户状态,否则即使token无效,也会有session信息,依旧判断用户为登录状态 .and() .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) //配置登出,登出放行 .and() .logout() .logoutSuccessHandler(customerLogoutSuccessHandler) .permitAll() .and() .csrf().disable() ; } }
三、其他
大概到这就差不多了,启动,localhost:8080/login,使用postman,采用form-data,post提交,参数是username和password,调用,返回token。
将token放在header中,请求接口。
3.1 不足之处
上面是最简单的处理,还有很多优化的地方。比如
控制token销毁?
使用redis+token组合,不仅解析token,还判断redis是否有这个token。注销和主动失效token:删除redis的key
控制token过期时间?如果用户在token过期前1秒还在操作,下1秒就需要重新登录,肯定不好
1、考虑加入refreshToken,过期时间比token长,前端在拿到token的同时获取过期时间,在过期前一分钟用refreshToken调用refresh接口,重新获取新的token。
2、 将返回的jwtToken设置短一点的过期时间,redis再存这个token,过期时间设置长一点。如果请求过来token过期,查询redis,如果redis还存在,返回新的token。(为什么redis的过期时间大于token的?因为redis的过期是可控的,手动可删除,以redis的为准)
每次请求都会被OncePerRequestFilter 拦截,每次都会被UserDetailService中的获取用户数据请求数据库
可以考虑做缓存,还是用redis或者直接保存内存中
3.2 解决
这是针对上面的2.2说的,也就是redis时间久一点,jwt过期后如果redis没过期,颁发新的jwt。
不过更推荐的是前端判断过期时间,在过期之前调用refresh接口拿到新的jwt。
为什么这样?
如果redis过期时间是一周,jwt是一个小时,那么一个小时后,拿着这个过期的jwt去调,就可以想创建多少个新的jwt就创建,只要没过redis的过期时间。当然这是在没对过期的jwt做限制的情况下,如果要考虑做限制,比如对redis的value加一个字段,保存当前jwt,刷新后就用新的jwt覆盖,refresh接口判断当前的过期jwt是不是和redis这个一样。
总之还需要判断刷新token的时候,过期jwt是否合法的问题。总不能去年的过期token也拿来刷新吧。
而在过期前去刷新token的话,至少不会发生这种事情
不过我这里自己写demo,采用的还是2.2的方式,也就是过期后给个新的,思路如下:
登录后颁发token,token有个时间戳,同时以username拼装作为key,保存这个时间戳到缓存(redis,cache)
请求来了,过滤器解析token,没过期的话,还需要比较缓存中的时间戳和token的时间戳是不是一样 ,如果时间戳不一样,说明该token不能刷新。无视
注销,清除缓存数据
这样就可以避免token过期后,我还能拿到这个token无限制的refresh。
不过这个还是有细节方面问题,并发下同时刷新token这些并没有考虑,部分代码如下
旧版本, 最新在底部
/** * 过滤器,在请求过来的时候,解析请求头中的token,再解析token得到用户信息,再存到SecurityContextHolder中 * @author zzzgd */ @Component @Slf4j public class CustomerJwtAuthenticationTokenFilter extends OncePerRequestFilter { @Autowired CustomerUserDetailService customerUserDetailService; @Autowired UserSessionService userSessionService; @Autowired UserTokenManager userTokenManager; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { //请求头为 accessToken //请求体为 Bearer token String authHeader = request.getHeader(SecurityConstants.HEADER); if (authHeader != null && authHeader.startsWith(SecurityConstants.TOKEN_SPLIT)) { final String authToken = authHeader.substring(SecurityConstants.TOKEN_SPLIT.length()); String username; Claims claims; try { claims = JwtTokenUtil.parseToken(authToken); username = claims.getSubject(); } catch (ExpiredJwtException e) { //token过期 claims = e.getClaims(); username = claims.getSubject(); CustomerUserDetails userDetails = userSessionService.getSessionByUsername(username); if (userDetails != null){ //session未过期,比对时间戳是否一致,是则重新颁发token if (isSameTimestampToken(username,e.getClaims())){ userTokenManager.awardAccessToken(userDetails,true); } } } //避免每次请求都请求数据库查询用户信息,从缓存中查询 CustomerUserDetails userDetails = userSessionService.getSessionByUsername(username); if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) { // UserDetails userDetails = customerUserDetailService.loadUserByUsername(username); if (userDetails != null) { if(isSameTimestampToken(username,claims)){ //必须token解析的时间戳和session保存的一致 UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, userDetails.getPassword(), userDetails.getAuthorities()); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authentication); } } } } chain.doFilter(request, response); } /** * 判断是否同一个时间戳 * @param username * @param claims * @return */ private boolean isSameTimestampToken(String username, Claims claims){ Long timestamp = userSessionService.getTokenTimestamp(username); Long jwtTimestamp = (Long) claims.get(SecurityConstants.TIME_STAMP); return timestamp.equals(jwtTimestamp); } }
/** * UserTokenManager * token管理 * * @author zgd * @date 2019/7/19 15:25 */ @Component public class UserTokenManager { @Autowired private UserAuthProperties userAuthProperties; @Autowired private UserSessionService userSessionService; /** * 颁发token * @param principal * @author zgd * @date 2019/7/19 15:34 * @return void */ public void awardAccessToken(CustomerUserDetails principal,boolean isRefresh) { //颁发token 确定时间戳,保存在session中和token中 long mill = System.currentTimeMillis(); userSessionService.saveSession(principal); userSessionService.saveTokenTimestamp(principal.getUsername(),mill); Map<String,Object> param = new HashMap<>(4); param.put(UserConstants.USER_ID,principal.getId()); param.put(SecurityConstants.TIME_STAMP,mill); String token = JwtTokenUtil.generateToken(principal.getUsername(), param,userAuthProperties.getJwtExpirationTime()); HashMap<String, String> map = Maps.newHashMapWithExpectedSize(1); map.put(SecurityConstants.HEADER,token); int code = isRefresh ? 201 : 200; ResponseUtil.outWithHeader(code,ResultUtil.success(),map); } } -----------------------------------
针对token解析的过滤器做了优化:
如果redis的session没过期, 但是请求头的token过期了, 判断时间戳一致后, 颁发新token并返回
如果redis的session没过期, 但是请求头的token过期了, 时间戳不一致, 说明当前请求的token无法刷新token, 设置响应码为401返回
如果请求头的token过期了, 但是redis的session失效或未找到, 直接放行, 交给后面的权限校验处理(也就是没有给上下文SecurityContextHolder设置登录信息, 后面如果判断这个请求缺少权限会自行处理)
/** * 过滤器,在请求过来的时候,解析请求头中的token,再解析token得到用户信息,再存到SecurityContextHolder中 * @author zzzgd */ @Component @Slf4j public class CustomerJwtAuthenticationTokenFilter extends OncePerRequestFilter { @Autowired CustomerUserDetailService customerUserDetailService; @Autowired UserSessionService userSessionService; @Autowired UserTokenManager userTokenManager; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { //请求头为 accessToken //请求体为 Bearer token String authHeader = request.getHeader(SecurityConstants.HEADER); if (authHeader != null && authHeader.startsWith(SecurityConstants.TOKEN_SPLIT)) { //请求头有token final String authToken = authHeader.substring(SecurityConstants.TOKEN_SPLIT.length()); String username; Claims claims; try { claims = JwtTokenUtil.parseToken(authToken); username = claims.getSubject(); } catch (ExpiredJwtException e) { //token过期 claims = e.getClaims(); username = claims.getSubject(); CustomerUserDetails userDetails = userSessionService.getSessionByUsername(username); if (userDetails != null){ //session未过期,比对时间戳是否一致,是则重新颁发token if (isSameTimestampToken(username,e.getClaims())){ userTokenManager.awardAccessToken(userDetails,true); //直接设置响应码为201,直接返回 return; }else{ //时间戳不一致.无效token,无法刷新token,响应码401,前端跳转登录页 ResponseUtil.out(HttpStatus.UNAUTHORIZED.value(),ResultUtil.failure(ErrorCodeConstants.REQUIRED_LOGIN_ERROR)); return; } }else{ //直接放行,交给后面的handler处理,如果当前请求是需要访问权限,则会由CustomerRestAccessDeniedHandler处理 chain.doFilter(request, response); return; } } //避免每次请求都请求数据库查询用户信息,从缓存中查询 CustomerUserDetails userDetails = userSessionService.getSessionByUsername(username); if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) { // UserDetails userDetails = customerUserDetailService.loadUserByUsername(username); if (userDetails != null) { if(isSameTimestampToken(username,claims)){ //必须token解析的时间戳和session保存的一致 UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, userDetails.getPassword(), userDetails.getAuthorities()); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authentication); } } } } chain.doFilter(request, response); } /** * 判断是否同一个时间戳 * @param username * @param claims * @return */ private boolean isSameTimestampToken(String username, Claims claims){ Long timestamp = userSessionService.getTokenTimestamp(username); Long jwtTimestamp = (Long) claims.get(SecurityConstants.TIME_STAMP); return timestamp.equals(jwtTimestamp); } }