1. 描述
1.1 实现思路
总体流程如下:
![](https://i-blog.csdnimg.cn/blog_migrate/a98ec7de8c0913d6291bbbb75a269109.png)
登录逻辑如下:
![](https://i-blog.csdnimg.cn/blog_migrate/3fd49350efe8162a08d1f7c755f2259f.png)
请求权限认证如下
![](https://i-blog.csdnimg.cn/blog_migrate/0a8c07e8738f303d4088222ccd466d82.png)
1.2 用户访问API划分
如下图所示
![](https://i-blog.csdnimg.cn/blog_migrate/4a66d1a7876a0e6a07d3c938578f1058.png)
2. 环境搭建
2.1 数据库
基于RBAC模型建立的,比较简单
![](https://i-blog.csdnimg.cn/blog_migrate/3ba67b092103356527cce8d1214e5482.png)
在这里要特别描述下权限表的各个字段含义:
![](https://i-blog.csdnimg.cn/blog_migrate/c88bf00510b04bea2078ae426a54be3e.png)
比如做SaaS如果还要细分的话,权限表实际上还可以拆,这里只是一个权限认证的小Demo,就简单化了
本Demo中权限表中的具体数据如下图所示(特别注意每行记录中的api_identify值,代表某个接口的唯一标识符,后面在Controller中会有对应标明")
![](https://i-blog.csdnimg.cn/blog_migrate/4ca5b4af9b4c542ed198bb240d0e9b8b.png)
2.2 项目
由于总体上比较简单,这里只描述一些重要的点,其余的自行查看发布到GitHub的项目
登录接口
@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拼接成的
有效时间也是通过配置来设置的,否则有个默认时间
两个Controller(注意每个接口上的请求映射注解name属性上,都标明了此接口对应的唯一标识符)
![](https://i-blog.csdnimg.cn/blog_migrate/e3a8bf0d2bcfe4778a4df13517d2b7c6.png)
统一状态码封装
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;
}
}
复制代码
自定义异常
![](https://i-blog.csdnimg.cn/blog_migrate/0161089627343fadbb3727ea8116ddf0.png)
异常统一处理
/**
* @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:
*/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);
}
}
复制代码
配置文件
![](https://i-blog.csdnimg.cn/blog_migrate/4364978993fe70769491fd4a20a659da.png)
3. 实践测试
3.1 Admin
登录张三用户
![](https://i-blog.csdnimg.cn/blog_migrate/07c916a5350d5f8ba68efd39303d2d82.png)
带着token访问OneController的各个接口
Get
![](https://i-blog.csdnimg.cn/blog_migrate/b2ce12ac4c46cd73f7b1590d774d4ce2.png)
Post
![](https://i-blog.csdnimg.cn/blog_migrate/eb3f9660657cb9fc5b28a4e705cd1934.png)
Put
![](https://i-blog.csdnimg.cn/blog_migrate/b6f135c13484a790484be1ea0c5deaf0.png)
Delete
![](https://i-blog.csdnimg.cn/blog_migrate/0cd68fe8ed4f72b3b12bf72035de3fe5.png)
带着token访问TwoController的各个接口
Get
![](https://i-blog.csdnimg.cn/blog_migrate/cbfde7a7b44324be42f659133847b862.png)
Post
![](https://i-blog.csdnimg.cn/blog_migrate/45d0e45816b84f6d0b3a5d6db3bd152a.png)
Put
![](https://i-blog.csdnimg.cn/blog_migrate/a08036505d83b18c8741dc8b3e4f8483.png)
Delete
![](https://i-blog.csdnimg.cn/blog_migrate/42d56ace74f1782040bea479ec0a4a56.png)
可以看到张三这个Admin用户正如我们所愿,可以访问到OneController和TwoController中的接口
3.2 Common
登录李四用户
![](https://i-blog.csdnimg.cn/blog_migrate/76af4eaf71e43e18050b332df9c55408.png)
带着token访问OneController的各个接口
Get
![](https://i-blog.csdnimg.cn/blog_migrate/9d1d1f7cf1831fe527a82722f1f920de.png)
Post
![](https://i-blog.csdnimg.cn/blog_migrate/0b5a9ed09bc57835b28e3d7a69832a17.png)
Put
![](https://i-blog.csdnimg.cn/blog_migrate/66f320b6fbe998fdeae153bd63c475b9.png)
Delete
![](https://i-blog.csdnimg.cn/blog_migrate/6d9c91ece95cf2c66b0f72a76b754bf7.png)
带着token访问OneController的各个接口
Get
Post
![](https://i-blog.csdnimg.cn/blog_migrate/ec591f6a6598a4c812eaca6c943ed867.png)
Put
![](https://i-blog.csdnimg.cn/blog_migrate/7e4642ab2a6f12d8f8afc5a6c4449c47.png)
Delete
![](https://i-blog.csdnimg.cn/blog_migrate/e146f93f0353b8c6e00bf59e18f2a0ad.png)
可以看到李四这个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接口的权限的,很矛盾。(让用户退出重新登录即可)
总而言之:仅供参考
作者:之诺