暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

ruoyi-common-security源码分析

程序员恰恰 2024-01-14
186


当前前后端分离架构逐渐成为主流,尤其是以spring-cloud为代表的微服务架构流行之后,传统的spring security已经有些跟不上时代,在微服务架构中,很多第三方的开源权限框架有着简单易用,适配性好的特点,可以在其基础上进行二次开发,具有很不错的应用价值,这里选取ruoyi-security的源码进行分析,学习微服务架构的鉴权逻辑。

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();
    }
}

功能:由于一个用户是占用一个线程的,那么这个线程中的数据其实是这个用户的数据,或者说该用户的用户数据要存放到这跟线程中,这样在程序执行过程中,用户和线程可以近似画上等号。这样的话,会极大方便操作。

其内部有一个较为核心的类SecurityContextHolder,这个类的作用是获取当前线程变量中的用户id,用户名称,token等信息。必须在网关通过请求头的方法传入,同时在HeaderInterceptor拦截器设置值。否则这里无法获取。上文中已经分析过是如何把请求头中的值存入本地线程的。
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> 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.classFeignAutoConfiguration.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 eHttpServletRequest request)
{
    String requestURI = request.getRequestURI();
    log.error("请求地址'{}',权限码校验失败'{}'", requestURI, e.getMessage());
    return AjaxResult.error(HttpStatus.FORBIDDEN, "没有访问权限,请联系管理员授权");
}
 
/**
 * 角色权限异常
 */

@ExceptionHandler(NotRoleException.class)
public AjaxResult handleNotRoleException(NotRoleException eHttpServletRequest 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项目中


文章转载自程序员恰恰,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

评论