天天看点

基于Interceptor+JWT+Redis的后端API权限验证小实现

作者:Java解白

前言

  • 本章节是做一个小型的前后端项目的验证授权控制简单Demo实现方案
  • 重点在于后端API的权限认证
  • 这种实现方式比较low,不容易开发维护,此博客还写的很丑陋,建议自己看项目代码分析就好(重点在于Interceptor的逻辑),很快就明白的。
  • 项目地址:github.com/Tonciy/perm…

1. 描述

1.1 实现思路

  • 总体流程如下:
  • 登录逻辑如下:
  • 请求权限认证如下

1.2 用户访问API划分

  • 如下图所示

2. 环境搭建

2.1 数据库

  • 基于RBAC模型建立的,比较简单
  • 在这里要特别描述下权限表的各个字段含义:
    • 比如做SaaS如果还要细分的话,权限表实际上还可以拆,这里只是一个权限认证的小Demo,就简单化了
  • 本Demo中权限表中的具体数据如下图所示(特别注意每行记录中的api_identify值,代表某个接口的唯一标识符,后面在Controller中会有对应标明")

2.2 项目

  • 项目地址:github.com/Tonciy/perm…
  • 由于总体上比较简单,这里只描述一些重要的点,其余的自行查看发布到GitHub的项目
  • 登录接口
  • @RestController @RequestMapping("/login") public class LoginController { @Resource private UserService userService; @Resource private JwtUtils jwtUtils; ​ @Resource private RedisTemplate<String, Object> redisTemplate; ​ @Value("${redis.user.prefix}") private String redisKeyPrefix; ​ @Value("${jwt.config.ttl}") private Long time = 1800L; ​ @PostMapping public Result login(String username, String password) throws CommonException { if(StringUtils.isEmpty(username) || StringUtils.isEmpty(password)){ throw new CommonException(ResultCode.REQUEST_PARARMETER_MISS); } User user = userService.findByUsername(username); if(user == null){ // 不存在此用户,登录失败 return new Result(ResultCode.USERNAME_PASSWORD_ERROR); }else{ // 比对密码 if(password.equals(user.getPassword())){ // 登录成功,存储当前用户到Redis里(设置存活时间) 签发token redisTemplate.opsForValue().set(redisKeyPrefix + user.getId(), user, time, TimeUnit.SECONDS); String token = jwtUtils.createJwt(user.getId(), user.getUsername(), null); return Result.SUCCESS(token); }else{ // 密码错误 return new Result(ResultCode.USERNAME_PASSWORD_ERROR); } } } } 复制代码
    • 这里的登录逻辑是按照上述的逻辑图来实现的
    • 特别注意用户信息存放到Redis中的key,是通过配置的前缀 + 用户id拼接成的
    • 有效时间也是通过配置来设置的,否则有个默认时间
  • 两个Controller(注意每个接口上的请求映射注解name属性上,都标明了此接口对应的唯一标识符)
  • 统一状态码封装
  • public enum ResultCode { ​ ​ SUCCESS(true, 10000, "操作成功!"), //---系统错误返回码----- FAIL(false, 10001, "操作失败"), UNAUTHENTICATED(false, 10002, "您还未登录"), TOKEN_LOSE_EFFICACY(false, 10003, "登录凭证已失效!"), UNAUTHORISE(false, 10004, "权限不足"), ​ /** * 登录失败异常 */ USERNAME_PASSWORD_ERROR(false, 20001, "用户名或者密码错误"), ​ REQUEST_PARARMETER_MISS(false, 30000, "请求参数缺失"), /** * 请求类型不支持 */ REQUEST_METHOD_NOT_SUPPORT(false, 40000, "不支持的请求类型"), SERVER_ERROR(false, 99999, "抱歉,系统繁忙,请稍后重试!"); //---其他操作返回码---- ​ ​ //操作是否成功 boolean success; //操作代码 int code; //提示信息 String message; ​ ResultCode(boolean success, int code, String message) { this.success = success; this.code = code; this.message = message; } ​ public boolean success() { return success; } ​ public int code() { return code; } ​ public String message() { return message; } } 复制代码
  • 自定义异常
  • 异常统一处理
  • ​ /** * @author: Zero * @time: 2022/12/28 * @description: 统一异常处理 */ @RestControllerAdvice public class BaseExceptionHandler { ​ /** * 通用自定义异常捕获(登录状态/权限验证) * * @return */ @ExceptionHandler(value = CommonException.class) public Result commonException(CommonException exception) { if (exception.getMessage() != null && exception.getMessage().equals(ResultCode.REQUEST_PARARMETER_MISS.message())) { // 请求参数缺失 return new Result(ResultCode.REQUEST_PARARMETER_MISS); } if (exception.getMessage() != null && exception.getMessage().equals(ResultCode.UNAUTHENTICATED.message())) { // 未登录/token非法 return new Result(ResultCode.UNAUTHENTICATED); } if (exception.getMessage() != null && exception.getMessage().equals(ResultCode.TOKEN_LOSE_EFFICACY.message())) { // 登录凭证token已经失效 return new Result(ResultCode.TOKEN_LOSE_EFFICACY); } if (exception.getMessage() != null && exception.getMessage().equals(ResultCode.UNAUTHORISE.message())) { // 访问权限不足 return new Result(ResultCode.UNAUTHORISE); } if (exception.getMessage() != null && exception.getMessage().equals(ResultCode.REQUEST_METHOD_NOT_SUPPORT.message())) { // 不支持的请求方法类型 return new Result(ResultCode.REQUEST_METHOD_NOT_SUPPORT); } if (exception.getMessage() != null) { // 给定异常信息 return new Result(10001, exception.getMessage(), false); } // 请求失败 return new Result(ResultCode.FAIL); } ​ ​ /** * 服务器异常统一返回 * * @return */ @ExceptionHandler(value = Exception.class) public Result error() { return new Result(ResultCode.SERVER_ERROR); } } 复制代码
  • 拦截器实现
  • /** * @author: Zero * @time: 2022/12/28 * @description: */ public class RequestInterceptor implements HandlerInterceptor { @Resource private RedisTemplate<String, Object> redisTemplate; ​ @Resource private JwtUtils jwtUtils; ​ ​ @Value("${redis.user.prefix}") private String redisKeyPrefix; ​ @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 1. 获取token String authorization = request.getHeader("Authorization"); // 2. 验证token (不为null 且 开头为"Bearer ",签发的时候是以"Bearer "开头,后面再接token实际值-业界统一这样做,也不知道为啥) if (!StringUtils.isEmpty(authorization) && authorization.startsWith("Bearer ")) { String token = authorization.replace("Bearer ", ""); Claims claims = null; try { claims = jwtUtils.parseJwt(token); } catch (ExpiredJwtException e) { e.printStackTrace(); throw new CommonException(ResultCode.TOKEN_LOSE_EFFICACY); // token失效 } catch (UnsupportedJwtException e) { e.printStackTrace(); throw new CommonException("不支持的token"); } catch (MalformedJwtException e) { e.printStackTrace(); throw new CommonException("token解析失败"); } catch (SignatureException e) { e.printStackTrace(); throw new CommonException("token签名验证失败"); } catch (IllegalArgumentException e) { e.printStackTrace(); throw new CommonException("token非法参数"); } if (claims != null) { // 已登录 // 从Redis中获取用户,从而获取权限信息 User user = (User) redisTemplate.opsForValue().get(redisKeyPrefix + claims.getId()); List<Permission> permissions = null; if (user != null) { permissions = user.getPermissions(); } else { // Redis出问题,导致保存的已经登录的用户信息没了(注意不是登录时间失效了) throw new CommonException(ResultCode.SERVER_ERROR); } // 通过注解反射获取每个API接口的唯一标识符 // --在这里的是唯一标识符是在Controller的方法上的@RequestMapping的name属性标明的,数据库的API也有 // --可以自己自定义注解接口来实现(这样获取时比较容易),使用Restful风格时推荐使用, // -- 使用了Restful风格但是没有统一使用@RequestMapping的话那就根据请求类型来获取注解 HandlerMethod h = (HandlerMethod) handler; // 获取接口上的@RequestMapping注解 Object annotation = null; // 获取请求类型 String method = request.getMethod().toUpperCase(); String name = null; // 表示目标接口处的唯一标识符 boolean pass = false; // 表示最终是否有权限访问此接口 switch (method) { case "GET": annotation = h.getMethodAnnotation(GetMapping.class); name = ((GetMapping) annotation).name(); break; case "POST": annotation = h.getMethodAnnotation(PostMapping.class); name = ((PostMapping) annotation).name(); break; case "DELETE": annotation = h.getMethodAnnotation(DeleteMapping.class); name = ((DeleteMapping) annotation).name(); break; case "PUT": annotation = h.getMethodAnnotation(PutMapping.class); name = ((PutMapping) annotation).name(); break; default: throw new CommonException(ResultCode.REQUEST_METHOD_NOT_SUPPORT); } if (permissions != null && !StringUtils.isEmpty(name)) { //如需权限限定时使用开放此句即可 for (Permission permission : permissions) { if (permission.getApiIdentify() != null && permission.getApiIdentify().equals(name)) { // 具有访问权限 pass = true; break; } } } if (pass) { // // 表示具有访问权限 return true; } else { // 无访问权限 throw new CommonException(ResultCode.UNAUTHORISE); } ​ } } // 未登录/token格式不对 throw new CommonException(ResultCode.UNAUTHENTICATED); } ​ } ​ 复制代码
  • 配置文件

3. 实践测试

3.1 Admin

  • 登录张三用户
  • 带着token访问OneController的各个接口
    • Get
    • Post
    • Put
    • Delete
  • 带着token访问TwoController的各个接口
    • Get
    • Post
    • Put
    • Delete
  • 可以看到张三这个Admin用户正如我们所愿,可以访问到OneController和TwoController中的接口

3.2 Common

  • 登录李四用户
基于Interceptor+JWT+Redis的后端API权限验证小实现
  • 带着token访问OneController的各个接口
    • Get
    • Post
    • Put
    • Delete
  • 带着token访问OneController的各个接口
    • Get
    • Post
    • Put
    • Delete
  • 可以看到李四这个Common用户,按照我们之前的规划,只能访问OneController的接口,访问不到TwoController的接口

3.3 Another

  1. 对于其他情况,比如说token过期,未登录,还是说token非法等情况
  2. 在拦截器中均有对应的情况解决
  3. 也就是直接抛出对应的装载了自定义状态码的异常
  4. 然后统一解决异常处理
  5. 在这里就不再一一演示了

4. 总结

  1. 总的来说,逻辑上是没啥问题的
  2. 基本上能够实现登录验权的基本功能
  3. 只要写Controller接口时,在请求映射注解上通过name属性标明此接口的唯一标识符(因为现在大多数使用的是Restful风格,这里推荐通过自定义注解上的属性来标识每个接口的唯一标识符,这样在Interceptor中便于获取每个接口的唯一标识符,不用再枚举使用的是哪个映射注解)
  4. 然后再到权限表中插入此接口信息,最后在角色-权限表中设置不同角色对应的权限映射关系即可
  5. 但也带来很多问题
    1. 得手动插入接口信息入表,得手动设置角色-权限表中的映射关系,很麻烦
    2. 最好是当接口写的差不多后,再统一弄这个,会节省很多时间,但是还是很麻烦,或者看能不能自己写个工具类出来,自动完成这一工作,目前这个正在考虑怎么写ing
    3. token本身可能带来的问题
    4. 比如说
      1. token发布后,比如说设置了有效时间为半个钟,那么这半个钟内此token都有效,无法主动注销此token(玩点极端的,可以重启Redis服务器,付出让所有在线用户掉线一次的代价销毁此token的作用,然后赶紧跑路)
      2. 还是假设token发布,其有效时间为半个钟,原本此用户是无法A接口的,在这半个钟内,超级管理员设置了此用户可以访问A接口,但是用户对应权限信息只是实时更新到了数据库中,而Redis中还是存的是老旧的用户信息,也就代表着使用刚刚发布的token来访问时,从Redis中获取到的用户信息,是不具有访问A接口的权限的,很矛盾。(让用户退出重新登录即可)
  6. 总而言之:仅供参考

继续阅读