- 联系方式: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 项目
-
由于总体上比较简单,这里只描述一些重要的点,其余的自行查看发布到
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
- 登录李四用户
-
带着
token
访问OneController
的各个接口- Get
- Post
- Put
- Delete
-
带着
token
访问OneController
的各个接口- Get
- Post
- Put
- Delete
-
可以看到李四这个
Common
用户,按照我们之前的规划,只能访问OneController
的接口,访问不到TwoController
的接口
3.3 Another
- 对于其他情况,比如说
token
过期,未登录,还是说token
非法等情况 - 在拦截器中均有对应的情况解决
- 也就是直接抛出对应的装载了自定义状态码的异常
- 然后统一解决异常处理
- 在这里就不再一一演示了
4. 总结
-
总的来说,逻辑上是没啥问题的
-
基本上能够实现登录验权的基本功能
-
只要写
Controller
接口时,在请求映射注解上通过name
属性标明此接口的唯一标识符(因为现在大多数使用的是Restful
风格,这里推荐通过自定义注解上的属性来标识每个接口的唯一标识符,这样在Interceptor
中便于获取每个接口的唯一标识符,不用再枚举使用的是哪个映射注解) -
然后再到权限表中插入此接口信息,最后在角色-权限表中设置不同角色对应的权限映射关系即可
-
但也带来很多问题
-
得手动插入接口信息入表,得手动设置角色-权限表中的映射关系,很麻烦
最好是当接口写的差不多后,再统一弄这个,会节省很多时间,但是还是很麻烦,或者看能不能自己写个工具类出来,自动完成这一工作,目前这个正在考虑怎么写ing
-
token本身可能带来的问题
比如说
token
发布后,比如说设置了有效时间为半个钟,那么这半个钟内此token
都有效,无法主动注销此token
(玩点极端的,可以重启Redis
服务器,付出让所有在线用户掉线一次的代价销毁此token
的作用,然后赶紧跑路)- 还是假设
token
发布,其有效时间为半个钟,原本此用户是无法A接口的,在这半个钟内,超级管理员设置了此用户可以访问A接口,但是用户对应权限信息只是实时更新到了数据库中,而Redis
中还是存的是老旧的用户信息,也就代表着使用刚刚发布的token
来访问时,从Redis
中获取到的用户信息,是不具有访问A接口的权限的,很矛盾。(让用户退出重新登录即可)
-
-
总而言之:仅供参考