基于Interceptor+JWT+Redis的权限验证实现小Demo

基于Interceptor+JWT+Redis的权限验证实现小Demo

捡破烂的诗人 974 2023-01-11
  • 联系方式:1761430646@qq.com
  • 编写时间:2023年1月10日17:55:51
  • 博客地址:www.zeroeden.cn
  • 菜狗摸索,有误勿喷,烦请联系

前言

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

1. 描述

1.1 实现思路

  • 总体流程如下:

  • 登录逻辑如下:

  • 请求权限认证如下

1.2 用户访问API划分

  • 如下图所示

2. 环境搭建

2.1 数据库

  • 基于RBAC模型建立的,比较简单

  • 在这里要特别描述下权限表的各个字段含义:

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

2.2 项目

  • 项目地址:https://github.com/Tonciy/permission-control.git

  • 由于总体上比较简单,这里只描述一些重要的点,其余的自行查看发布到GitHub的项目

    1. 登录接口

      @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拼接成的
      • 有效时间也是通过配置来设置的,否则有个默认时间
    2. 两个Controller注意每个接口上的请求映射注解name属性上,都标明了此接口对应的唯一标识符

    3. 统一状态码封装

      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;
          }
      }
      
    4. 自定义异常

    5. 异常统一处理(这里代码写的很丑陋)

      /**
       * @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);
          }
      }
      
    6. 拦截器实现

      /**
       * @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);
          }
      
      }
      
      
    7. 配置文件

3. 实践测试

3.1 Admin

  • 登录张三用户

  • 带着token访问OneController的各个接口

    • Get

    • Post

    • Put

    • Delete

  • 带着token访问TwoController的各个接口

    • Get

    • Post

    • Put

    • Delete

  • 可以看到张三这个Admin用户正如我们所愿,可以访问到OneControllerTwoController中的接口

3.2 Common

  • 登录李四用户

  • 带着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. 得手动插入接口信息入表,得手动设置角色-权限表中的映射关系,很麻烦

      最好是当接口写的差不多后,再统一弄这个,会节省很多时间,但是还是很麻烦,或者看能不能自己写个工具类出来,自动完成这一工作,目前这个正在考虑怎么写ing

    2. token本身可能带来的问题

      比如说

      1. token发布后,比如说设置了有效时间为半个钟,那么这半个钟内此token都有效,无法主动注销此token(玩点极端的,可以重启Redis服务器,付出让所有在线用户掉线一次的代价销毁此token的作用,然后赶紧跑路)
      2. 还是假设token发布,其有效时间为半个钟,原本此用户是无法A接口的,在这半个钟内,超级管理员设置了此用户可以访问A接口,但是用户对应权限信息只是实时更新到了数据库中,而Redis中还是存的是老旧的用户信息,也就代表着使用刚刚发布的token来访问时,从Redis中获取到的用户信息,是不具有访问A接口的权限的,很矛盾。(让用户退出重新登录即可)
  6. 总而言之:仅供参考


# Interceptor # 验证授权 # Permission # JWT # Redis