花了点时间写了一个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去校验账号、密码是否正确。
@Overridepublic 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方法内部:
然后进入这个方法内部,继续往下走,看到一段核心代码:
进入retrieveUser方法里,然后往下走,看到一句核心代码,这个核心代码就是获取用户信息的:
这里注意,调用了UserDetailsService的loadUserByUsername方法,传入的就是前端传过来的username,意思就是要根据这个username去获取UserDetails对象,所以我们就要去查询数据库,所以我们就要实现UserDetailsService接口并重写loadUserByUsername方法。
@Service("userDetailsService")@Slf4jpublic 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 */@Overrideprotected 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 */@Overrideprotected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException { log.info("TokenLoginFilter-unsuccessfulAuthentication:认证失败!"); // 响应给前端调用处 ResponseUtil.out(response, ResponseResult.error());}
在这个方法里,直接响应给前端错误情况即可。因为我这个Demo是基于前后端分离的,因此只需响应给前端结果、状态码即可,让前端来跳转。如果不是前后端分离的,可以在这里进行页面跳转。
继承BasicAuthenticationFilter类,重写doFilterInternal过滤器,在这个过滤器里获取token并验证,并进行动态权限控制。
@Slf4jpublic 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
如果不做动态权限,则可以省略那一部分对比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帮你管理权限,当你访问到没有权限访问的接口时,回来到这里。
@Slf4jpublic 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()); }}
这里主要是想说一件事,就是在我们的认证、授权、退出处理器的配置时,如果这个类里需要使用到其他类(对象),可以通过构造方法的方式传进去,因为它们没有被Spring管理,你是没有办法使用@Autowired注入的。
/** * 配置设置 - 更多配置项见官方文档 * @param http http * @throws Exception */@Overrideprotected 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://mp.weixin.qq.com/s/cgZara2gJIewpul432ncBQ
留言与评论(共有 0 条评论) “” |