SpringSecurity+JWT 身份验证及动态权限解决方案(很实用)
花了点时间写了一个SpringSecurity
集合JWT完成身份验证的Demo,并按照自己的想法完成了动态权限问题。在写这个Demo之初,使用的是SpringSecurity
自带的注解权限,但是这样权限就显得不太灵活,在实现之后,感觉也挺复杂的,欢迎大家给出建议。
认证流程及授权流程
我画了个建议的认证授权流程图,后面会结合代码进行解释整个流程。
一、登录认证阶段
实现SpringSecurity
的UsernamePasswordAuthenticationFilter
接口(public class TokenLoginFilter extends UsernamePasswordAuthenticationFilter
),在它的实现类的构造方法里设置登录的请求路径和请求方式。
this.setPostOnly(false); // 认证路径 - 发送什么请求,就会进行认证 this.setRequiresAuthenticationRequestMatcher(new AntPathRequestMatcher("/service_auth/admin/index/login","POST"));
当前端发起配置的请求时,请求会被拦截,进入到attemptAuthentication
方法进行验证,在这个方法里可以从request中取出账号、密码,从而调用AuthenticationManager
的authenticate
去校验账号、密码是否正确。
@Override public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { try { User user = new ObjectMapper().readValue(request.getInputStream(), User.class); // 也可以直接获取账号密码 String username = obtainUsername(request); String password = obtainPassword(request); log.info("TokenLoginFilter-attemptAuthentication:尝试认证,用户名:{}, 密码:{}", username, password); // 在authenticate里去进行校验的,校验过程中会去把UserDetailService里返回的SecurityUser(UserDetails)里的账号密码和这里传的账号密码进行比对 // 并在UserDetailService里将权限进行赋予 // 校验通过,会进入到successfulAuthentication方法 return authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(user.getUsername(), user.getPassword(), new ArrayList<>())); } catch (IOException e) { throw new RuntimeException(e); } }
那么这个authenticate方法是怎么验证我们账号密码正确性的呢?
打上断点,跟随源码,我们进入到authenticate
方法内部:
然后进入这个方法内部,继续往下走,看到一段核心代码:
进入retrieveUser方法里,然后往下走,看到一句核心代码,这个核心代码就是获取用户信息的:
这里注意,调用了UserDetailsService
的loadUserByUsername
方法,传入的就是前端传过来的username,意思就是要根据这个username去获取UserDetails
对象,所以我们就要去查询数据库,所以我们就要实现UserDetailsService
接口并重写loadUserByUsername
方法。
@Service("userDetailsService") @Slf4j public class UserDetailServiceImpl implements UserDetailsService { @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { log.info("根据username去数据库查询用户信息,username:{}", username); // 1、从数据库中取出用户信息 - 这里模拟,直接new一个User对象 User user = new User(); user.setUsername(username); // 111111经过加密后 user.setPassword("96e79218965eb72c92a549dd5a330112"); SecurityUser securityUser = new SecurityUser(user); // 可以根据查出来的user.getId()去查询这个用户对应的权限集合 - 这里模拟,直接new一个结合 List authorities = new ArrayList<>(); // 将权限赋予用户 securityUser.setPermissionValueList(authorities); return securityUser; } }
在这个方法里,我们通过查询数据库,获取用户username、password
和其对应的权限并设置到UserDetails
对象里(代码里的SecurityUser
是我自己implements UserDetails
的,也就是它的子类)。
获取到userDetails
对象后,回到之前的代码(retriveUser
所在的地方)里,这个user经过包装,里面包含我们从数据库里取出的username、password
。
接着往下看,看到核心代码:
注意这个additionalAuthenticationChecks
方法,我们进入到这个方法内部:
可以发现,这是对比密码的,即前端传过来的密码和数据库中存储的已经加密过的密码是否能匹配上。然后我们回到之前的代码里,直接到结尾,返回一个对象。
在账号、密码验证完之后的一系列操作里,SpringSecurity
自己再对数据进行一些封装放到SecurityContextHolder
里。
至此,用户的认证流程已经走完。
认证成功之后
认证成功之后,我们要告诉前端登录认证通过,会进入UsernamePasswordAuthenticationFilter
的successfulAuthentication
方法里。
/** * 登录成功 * @param request request * @param response response * @param chain chain * @param auth auth * @throws IOException * @throws ServletException */ @Override protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication auth) throws IOException, ServletException { log.info("TokenLoginFilter-successfulAuthentication:认证通过!"); SecurityUser user = (SecurityUser) auth.getPrincipal(); // 创建token String token = tokenManager.createToken(user.getCurrentUserInfo().getUsername()); log.info("创建的Token为:{}", token); // 这里建议,以username为Key,权限集合为value将权限存入Redis,因为权限在后面会频繁被取出来用 // redisTemplate.opsForValue().set(user.getCurrentUserInfo().getUsername(), user.getPermissionValueList()); // 响应给前端调用处 ResponseUtil.out(response, ResponseResult.ok().data("token", token)); }
在这个方法里,我们创建一个token,并相应给前端调用者。ResponseUtil
是封装的一个响应工具,tokenManager
是JWT工具,这里不做过多解释,可以去仓库克隆我的源码查看,根据我这个流程走即可。
因为我这个Demo是基于前后端分离的,因此只需响应给前端结果(比如这里的token)即可,让前端来跳转。如果不是前后端分离的,可以在这里进行页面跳转。
如果认证失败
认证失败,会进入UsernamePasswordAuthenticationFilter
的unsuccessfulAuthentication
方法里。
/** * 登录失败 * @param request * @param response * @param e * @throws IOException * @throws ServletException */ @Override protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException { log.info("TokenLoginFilter-unsuccessfulAuthentication:认证失败!"); // 响应给前端调用处 ResponseUtil.out(response, ResponseResult.error()); }
在这个方法里,直接响应给前端错误情况即可。因为我这个Demo是基于前后端分离的,因此只需响应给前端结果、状态码即可,让前端来跳转。如果不是前后端分离的,可以在这里进行页面跳转。
二、授权阶段 - 如果你要做权限控制
继承BasicAuthenticationFilter
类,重写doFilterInternal
过滤器,在这个过滤器里获取token并验证,并进行动态权限控制。
@Slf4j public class TokenAuthenticationFilter extends BasicAuthenticationFilter { private TokenManager tokenManager; private AntPathMatcher antPathMatcher = new AntPathMatcher(); public TokenAuthenticationFilter(AuthenticationManager authManager, TokenManager tokenManager) { super(authManager); this.tokenManager = tokenManager; } @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException { UsernamePasswordAuthenticationToken authentication = null; try { log.info("授权过滤器,验证Token..."); authentication = getAuthentication(request); } catch (ExpiredJwtException e) { // 可能token过期 log.info("异常捕获:{}",e.getMessage()); ResponseUtil.out(response, ResponseResult.unauthorized()); } if (authentication != null) { String url = request.getRequestURI(); // setAuthentication设置不设置都行,如果需要用注解来控制权限,则必须设置 SecurityContextHolder.getContext().setAuthentication(authentication); UserServiceImpl userService = new UserServiceImpl(); List menuList = userService.getAllMenus(); // 遍历所有菜单 for (Menu menu : menuList) { // 如果url匹配上了 if (antPathMatcher.match(menu.getPattern(), url) && menu.getRoles().size() > 0){ log.info("URL匹配上了,请求URL:{},匹配上的URL:{}", url, menu.getPattern()); List stringList = new ArrayList<>(); for (GrantedAuthority authority : authentication.getAuthorities()) { String authority1 = authority.getAuthority(); stringList.add(authority1); } for (Role role : menu.getRoles()) { if (stringList.contains(role.getName())) { log.info("角色匹配,角色为:{}", role.getName()); chain.doFilter(request, response); return; } } // 没有权限 log.info("URL匹配上了,但无权访问,请求URL:{},匹配上的URL:{}", url, menu.getPattern()); ResponseUtil.out(response, ResponseResult.noPermission()); return; } } // url没有匹配上菜单,可以访问 log.info("URL未匹配上,所有人都可以访问!"); chain.doFilter(request, response); } else { // 没有登录 log.info("用户Token无效!"); } } private UsernamePasswordAuthenticationToken getAuthentication(HttpServletRequest request) { // token置于header里 String token = request.getHeader("X-Token"); log.info("X-Token:{}", token); if (token != null && !"".equals(token.trim())) { // 根据token获取用户名 String userName = tokenManager.getUserFromToken(token); // 这里可以根据用户名去Redis中取出权限集合 // 不应该从SecurityContextHolder获取,会出现问题,如果你换一个token(这个token也是有效的)来调用方法,从这里取,这权限还是之前token登录时存进来的(经过我测试) // 为什么呢?我的猜测是:因为JWT是无状态的,你没有办法在注销的时候,将SpringSecurity全局对象里的东西清理 // 如果你先用账号2登录获取一个token2,然后用账号1登录获取一个token1,用token1去调用一次api的时候从SecurityContextHolder获取一次权限,然后用token2去调用一次api获取一次权限,你会发现这个权限居然是token1拥有的(我测试过) // Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); // List permissionValueList = (List) redisTemplate.opsForValue().get(userName); // 这里直接模拟从Redis中取出权限 List permissionValueList = new ArrayList<>(); // 权限 - 为了测试根据权限控制访问权限 permissionValueList.add("admin.test"); // 角色 - 为了测试根据角色控制访问权限 permissionValueList.add("ROLE_admin"); // 需要将权限转换成SpringSecurity认识的 Collection authorities = new ArrayList<>(); for(String permissionValue : permissionValueList) { if(StringUtils.isEmpty(permissionValue)) { continue; } SimpleGrantedAuthority authority = new SimpleGrantedAuthority(permissionValue); authorities.add(authority); } if (!StringUtils.isEmpty(userName)) { log.info("授权过滤器:授权完成!"); return new UsernamePasswordAuthenticationToken(userName, token, authorities); } return null; } return null; } }
如果不做动态权限,则可以省略那一部分对比url的代码。但是用户的拥有的权限,建议存储在Redis里。每次进入到这个过滤器,就将其取出来封装成Security认识的,放到SecurityContextHolder
里,这个时候你的权限是定死了的,可以在配置文件里进行配置,也可以使用注解在Controller里进行控制。
如果你要做动态权限,则可以根据你自己的逻辑修改那一部分对比url的代码。基于角色控制权限,某个角色拥有某些权限(可访问接口)的这个原则来做。
我在写这一部分代码的时候,感觉还是有点复杂的。用户的权限、所有菜单都可以存放在Redis里,两者发生改变的时候就清除Redis里的数据,下一次读取的时候先从数据库里读取,然后将其放到Redis缓存里,这一部分逻辑,由于一开始只是打算写一个小Demo(如果真要写的话,还需要创建相关的数据库表、连接Redis之类的,有点麻烦),所以我没有写,读者如果有兴趣可以自己去实现以下,这个并不难。
三、注销阶段
注销的时候,应该将Redis里存储的权限进行删除(如果有的话)。
public class TokenLogoutHandler implements LogoutHandler { /** Token管理类 */ private TokenManager tokenManager; public TokenLogoutHandler(TokenManager tokenManager) { this.tokenManager = tokenManager; } /** * 登出业务处理 * @param request request * @param response response * @param authentication */ @Override public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) { String token = request.getHeader("X-Token"); if (token != null) { /* tokenManager.removeToken(token); */ try{ String userName = tokenManager.getUserFromToken(token); }catch (ExpiredJwtException e){ // 可能token过期了,所以得捕获 ResponseUtil.out(response, ResponseResult.ok()); } // 清空当前用户缓存中的权限数据 // 如果你的权限使用userName作为key存在Redis中,可以通过userName将缓存清空 // .... } ResponseUtil.out(response, ResponseResult.ok()); } }
四、未授权处理
如果你使用了注解或是在配置文件中手动配置了权限管理,即让SpringSecurity
帮你管理权限,当你访问到没有权限访问的接口时,回来到这里。
@Slf4j public class UnauthorizedEntryPoint implements AuthenticationEntryPoint { /** * 未授权返回错误码 * @param request request * @param response response * @param authException authException * @throws IOException * @throws ServletException */ @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { // 响应错误码 ResponseUtil.out(response, ResponseResult.error()); } }
五、SpringSecurity配置文件核心代码
这里主要是想说一件事,就是在我们的认证、授权、退出处理器的配置时,如果这个类里需要使用到其他类(对象),可以通过构造方法的方式传进去,因为它们没有被Spring管理,你是没有办法使用@Autowired
注入的。
/** * 配置设置 - 更多配置项见官方文档 * @param http http * @throws Exception */ @Override protected void configure(HttpSecurity http) throws Exception { http.exceptionHandling() .authenticationEntryPoint(new UnauthorizedEntryPoint()) // 允许跨域 .and().cors() .and().csrf().disable() .authorizeRequests() .anyRequest().authenticated() // 退出请求路径 .and().logout().logoutUrl("/service_auth/admin/index/logout") // 退出处理器 .addLogoutHandler(new TokenLogoutHandler(tokenManager)).and() // 认证过滤器 .addFilter(new TokenLoginFilter(authenticationManager(), tokenManager)) // 授权过滤器 .addFilter(new TokenAuthenticationFilter(authenticationManager(), tokenManager)).httpBasic(); }
总结
核心其实就是前面贴出的一、二(认证和授权),其实还是有优化空间的,我本人对于SpringSecurity
没有了解很深,所以只能写成这样。
如果读者感兴趣,可以下载源码阅读,源码里注释也是非常详细的。
- https://github.com/liuchengyin01/JwtWithSpringSecurityDemo
- https://gitee.com/liuchengyin_vae/JwtWithSpringSecurityDemo
