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

1. 描述

1.1 实现思路

  • 总体流程如下:

  • 登录逻辑如下:

  • 请求权限认证如下

1.2 用户访问API划分

  • 如下图所示

2. 环境搭建

2.1 数据库

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

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

  • 比如做SaaS如果还要细分的话,权限表实际上还可以拆,这里只是一个权限认证的小Demo,就简单化了

  • 本Demo中权限表中的具体数据如下图所示(特别注意每行记录中的api_identify值,代表某个接口的唯一标识符,后面在Controller中会有对应标明")

2.2 项目

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

  1. 登录接口

@RestController@RequestMapping("/login")publicclassLoginController {
    @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}")
    privateLong 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拼接成的

  • 有效时间也是通过配置来设置的,否则有个默认时间

  1. 两个Controller(注意每个接口上的请求映射注解name属性上,都标明了此接口对应的唯一标识符

  1. 统一状态码封装

publicenumResultCode {
​
​
    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;
    }
​
    publicbooleansuccess(){
        return success;
    }
​
    publicintcode(){
        return code;
    }
​
    publicStringmessage(){
        return message;
    }
}
复制代码
  1. 自定义异常

  1. 异常统一处理

​
/**
 * @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);
    }
}
复制代码
  1. 拦截器实现

/**
 * @author: Zero
 * @time: 2022/12/28
 * @description:
 */publicclassRequestInterceptorimplementsHandlerInterceptor {
    @Resource
    private RedisTemplate<String, Object> redisTemplate;
​
    @Resource
    private JwtUtils jwtUtils;
​
​
    @Value("${redis.user.prefix}")
    private String redisKeyPrefix;
​
    @Override
    publicbooleanpreHandle(HttpServletRequest request, HttpServletResponse response, Object handler)throws Exception {
        // 1. 获取token
        Stringauthorization= request.getHeader("Authorization");
        // 2. 验证token  (不为null 且 开头为"Bearer ",签发的时候是以"Bearer "开头,后面再接token实际值-业界统一这样做,也不知道为啥)
        if (!StringUtils.isEmpty(authorization) && authorization.startsWith("Bearer ")) {
            Stringtoken= authorization.replace("Bearer ", "");
            Claimsclaims=null;
            try {
                claims = jwtUtils.parseJwt(token);
            } catch (ExpiredJwtException e) {
                e.printStackTrace();
                thrownewCommonException(ResultCode.TOKEN_LOSE_EFFICACY); // token失效
            } catch (UnsupportedJwtException e) {
                e.printStackTrace();
                thrownewCommonException("不支持的token");
            } catch (MalformedJwtException e) {
                e.printStackTrace();
                thrownewCommonException("token解析失败");
            } catch (SignatureException e) {
                e.printStackTrace();
                thrownewCommonException("token签名验证失败");
            } catch (IllegalArgumentException e) {
                e.printStackTrace();
                thrownewCommonException("token非法参数");
            }
            if (claims != null) {
                // 已登录
                // 从Redis中获取用户,从而获取权限信息
                Useruser= (User) redisTemplate.opsForValue().get(redisKeyPrefix + claims.getId());
                List<Permission> permissions = null;
                if (user != null) {
                    permissions = user.getPermissions();
                } else {
                    // Redis出问题,导致保存的已经登录的用户信息没了(注意不是登录时间失效了)
                    thrownewCommonException(ResultCode.SERVER_ERROR);
                }
                // 通过注解反射获取每个API接口的唯一标识符
                //  --在这里的是唯一标识符是在Controller的方法上的@RequestMapping的name属性标明的,数据库的API也有
                //  --可以自己自定义注解接口来实现(这样获取时比较容易),使用Restful风格时推荐使用,
                //  -- 使用了Restful风格但是没有统一使用@RequestMapping的话那就根据请求类型来获取注解
                HandlerMethodh= (HandlerMethod) handler;
                //  获取接口上的@RequestMapping注解
                Objectannotation=null;
                // 获取请求类型
                Stringmethod= request.getMethod().toUpperCase();
                Stringname=null;    // 表示目标接口处的唯一标识符
                booleanpass=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:
                        thrownewCommonException(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) { //
                    // 表示具有访问权限
                    returntrue;
                } else {
                    // 无访问权限
                    thrownewCommonException(ResultCode.UNAUTHORISE);
                }
​
            }
        }
        // 未登录/token格式不对
        thrownewCommonException(ResultCode.UNAUTHENTICATED);
    }
​
}
​
复制代码
  1. 配置文件

3. 实践测试

3.1 Admin

  • 登录张三用户

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

  • Get

  • Post

  • Put

  • Delete

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

  • Get

  • Post

  • Put

  • Delete

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

3.2 Common

  • 登录李四用户

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

  • Get

  • Post

  • Put

  • Delete

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

  • Get

  • Post

  • Put

  • Delete

  • 可以看到李四这个Common用户,按照我们之前的规划,只能访问OneController的接口,访问不到TwoController的接口

3.3 Another

  1. 对于其他情况,比如说token过期,未登录,还是说token非法等情况

  1. 在拦截器中均有对应的情况解决

  1. 也就是直接抛出对应的装载了自定义状态码的异常

  1. 然后统一解决异常处理

  1. 在这里就不再一一演示了

4. 总结

  1. 总的来说,逻辑上是没啥问题的

  1. 基本上能够实现登录验权的基本功能

  1. 只要写Controller接口时,在请求映射注解上通过name属性标明此接口的唯一标识符因为现在大多数使用的是Restful风格,这里推荐通过自定义注解上的属性来标识每个接口的唯一标识符,这样在Interceptor中便于获取每个接口的唯一标识符,不用再枚举使用的是哪个映射注解

  1. 然后再到权限表中插入此接口信息,最后在角色-权限表中设置不同角色对应的权限映射关系即可

  1. 但也带来很多问题

  1. 得手动插入接口信息入表,得手动设置角色-权限表中的映射关系,很麻烦

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

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

作者:之诺

原文链接:https://juejin.cn/post/7187228194406137914

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值