1. 框架结构
拦截器
aop切面
注解
登录逻辑工具
异常处理handler
令牌服务
配置
拦截器
HeaderInterceptor:自定义头拦截器,将header数据封装到线程变量中方便获得。同时会验证当前用户有效期自动刷新有效期
public class HeaderInterceptor implements AsyncHandlerInterceptor
{
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception
{
if (!(handler instanceof HandlerMethod))
{
return true;
}
//对应网关中的AuthFilter中将jwt令牌中的claims信息存入request header的操作,这里把用户信息从header中取出来,并且存入该线程的SecurityContextHolder
//SecurityContextHolder内部的核心储存是一个TransmittableThreadLocal(ThreadLocal的特殊形式)
SecurityContextHolder.setUserId(ServletUtils.getHeader(request, SecurityConstants.DETAILS_USER_ID));
SecurityContextHolder.setUserName(ServletUtils.getHeader(request, SecurityConstants.DETAILS_USERNAME));
SecurityContextHolder.setUserKey(ServletUtils.getHeader(request, SecurityConstants.USER_KEY));
//这一步的底层操作,仍然是利用ServletUtils从requestHeader中获取token
String token = SecurityUtils.getToken();
//开始进入权限系统的操作流程,首先判断令牌是否为空
if (StringUtils.isNotEmpty(token))
{
//利用token,解析出当前的登录用户是谁
LoginUser loginUser = AuthUtil.getLoginUser(token);
if (StringUtils.isNotNull(loginUser))
{
//这一步是要判断该用户的登陆状态是否已经失效了
AuthUtil.verifyLoginUserExpire(loginUser);
//最后把登录用户信息,存入本线程的上下文环境
SecurityContextHolder.set(SecurityConstants.LOGIN_USER, loginUser);
}
}
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex)
throws Exception
{
SecurityContextHolder.remove();
}
}
public class SecurityContextHolder
{
private static final TransmittableThreadLocal<Map<String, Object>> THREAD_LOCAL = new TransmittableThreadLocal<>();
public static void set(String key, Object value)
{
Map<String, Object> map = getLocalMap();
map.put(key, value == null ? StringUtils.EMPTY : value);
}
public static String get(String key)
{
Map<String, Object> map = getLocalMap();
return Convert.toStr(map.getOrDefault(key, StringUtils.EMPTY));
}
public static <T> T get(String key, Class<T> clazz)
{
Map<String, Object> map = getLocalMap();
return StringUtils.cast(map.getOrDefault(key, null));
}
public static Map<String, Object> getLocalMap()
{
Map<String, Object> map = THREAD_LOCAL.get();
if (map == null)
{
map = new ConcurrentHashMap<String, Object>();
THREAD_LOCAL.set(map);
}
return map;
}
public static void setLocalMap(Map<String, Object> threadLocalMap)
{
THREAD_LOCAL.set(threadLocalMap);
}
public static Long getUserId()
{
return Convert.toLong(get(SecurityConstants.DETAILS_USER_ID), 0L);
}
public static void setUserId(String account)
{
set(SecurityConstants.DETAILS_USER_ID, account);
}
public static String getUserName()
{
return get(SecurityConstants.DETAILS_USERNAME);
}
}
SecurityContextHolder中有一个静态的线程变量,其类型为TransmittableThreadLocal,这是阿里巴巴开源的一个类型,主要作用是处理父子线程变量不能共用的情况。ThreadLocal是跟当前线程挂钩的,所以脱离当前线程它就起不了作用。
ThreadLocal和TransmittableThreadLocal的使用场景区别
ThreadLocal场景:它的应用就是比如当前用户特有的一些属性,不能跟其他线程,用户共用。
TransmittableThreadLocal场景:就是父子线程或者不同线程需要共用一些变量。
这个拦截器的功能并不是很复杂,主要是以下几点
对应网关中的AuthFilter中将jwt令牌中的claims信息存入request header的操作,这里把用户信息从header中取出来,并且存入该线程的SecurityContextHolder
获取requestHeader中的token
判断令牌是否为空,解析出当前的登录用户是谁,并判断用户的token以及登录状态是否已经过期
最后把用户登录信息也存入SecurityContextHolder
以上是preHandle方法中的流程
在afterCompletion方法中,就一件事情,清除SecurityContextHolder,这一点也很好理解,当当前用户结束操作的时候,清楚本线程中的信息,给下一个用户执行腾空间。
切面
系统中有两个aspect,分别是:InnerAuthAspect:内部服务调用验证处理
@Aspect
@Component
public class InnerAuthAspect implements Ordered
{
@Around("@annotation(innerAuth)")
public Object innerAround(ProceedingJoinPoint point, InnerAuth innerAuth) throws Throwable
{
//这一步是查请求来源
String source = ServletUtils.getRequest().getHeader(SecurityConstants.FROM_SOURCE);
// 内部请求验证
// 如果不是来源于内部的请求,则抛出异常
if (!StringUtils.equals(SecurityConstants.INNER, source))
{
throw new InnerAuthException("没有内部访问权限,不允许访问");
}
//这里获取用户信息
String userid = ServletUtils.getRequest().getHeader(SecurityConstants.DETAILS_USER_ID);
String username = ServletUtils.getRequest().getHeader(SecurityConstants.DETAILS_USERNAME);
// 用户信息验证
// 未设置用户信息的,以及其他异常数据,同样抛出异常
if (innerAuth.isUser() && (StringUtils.isEmpty(userid) || StringUtils.isEmpty(username)))
{
throw new InnerAuthException("没有设置用户信息,不允许访问 ");
}
//在切入点执行
return point.proceed();
}
/**
* 确保在权限认证aop执行前执行
*/
@Override
public int getOrder()
{
return Ordered.HIGHEST_PRECEDENCE + 1;
}
}
这个切面实现了Ordered接口,其作用是确保在权限认证AOP执行前执行
主逻辑方法使用@Around注解修饰,作用目标是内部注解@innerAuth
该切面的主要功能是,
验证请求是否是内部请求,
验证用户信息是否设置
这个切面很明确地说明了,对于用户请求,必须携带用户名和id,才会被视作是合法请求。
PreAuthorizeAspect:基于spring AOP的注解鉴权
@Aspect
@Component
public class PreAuthorizeAspect
{
/**
* 构建
*/
public PreAuthorizeAspect()
{
}
/**
* 定义AOP签名 (切入所有使用鉴权注解的方法)
*/
public static final String POINTCUT_SIGN = " @annotation(com.ruoyi.common.security.annotation.RequiresLogin) || "
+ "@annotation(com.ruoyi.common.security.annotation.RequiresPermissions) || "
+ "@annotation(com.ruoyi.common.security.annotation.RequiresRoles)";
/**
* 声明AOP签名
*/
@Pointcut(POINTCUT_SIGN)
public void pointcut()
{
}
/**
* 环绕切入
*
* @param joinPoint 切面对象
* @return 底层方法执行后的返回值
* @throws Throwable 底层方法抛出的异常
*/
@Around("pointcut()")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable
{
// 注解鉴权
//这一步是获取方法签名
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
//这一步的作用是方法增强,对一个Method对象进行注解检查,即判断被目标注解修饰的方法,当前请求是否有足够权限访问
checkMethodAnnotation(signature.getMethod());
try
{
// 执行原有逻辑
Object obj = joinPoint.proceed();
return obj;
}
catch (Throwable e)
{
throw e;
}
}
/**
* 对一个Method对象进行注解检查
*/
public void checkMethodAnnotation(Method method)
{
// 校验 @RequiresLogin 注解
RequiresLogin requiresLogin = method.getAnnotation(RequiresLogin.class);
if (requiresLogin != null)
{
AuthUtil.checkLogin();
}
// 校验 @RequiresRoles 注解
RequiresRoles requiresRoles = method.getAnnotation(RequiresRoles.class);
if (requiresRoles != null)
{
AuthUtil.checkRole(requiresRoles);
}
// 校验 @RequiresPermissions 注解
RequiresPermissions requiresPermissions = method.getAnnotation(RequiresPermissions.class);
if (requiresPermissions != null)
{
AuthUtil.checkPermi(requiresPermissions);
}
}
}
此切面的作用是提供注解鉴权
切面中声明了AOP签名,并且使用@Pointcut注解限定了该切面的切入点。这里使用了环绕切入。
在around方法中提供注解鉴权:
获取方法签名
方法增强,对一个Method对象进行注解检查,即判断被目标注解修饰的方法,当前请求是否有足够权限访问。checkMethodAnnotation方法中会对三大注解进行校验,只要有一个不符合条件,就会抛出NotPermissionException异常。
执行原有逻辑
public class NotPermissionException extends RuntimeException
{
private static final long serialVersionUID = 1L;
public NotPermissionException(String permission)
{
super(permission);
}
public NotPermissionException(String[] permissions)
{
super(StringUtils.join(permissions, ","));
}
}
注解
总的来看,ruoyi权限系统中的注解分为权限注解和功能注解,权限注解+aop功能增强,实现了注解鉴权,功能注解则是决定此方法是否能够被远程调用等。
注解详解:
@RequiresLogin:登录认证:只有登录之后才能进入该方法
权限注解:
@Retention(RetentionPolicy.RUNTIME)
@Target({ ElementType.METHOD, ElementType.TYPE })
public @interface RequiresLogin
{
}
@RequiresPermissions: 权限认证:必须具有指定权限才能进入该方法
@Retention(RetentionPolicy.RUNTIME)
@Target({ ElementType.METHOD, ElementType.TYPE })
public @interface RequiresPermissions
{
/**
* 需要校验的权限码
*/
String[] value() default {};
/**
* 验证模式:AND | OR,默认AND
*/
Logical logical() default Logical.AND;
}
此注解有两个参数:权限码和验证逻辑
权限码是一个数组,用于记录拥有哪些权限才能访问本接口
验证逻辑是说明,以上的访问权限之间是或还是与的关系。
@RequiresRoles:角色认证:必须具有指定角色标识才能进入该方法
@Retention(RetentionPolicy.RUNTIME)
@Target({ ElementType.METHOD, ElementType.TYPE })
public @interface RequiresRoles
{
/**
* 需要校验的角色标识
*/
String[] value() default {};
/**
* 验证逻辑:AND | OR,默认AND
*/
Logical logical() default Logical.AND;
}
此注解有两个参数:角色标识和验证逻辑
角色标识也是一个数组,记录访问该接口需要的角色
验证逻辑:说明是或还是与的关系
功能注解
@EnableCustomConfig
这个注解是一个合并注解,具体作用是什么并不是很明确。但至少可以简化代码
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
// 表示通过aop框架暴露该代理对象,AopContext能够访问
@EnableAspectJAutoProxy(exposeProxy = true)
// 指定要扫描的Mapper类的包的路径
@MapperScan("com.ruoyi.**.mapper")
// 开启线程异步执行
@EnableAsync
// 自动加载类
@Import({ ApplicationConfig.class, FeignAutoConfiguration.class })
public @interface EnableCustomConfig
{
}
@EnableRyFeignClients
这个注解是自定义feign注解,用于远程调用,添加basePackages的路径
这个注解其实是对@EnableFeignClients注解的增强,给basePackages设置了默认值"com.ruoyi"
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@EnableFeignClients
public @interface EnableRyFeignClients
{
String[] value() default {};
String[] basePackages() default { "com.ruoyi" };
Class<?>[] basePackageClasses() default {};
Class<?>[] defaultConfiguration() default {};
Class<?>[] clients() default {};
}
补充
@EnableFeignClients 在spring cloud项目中,通过@EnableFeignClients启用feign客户端 该注解会扫描包路径下的@FeignClient注解定义的接口,并注册到IOC容器中 声明在调用服务的微服务上
@FeignClient 在做springcloud分布式开发过程中会有需要访问其他服务的情况,每一个服务之间都是以接口的方式访问的,那么就需要使用到 @FeignClient 来访问其他服务的接口。@FeignClient 实现的是声明式的、模块化的Http客户端,可以让我们对其他服务接口的访问更边界就像是controller和service之间的调用一样。@FeignClient用于指定需要调用的目标服务 四大核心参数 value:服务提供者名称 contextId:定义创建出来的bean的名称 url:指定服务的地址 fallbackFactory:服务远程调用失败时候的回调程序
@InnerAuth
内部认证注解,有一个参数,内容是是否校验用户信息。默认情况下,标记了此注解的接口是内部调用的,且默认不需要携带用户信息。
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface InnerAuth
{
/**
* 是否校验用户信息
*/
boolean isUser() default false;
}
登录逻辑工具
总的来看,登录逻辑工具包是权限系统的核心逻辑,权限验证的逻辑都是写在这里的
工具详解:
AuthLogic:权限验证,逻辑实现类
里面提供了非常多的验证方法,这里不逐个介绍了,其基本的逻辑都是进行验证,如果校验不通过,则抛出异常。
此类中有几个核心的方法:
hasPermi:判断是否包含权限
遍历当前用户的权限列表,和该接口/方法的权限码进行一个匹配,即可得知用户是否具有权限了。
public boolean hasPermi(String permission)
{
return hasPermi(getPermiList(), permission);
}
public boolean hasPermi(Collection<String> authorities, String permission)
{
return authorities.stream().filter(StringUtils::hasText)
.anyMatch(x -> ALL_PERMISSION.contains(x) || PatternMatchUtils.simpleMatch(x, permission));
}
hasRole:判断是否包含角色
遍历当前用户的角色列表,和该接口/方法的角色进行一个匹配,即可得知用户是否具有权限了。
public boolean hasRole(String role)
{
return hasRole(getRoleList(), role);
}
public boolean hasRole(Collection<String> roles, String role)
{
return roles.stream().filter(StringUtils::hasText)
.anyMatch(x -> SUPER_ADMIN.contains(x) || PatternMatchUtils.simpleMatch(x, role));
}
getRoleList:获取当前账号的角色列表
在loginUser对象中,是有roles(角色列表)和permissions(权限列表)
public Set<String> getRoleList()
{
try
{
LoginUser loginUser = getLoginUser();
return loginUser.getRoles();
}
catch (Exception e)
{
return new HashSet<>();
}
}
getPermiList:获取当前账号的权限列表
在loginUser对象中,是有roles(角色列表)和permissions(权限列表)
public Set<String> getPermiList()
{
try
{
LoginUser loginUser = getLoginUser();
return loginUser.getPermissions();
}
catch (Exception e)
{
return new HashSet<>();
}
}
getLoginUser:以上两个方法都依赖getLoginUser方法,其作用获取当前用户缓存信息, 如果未登录,则抛出异常
有两个版本,区别在于是否提供一个token作为参数
未提供参数的版本,是从SecurityUtils中获取当前用户发起的请求中的token
public LoginUser getLoginUser()
{
String token = SecurityUtils.getToken();
if (token == null)
{
throw new NotLoginException("未提供token");
}
LoginUser loginUser = SecurityUtils.getLoginUser();
if (loginUser == null)
{
throw new NotLoginException("无效的token");
}
return loginUser;
}
提供参数的版本,直接解析jwt令牌,并从redis中查询出相关的信息
public LoginUser getLoginUser(String token)
{
return tokenService.getLoginUser(token);
}
以上就是ruoyi-security中最核心的auth校验逻辑,其实非常简单,核心的思路就是在接口方法上声明所需的权限或角色,然后从用户信息中获取该用户具有的权限和角色,进行匹配即可。就这么简单。
AuthUtil:Token 权限验证工具类
工具类可以看作是对底层AuthLogic的进一步封装,外界是不会直接调用AuthLogic的,都是通过AuthUtil来实现的。
全局异常处理器
这是很常规的一个全局异常处理器,声明@RestControllerAdvice注解,说明这是一个全局异常处理器。
@RestControllerAdvice注解是Spring MVC和Spring Boot应用程序中用于定义全局异常处理类的注解,它是@ControllerAdvice注解的特殊版本,用于RESTful风格的应用程序。@RestControllerAdvice可以捕获整个应用程序中抛出的异常,并对它们进行处理。这样可以实现在整个应用程序范围内统一处理异常的目标
展示两个基础的权限异常处理。
/**
* 权限码异常
*/
@ExceptionHandler(NotPermissionException.class)
public AjaxResult handleNotPermissionException(NotPermissionException e, HttpServletRequest request)
{
String requestURI = request.getRequestURI();
log.error("请求地址'{}',权限码校验失败'{}'", requestURI, e.getMessage());
return AjaxResult.error(HttpStatus.FORBIDDEN, "没有访问权限,请联系管理员授权");
}
/**
* 角色权限异常
*/
@ExceptionHandler(NotRoleException.class)
public AjaxResult handleNotRoleException(NotRoleException e, HttpServletRequest request)
{
String requestURI = request.getRequestURI();
log.error("请求地址'{}',角色权限校验失败'{}'", requestURI, e.getMessage());
return AjaxResult.error(HttpStatus.FORBIDDEN, "没有访问权限,请联系管理员授权");
}
补充:
@ExceptionHandler
用来声明异常处理器。声明了此注解的方法,作为异常处理器,处理捕获的异常。
令牌服务
TokenService:token验证处理。ruoyi-auth中的tokenService正是来源于此。
这个类的主要功能主要是以下几个:
创建令牌
public Map<String, Object> createToken(LoginUser loginUser)
{
String token = IdUtils.fastUUID();
Long userId = loginUser.getSysUser().getUserId();
String userName = loginUser.getSysUser().getUserName();
loginUser.setToken(token);
loginUser.setUserid(userId);
loginUser.setUsername(userName);
loginUser.setIpaddr(IpUtils.getIpAddr(ServletUtils.getRequest()));
refreshToken(loginUser);
// Jwt存储信息
Map<String, Object> claimsMap = new HashMap<String, Object>();
claimsMap.put(SecurityConstants.USER_KEY, token);
claimsMap.put(SecurityConstants.DETAILS_USER_ID, userId);
claimsMap.put(SecurityConstants.DETAILS_USERNAME, userName);
// 接口返回信息
Map<String, Object> rspMap = new HashMap<String, Object>();
rspMap.put("access_token", JwtUtils.createToken(claimsMap));
rspMap.put("expires_in", expireTime);
rspMap.put("user_id",userId);
rspMap.put("user_name",userName);
return rspMap;
}
public void refreshToken(LoginUser loginUser)
{
loginUser.setLoginTime(System.currentTimeMillis());
loginUser.setExpireTime(loginUser.getLoginTime() + expireTime * MILLIS_MINUTE);
// 根据uuid将loginUser缓存
String userKey = getTokenKey(loginUser.getToken());
redisService.setCacheObject(userKey, loginUser, expireTime, TimeUnit.MINUTES);
}
private String getTokenKey(String token)
{
return ACCESS_TOKEN + token;
}
这里refreshToken的作用是刷新令牌有效期,默认是从当前时间开始计算,设置有效期为120分钟,并且保存到redis中
创建令牌的流程还是很清晰的
首先准备好生成token所需的参数
在redis中刷新用户登录状态
将生成jwt的数据转化为map
生成jwt并封装返回
获取用户身份信息
有三个重载方法,分别是无参数,参数为HttpServletRequest对象,参数为token。其中参数为token是最底层的方法,另外两个都是基于参数为token的getLoginUser实现的。
public LoginUser getLoginUser()
{
return getLoginUser(ServletUtils.getRequest());
}
public LoginUser getLoginUser(HttpServletRequest request)
{
// 获取请求携带的令牌
String token = SecurityUtils.getToken(request);
return getLoginUser(token);
}
public LoginUser getLoginUser(String token)
{
LoginUser user = null;
try
{
if (StringUtils.isNotEmpty(token))
{
String userkey = JwtUtils.getUserKey(token);
user = redisService.getCacheObject(getTokenKey(userkey));
return user;
}
}
catch (Exception e)
{
}
return user;
}
// 验证令牌有效期,相差不足120分钟,自动刷新缓存
public void verifyToken(LoginUser loginUser)
{
long expireTime = loginUser.getExpireTime();
long currentTime = System.currentTimeMillis();
if (expireTime - currentTime <= MILLIS_MINUTE_TEN)
{
refreshToken(loginUser);
}
}
配置
ruoyi-cloud中的配置还是非常简单的,就两个配置类
WebMvcConfig:拦截器配置
public class WebMvcConfig implements WebMvcConfigurer
{
/** 不需要拦截地址 */
public static final String[] excludeUrls = { "/login", "/logout", "/refresh" };
@Override
public void addInterceptors(InterceptorRegistry registry)
{
registry.addInterceptor(getHeaderInterceptor())
.addPathPatterns("/**")
.excludePathPatterns(excludeUrls)
.order(-10);
}
/**
* 自定义请求头拦截器
*/
public HeaderInterceptor getHeaderInterceptor()
{
return new HeaderInterceptor();
}
}
主要是配置了放行的地址,以及需要拦截的url路径。同时配置了上文的提到的HeaderInterceptor。
ApplicationConfig:系统配置
public class ApplicationConfig
{
/**
* 时区配置
*/
@Bean
public Jackson2ObjectMapperBuilderCustomizer jacksonObjectMapperCustomization()
{
return jacksonObjectMapperBuilder -> jacksonObjectMapperBuilder.timeZone(TimeZone.getDefault());
}
}
这就没啥好说的了,配置了当前时区。
总结
以上就是ruoyi-cloud中的ruoyi-common-security的源码阅读。总体来说,源码的可读性是非常好的,读起来并不难懂。这款权限框架结构非常简单,是很轻量级的微服务权限框架。没有spring security那样繁琐的配置。经过一些简单的二次开发后,非常适合在前后端分离的项目中使用,也可以很好的应用在分布式,多模块项目中。可以无缝结合spring cloud gatway,集成在spring cloud项目中