前言
权限验证,是我们一个系统设计当中,经常会遇到的问题,常见的框架当然有Shiro,SpringSecurity 等等。但是有个问题,不论是那个框架都有一定的学习成本,同时在当前Result Api 风格的加持之下,这些框架虽然很优秀,但是我们使用到的功能往往只是其中冰山一角。尤其是当我们需要为我们的服务做更加个性化的操作,定制的时候,往往涉及到的改动就比较大。所以,今天咱们就来直接使用到我们的Spring AOP 来快速实现我们的权限认证操作。
当然也是一个月没有水文章了,在这里冒个泡泡~
基本流程
在开始之前,毫无疑问,我们需要知道我们权限认证的基本流程,首先要明白一件事情。我们要认证,我要知道你这个人,那么首先我就一定需要一个标识。就比如,你有工牌我才知道你是谁,你的工牌上面写了你的身份职位,那么我就知道,要叫你小王,还是王工,还是王总。所以,在这里我们要认识清楚,我们这里有两个比较重要的信息,需要我们确认,首先就是你的工牌如何获得,然后在工牌上面怎么记录信息。那么解决了这两个问题,那么我们的这个权限认证其实就搞定了一大半了,其他的无非就是代码实现而已。
登录
首先的话,我们还是需要先看到登录,登录是获取到工牌的一个开始。我们经常做到的一个操作无非就是。
- 用户输入密码
- 服务端校验用户,签发token
- 客户端携带token 访问受限资源
- 服务端,验证token是否正确
这个就是我们登录的基本流程,毫无疑问,这个其实就已经涉及到了基本的验证。那就是你如何校验token是正确的。如果我在token当中,存放了用户的角色信息,那么是不是说我们可以直接完成用户的校验。答案当然是,实际上这个就是最简单的一个权限认证,只是这个认证里面一开始是没有携带角色信息的。
JWT
之后,我们谈到了登录,我们知道我们要签发token,然后签发token的话,我们一般会使用到JWT。那么售卖是JWT,JWT是一种签发加密的算法,机制,用来生成到我们的token。
JWT(JSON Web Token)由三部分组成,分别是头部(Header)、载荷(Payload)和签名(Signature)。这三部分是通过点号(.)连接在一起的,形成了一个完整的JWT字符串。其中:
-
头部(Header):头部通常包含了两部分信息:令牌的类型(比如JWT)和所使用的签名算法(比如HMAC SHA256或RSA)。头部是一个JSON对象,经过Base64编码后作为JWT的第一部分。
-
载荷(Payload):载荷包含了JWT的主要内容,也就是一些声明(claims)。有三种类型的声明:注册声明(registered claims)、公共声明(public claims)和私有声明(private claims)。载荷也是一个JSON对象,经过Base64编码后作为JWT的第二部分。
-
签名(Signature):签名是使用头部中指定的算法对头部和载荷进行加密生成的,用于验证JWT的真实性和完整性。签名的生成需要用到编码后的头部、编码后的载荷和一个密钥。签名是JWT的第三部分,用于验证JWT是否被篡改过。
它们依次经过Base64编码并用点号连接在一起,形成了一个完整的JWT字符串。
所以,在这里你要知道一件事情,那就是我们JWT生成的字符串,是由Base64进行编码的,我们的签名也就是slat其实只是用来校验头尾,查看JWT字符串是否被修改的。也就是说,JWT并不是加密,当我们签发的时候,不要把用户账号,密码也签发进去了,你可以签发userid作为一个区分,但是不能是账号密码,否则的话,通过解码。是可以看到我们的账号密码的,这一点很重要。
RABC
之后,我们知道了JWT,登录流程,同时我们明白,我们在验证token,生成token的时候,我们可以把角色信息搞进去,然后的话验证token的时候,就可以明白到我们的角色。那么我们的角色和权限之间的 关系要如何表示,存储的,比如当前用户,对应哪个角色,这个角色对应哪些权限(可以做上面)那么这个的话,我们就可以使用到RABC来进行管理表示。
RABC(Role-Based Access Control,基于角色的访问控制)权限模型是一种广泛应用于计算机安全领域的访问控制策略。它的核心思想是根据用户的角色来分配和管理权限,而不是直接将权限分配给单个用户。这种模型的作用主要体现在以下几个方面:
简化权限管理:在RABC模型中,权限是与角色相关联的,而不是与用户直接相关联。这意味着管理员只需要管理角色和用户之间的关系,而不是单独为每个用户设置权限,大大简化了权限的分配和维护工作。
灵活性和可扩展性:随着组织的发展,可能会有新的职位或角色出现,RABC模型可以很容易地通过添加新角色并为这些角色分配适当的权限来适应这种变化,而不需要重新配置每个用户的权限。
最小权限原则:RABC模型支持最小权限原则,即用户只能获得完成其工作所必需的最小权限集。这有助于减少安全风险,因为用户不会获得超出其工作职责范围的权限。
分离职责:通过为不同的角色分配不同的权限,RABC模型有助于实现职责分离,防止单一用户拥有过多的权限,从而降低滥用权限的风险。
易于审计和合规:由于权限是基于角色分配的,因此审计和合规检查变得更加容易。管理员可以快速查看特定角色的权限设置,确保符合相关的法律法规和内部政策。
快速适应组织变化:当员工更换职位或离职时,管理员可以通过简单地更改其角色或从角色中移除用户来更新权限,而无需逐个调整权限设置。
具体实现
那么到这里,我们已经明白了,我们基本的操作,那么时候开始到我们的具体实现,那么在这里的话我们还要看到我们做经常见到的两个东西,过滤器和拦截器,我们这里也简单说说其中的区别。
首先在Spring框架中,拦截器(Interceptor)和过滤器(Filter)都是用于处理HTTP请求和响应的组件,只是它们在概念、实现和执行时机上存在一些关键的区别。
-
概念和实现层面的区别:
- 拦截器(Interceptor) :Spring的拦截器是一个AOP(面向切面编程)的概念,它工作在Spring MVC的前置通知(Before)中。拦截器可以访问到Spring的上下文(ApplicationContext),因此可以访问Spring容器中的bean,执行更复杂的操作,如安全性检查、事务管理等。拦截器实现的是
HandlerInterceptor
接口。 - 过滤器(Filter) :过滤器是Java Servlet规范的一部分,它工作在Web应用的请求和响应的整个生命周期中。过滤器不具备访问Spring上下文的能力,因此它们通常用于执行那些不需要Spring容器支持的操作,如编码转换、请求日志记录等。过滤器实现的是
Filter
接口。
- 拦截器(Interceptor) :Spring的拦截器是一个AOP(面向切面编程)的概念,它工作在Spring MVC的前置通知(Before)中。拦截器可以访问到Spring的上下文(ApplicationContext),因此可以访问Spring容器中的bean,执行更复杂的操作,如安全性检查、事务管理等。拦截器实现的是
-
执行时机和顺序的区别:
- 拦截器:拦截器主要在请求的执行过程中发挥作用,它们可以在控制器(Controller)的执行前后或者在视图(View)的渲染前后进行拦截。拦截器的执行顺序取决于它们的排序或者在配置中的声明顺序。
- 过滤器:过滤器主要在请求进入Web应用和响应离开Web应用时执行。它们通常用于处理请求前的安全检查、日志记录或者响应的压缩等。过滤器的执行顺序通常是由它们在web.xml中的配置顺序决定的。
-
对Spring MVC的支持:
- 拦截器:由于拦截器是Spring MVC的一部分,它们可以访问Spring MVC的模型和视图,例如,它们可以修改控制器方法的参数、模型属性、处理响应等。
- 过滤器:过滤器工作在请求的较早阶段,此时Spring MVC的模型和视图还未形成,因此过滤器无法直接访问或修改Spring MVC的模型和视图。
-
配置方式的区别:
- 拦截器:拦截器需要注册到Spring MVC的拦截器链中,这通常在Spring的配置文件中完成,或者使用Java配置类。
- 过滤器 :过滤器的配置通常在web.xml文件中进行,或者在Spring的配置文件中通过
FilterRegistrationBean
进行注册。
所以总结一下就是, --->Servlet---->过滤器----->Dispatch--->Spring(--->MVC)---->拦截器---->controller
所以,其实你会很容易发现,只要你能够拿到Bean,然后通过AOP机制,或者你自己手写IOC,通过动态代理实现AOP之后就可以玩出花来。
那么在这里,我们毫无疑问,选择的是AOP,毕竟标题都这样写了。
Token签发
回顾我们刚刚的流程,第一个重要的点,毫无疑问就是我们Token的签发,我们Token要签发什么东西,才能快速方便后续完成到我们的权限验证。
那么在这里我们签发的是这些东西,参考代码如下:
java
@Override
@Transactional(rollbackFor=Exception.class)
public R<SysLoginRes> login(SysLoginReq sysLoginReq, HttpServletRequest request) {
SysLoginRes sysLoginRes = new SysLoginRes();
boolean captcha = sysCaptchaService.validate(sysLoginReq.getUuid(), sysLoginReq.getCaptcha());
if(!captcha){
return R.error("验证码错误",sysLoginRes);
}
//查询用户
WkUserEntity wkUserEntity = wkUserService.queryByUserName(sysLoginReq.getUsername());
if(wkUserEntity==null){
throw new NoSuchUserInfoException();
}
boolean matchesPassword = SecurityUtils.matchesPassword(sysLoginReq.getPassword(), wkUserEntity.getPassword());
if(!matchesPassword){
throw new NoSuchUserInfoException();
}
//查看用户状态
if(wkUserEntity.getStatus()== UserSateEnum.BAD.getState()){
return R.error("当前用于已经禁用",sysLoginRes);
}
//查询到用户权限
List<String> perms = wkUserService.queryAllPerms(wkUserEntity.getUserid());
//组装为tokenPlus
PlusToken plusToken = new PlusToken();
plusToken.setOrgId(wkOrganizationUserService.queryUseridOrgId(wkUserEntity.getUserid()));
plusToken.setName(wkUserEntity.getName());
plusToken.setPerms(perms);
//签发tokenString
String token = JwtTokenUtil.generateToken(plusToken);
//存储封装token
Token tokenSave = new Token();
tokenSave.setToken(token);
tokenSave.setIp(IPAddrUtils.GetIPAddr());
tokenSave.setLoginType(sysLoginReq.getType());
//当前只有PC端,所以这里就先这样
String tokenKey = wkUserEntity.getUserid()+":"+LoginType.PcType;
redisUtils.set(RedisTransKey.setServerToken(tokenKey),
tokenSave,3, TimeUnit.DAYS);
//组装一下返回的信息
sysLoginRes.setUserid(String.valueOf(wkUserEntity.getUserid()));
sysLoginRes.setToken(token);
sysLoginRes.setType(sysLoginReq.getType());
sysLoginRes.setPermissions(perms);
return R.success(sysLoginRes);
}
我在这里签发了一个叫做PlusToken的东西,那么这个东西里面是什么呢?
java
@Data
@AllArgsConstructor
@NoArgsConstructor
public class PlusToken implements Serializable {
private Long orgId;
private String name;
private List<String> perms;
}
我在这里只是签发了这些东西。用户昵称,组织ID,权限。我将其序列化字符串,然后封装到JWT当中。
这里面并没有存放铭感信息。并且由于JWT的特性:
- 可以检测是否被篡改---》不怕你改token
- 可以校验是否过期 当然弊端嘛,就是没有做到实时权限更新。当然这个也非常好解决。并且注意到一个细节,我们还会将token存到redis当中,验证的时候,要求前端携带userid,我们的key就是userid,所以根本不怕你篡改,当你篡改时,毫无疑问解析为空,如下:
这个是我们拿到plustoken的代码 完整工具类如下"
java
public class JwtTokenUtil {
private static final String secret;
private static final Long expiration;
private static Map<String, Object> header;
static {
secret="weekly-Huterox";
expiration = 3*24*60*60*1000L;
header=new HashMap<>();
header.put("typ", "jwt");
}
/**
* 生成token令牌
* @return 令token牌
*/
public static String generateToken(User user) {
Map<String, Object> claims = new HashMap<>();
claims.put("username", user.getUsername());
claims.put("userid",user.getUserid());
claims.put("created", new Date());
return generateToken(claims);
}
/**
* 生成token令牌,Plus版本
* @return 令token牌
*/
public static String generateToken(PlusToken plusToken) {
Map<String, Object> claims = new HashMap<>();
String jsonString = JSON.toJSONString(plusToken);
claims.put("plus",jsonString);
return generateToken(claims);
}
/**
* 解析Plus令牌
* @return 令token牌
*/
public static PlusToken parsePlusToken(String token) {
PlusToken plusToken=null;
try {
Claims claims = getClaimsFromToken(token);
String jsonString = (String) claims.get("plus");
plusToken = JSON.parseObject(jsonString, PlusToken.class);
} catch (Exception e) {
plusToken = new PlusToken();
}
return plusToken;
}
/**
* @param token 令牌
* @return 用户名
*/
public static String GetUserNameFromToken(String token) {
String username;
try {
Claims claims = getClaimsFromToken(token);
username = (String) claims.get("username");
} catch (Exception e) {
username = null;
}
return username;
}
public static String GetUserIDFromToken(String token) {
String username;
try {
Claims claims = getClaimsFromToken(token);
username = (String) claims.get("userid");
} catch (Exception e) {
username = null;
}
return username;
}
/**
* 判断令牌是否过期
* @param token 令牌
* @return 是否过期
*/
public static Boolean isTokenExpired(String token) {
try {
Claims claims = getClaimsFromToken(token);
Date expiration = claims.getExpiration();
return expiration.before(new Date());
} catch (Exception e) {
return false;
}
}
/**
* 刷新令牌
*
* @param token 原令牌
* @return 新令牌
*/
public String refreshToken(String token) {
String refreshedToken;
try {
Claims claims = getClaimsFromToken(token);
claims.put("created", new Date());
refreshedToken = generateToken(claims);
} catch (Exception e) {
refreshedToken = null;
}
return refreshedToken;
}
/**
* 验证令牌
* @return 是否有效
*/
public static Boolean validateToken(String token, User user) {
String username = GetUserNameFromToken(token);
return (username.equals(user.getUsername()) && !isTokenExpired(token));
}
/**
* 从claims生成令牌,如果看不懂就看谁调用它
*
* @param claims 数据声明
* @return 令牌
*/
private static String generateToken(Map<String, Object> claims) {
Date expirationDate = new Date(System.currentTimeMillis() + expiration);
return Jwts.builder()
.setHeader(header)
.setClaims(claims)
.setExpiration(expirationDate)
.signWith(SignatureAlgorithm.HS512, secret)
.compact();
}
/**
* 从令牌中获取数据声明,如果看不懂就看谁调用它
* @param token 令牌
* @return 数据声明
*/
public static Claims getClaimsFromToken(String token) {
Claims claims;
try {
claims = Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody();
} catch (Exception e) {
claims = null;
}
return claims;
}
}
权限验证
之后就是验证了,这个首先我们自定义注解。
java
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface WeeklyHasPermission {
String[] value() default {};
}
之后因为我们都存储了,这些东西权限,所以我们直接遍历查询就可以了。
java
/**
* @version 1.0
* @author:Huterox
* @date 2024/1/3 11:02
*
* 这边完成我们的权限验证工作
*/
@Component
@Aspect
@Slf4j
@Order(2)
public class WeeklyPermissionAspect {
private RedisUtils redisUtils;
public WeeklyPermissionAspect(RedisUtils redisUtils) {
this.redisUtils = redisUtils;
}
@Autowired
public void setRedisUtils(RedisUtils redisUtils) {
this.redisUtils = redisUtils;
}
@Pointcut("@annotation(com.weekly.anno.WeeklyHasPermission)")
public void verification() {}
@Around("verification()")
public Object verification(ProceedingJoinPoint joinPoint) throws Throwable{
MethodSignature methodSignature = (MethodSignature)joinPoint.getSignature();
Method method = null;
method = methodSignature.getMethod();
WeeklyHasPermission annotation = method.getDeclaredAnnotation(WeeklyHasPermission.class);
String[] value = annotation.value();
//*************同时完成登录和权限验证************************
RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
ServletRequestAttributes servletRequestAttributes = (ServletRequestAttributes) requestAttributes;
assert servletRequestAttributes != null;
HttpServletRequest request = servletRequestAttributes.getRequest();
String ipAddr = IPAddrUtils.GetIPAddr();
//分登录的设备进行验证
String loginType = request.getHeader("Type");
String userid = request.getHeader("Userid");
String tokenUser = request.getHeader("Token");
String tokenKey = RedisTransKey.getServerToken(userid + ":" + loginType);
if(tokenUser==null || userid==null || loginType==null){
throw new BadLoginParamsException();
}
if(redisUtils.hasKey(tokenKey)){
if(loginType.equals(LoginType.PcType)){
//从redis当中重新拿到数据
Object o = redisUtils.get(tokenKey);
Token loginToken = JSON.parseObject(o.toString(), Token.class);
//这里不需要再验证IP地址了,再登录就直接覆盖就好了,然后让同一类型的另一个账号退出
if(!(loginToken.getToken().equals(tokenUser))){
throw new BadLoginQException();
}
}else if (loginType.equals(LoginType.MobileType)){
Object o = redisUtils.get(tokenKey);
Token loginToken = JSON.parseObject(o.toString(), Token.class);
if(!(loginToken.getToken().equals(tokenUser))){
throw new BadLoginQException();
}
}
}else {
throw new NotLoginException();
}
//************************完成权限验证**********************************
//3.1 这里直接完成验证
if (value.length==0){
return joinPoint.proceed();
}
//3.2 非常直接的一一种权限验证机制
HasPermission userPermissionByUserid = new WeeklySamplePermissionByPlusToken();
boolean b = false;
for (String va : value) {
if(userPermissionByUserid.hasPermission(va)){
b = true;
break;
}
}
//4. 根据权限验证的结果来完成一个基本的评判
if(!b){
throw new BadPermissionException("权限不足");
}
return joinPoint.proceed();
}
}
怎么样去去一两百行代码就完成了权限验证这个功能。
优化
当然这个只是最基本的,实际上我们还有一些优化是可以进行的,比如我们签发的token是不是确实太长了。那么我们是不是可以这样:
- 随机签发token,将我们的用户信息,账号信息等等,存储到redis里面,刚刚签发的token是作为key来查询到我们的用户信息,这样的话,更加铭感的信息我们也可以存储进去。
- 实时更新权限,在点1的情况下,我在修改了用户角色的时候,是否可以更新到我们的刚刚存储的信息。当然这里要注意的是,在修改某个用户的权限的时候,我们是不知道这个随机签发的token的,所以我们在我们内部还需要存一个东西,也即是这样: token ---> 用户信息 (包含权限,账号等等必要信息);userid+约定后缀 ---> token 这样的话,当我们修改了某个用户的权限的时候,是不是,我们可以就去更新到我们的redis,然后的话,我们就可以做到过动态更新用户权限。当然这块对redis的负载还是比较大的,因为当redis的内存不够的时候,会触发回收机制,可能存储进去的信息会被回收导致用户验证权限的时候出现问题。所以在数据库当中也要做好临时备份,当然这个问题我们都可以先不考虑,看项目的实际情况。只是一个简单的系统,其实基本都够用了。
总结
ok,以上就是全部内容,下个月再会~