RuoYi-Vue 最新 SpringBoot3 前后端分离版本源码分析

RuoYi-Vue 最新 SpringBoot3 前后端分离版本源码分析

RuoYi-Vue 本地环境部署

直接去 gitee 上拉取最新版本即可,分支切换到 springboo3 就可以了,本地部署也非常简单,只需要更改数据库和 Redis 配置即可

在线体验

若依官网:http://ruoyi.vip

演示地址:http://vue.ruoyi.vip

代码下载:https://gitee.com/y_project/RuoYi/tree/springboot3/

若依菜单类型

在系统管理-菜单管理,新增菜单,可以看到有三种菜单类型,

目录可以理解成一级菜单,系统管理、系统监控、系统工具这些都算是目录了。

而菜单则可以理解成二级菜单,菜单管理、用户管理都算是菜单了

按钮则对应二级菜单页面的操作了,比如在用户管理列表页,新增用户、编辑用户、删除用户等都是按钮

sys_menu 菜单表有个字段 menu_type来区分这三种菜单类型

权限管理

RuoYi-Vue 最新 SpringBoot3 前后端分离版本是使用 SpringSecurity 来进行安全认证和权限控制的,从 maven 依赖可以看到版本是 SpringSecurity6.3.0

SpringSecurity 配置

SpringSecurity 的配置类是实现安全控制的核心部分,开启 SpringSecurity 各种功能,以确保 Web 应用程序的安全性,包括认证、授权、回话管理、过滤器添加等.

java 复制代码
// 表示开启方法级别的权限控制=> @PreAuthorize
@EnableMethodSecurity(prePostEnabled = true, securedEnabled = true)
@Configuration
public class SecurityConfig {
   //身份验证实现
   @Bean
   public AuthenticationManager authenticationManager() {
       DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();
       // 这句代码很重要,后续登录的时候会进入到UserDetailsServiceImpl#loadUserByUsername方法进行认证
       daoAuthenticationProvider.setUserDetailsService(userDetailsService);
       daoAuthenticationProvider.setPasswordEncoder(bCryptPasswordEncoder());
       return new ProviderManager(daoAuthenticationProvider);
   }
  @Bean
  protected SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
   return httpSecurity
       // CSRF禁用,因为不使用session
       .csrf(csrf -> csrf.disable())
       // 禁用HTTP响应标头
       .headers((headersCustomizer) -> {
           headersCustomizer.cacheControl(cache -> cache.disable()).frameOptions(options -> options.sameOrigin());
       })
       // 认证失败处理类(认证失败的进入unauthorizedHandler类处理)
       .exceptionHandling(exception -> exception.authenticationEntryPoint(unauthorizedHandler))
       // 基于token,所以不需要session
       .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
       // 注解标记允许匿名访问的url
       .authorizeHttpRequests((requests) -> {
           permitAllUrl.getUrls().forEach(url -> requests.requestMatchers(url).permitAll());
           // 对于登录login 注册register 验证码captchaImage 允许匿名访问
           requests.requestMatchers("/login", "/register", "/captchaImage").permitAll()
               // 静态资源,可匿名访问
               .requestMatchers(HttpMethod.GET, "/", "/*.html", "/**.html", "/**.css", "/**.js", "/profile/**").permitAll()
               .requestMatchers("/swagger-ui.html", "/v3/api-docs/**", "/swagger-ui/**", "/druid/**").permitAll()
               // 除上面外的所有请求全部需要鉴权认证
               .anyRequest().authenticated();
       })
       // 添加Logout filter(退出的时候进入logoutSuccessHandler)
       .logout(logout -> logout.logoutUrl("/logout").logoutSuccessHandler(logoutSuccessHandler))
       // 添加JWT filter(每次请求都会进入UsernamePasswordAuthenticationFilter,校验token有效性、合法性)
       .addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class)
       // 添加CORS filter
       .addFilterBefore(corsFilter, JwtAuthenticationTokenFilter.class)
       .addFilterBefore(corsFilter, LogoutFilter.class)
       .build();
  }
}

看看退出账户的时候logoutSuccessHandler做啥了

java 复制代码
@Override
public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
   LoginUser loginUser = tokenService.getLoginUser(request);
   if (StringUtils.isNotNull(loginUser)) {
       String userName = loginUser.getUsername();
       // 删除用户缓存记录
       tokenService.delLoginUser(loginUser.getToken());
       // 记录用户退出日志
       AsyncManager.me().execute(AsyncFactory.recordLogininfor(userName, Constants.LOGOUT, MessageUtils.message("user.logout.success")));
   }
   ServletUtils.renderString(response, JSON.toJSONString(AjaxResult.success(MessageUtils.message("user.logout.success"))));
}

每次请求都会被 UsernamePasswordAuthenticationFilter 拦截

java 复制代码
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
   // 通过令牌服务获取登录用户信息
   LoginUser loginUser = tokenService.getLoginUser(request);
   if (StringUtils.isNotNull(loginUser) && StringUtils.isNull(SecurityUtils.getAuthentication())) {
       // 验证用户令牌是否有效
       tokenService.verifyToken(loginUser);
       // 创建认证对象
       UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());
       // 设置认证对象的详细信息,这些详细信息是基于web的认证细节
       authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
       // 将认证对象设置到安全上下文中,这样应用的其它部分可以访问到用户信息
       SecurityContextHolder.getContext().setAuthentication(authenticationToken);
   }
   // 继续执行下一个过滤器链
   chain.doFilter(request, response);
}

登录接口(认证管理)

java 复制代码
@PostMapping("/login")
 public AjaxResult login(@RequestBody LoginBody loginBody) {
   AjaxResult ajax = AjaxResult.success();
   // 生成令牌
   String token = loginService.login(loginBody.getUsername(), loginBody.getPassword(), loginBody.getCode(), loginBody.getUuid());
   ajax.put(Constants.TOKEN, token);
   return ajax;
 }

核心实现在 loginService.login 方法

java 复制代码
public String login(String username, String password, String code, String uuid) {
   // 验证码校验
   validateCaptcha(username, code, uuid);
   // 登录前置校验(前端校验了,后端再次校验长度啊,空格、IP黑名单校验之类的...)
   loginPreCheck(username, password);
   // 用户验证,具体验证的细节是由 SpringSecurity 认证管理器来处理的
   Authentication authentication = null;
   try {
       UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(username, password);
       AuthenticationContextHolder.setContext(authenticationToken);
       // 该方法会去调用UserDetailsServiceImpl.loadUserByUsername
       authentication = authenticationManager.authenticate(authenticationToken);
   } catch (Exception e) {
       ...
   } finally {
       AuthenticationContextHolder.clearContext();
   }
   // 异步生成登录日志
   AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_SUCCESS, MessageUtils.message("user.login.success")));
   LoginUser loginUser = (LoginUser) authentication.getPrincipal();
   recordLoginInfo(loginUser.getUserId());
   // 生成token
   return tokenService.createToken(loginUser);
}
public void validateCaptcha(String username, String code, String uuid) {
   // 检查是否启用了验证码功能
    boolean captchaEnabled = configService.selectCaptchaEnabled();
    if (captchaEnabled) {
        // 构建验证码缓存 key
        String verifyKey = CacheConstants.CAPTCHA_CODE_KEY + StringUtils.nvl(uuid, "");
        String captcha = redisCache.getCacheObject(verifyKey);
        // redis验证码不存在,抛出验证码已失效异常
        if (captcha == null) {
            AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("user.jcaptcha.expire")));
            throw new CaptchaExpireException();
        }
        // 删除缓存的验证码,用完就删,因为验证码是一次生效的
        redisCache.deleteObject(verifyKey);
        // 校验用户提交的验证码和缓存的验证码,不成功,抛出验证码错误
        if (!code.equalsIgnoreCase(captcha)) {
            AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("user.jcaptcha.error")));
            throw new CaptchaException();
        }
    }
}

整个 login 方法大致分为两大块:

首先是认证细节

第二个就是认证完成之后的 token 生成

Authentication 认证

具体验证的细节是由 SpringSecurity 认证管理器来处理的,来看看UserDetailsServiceImpl.loadUserByUsername逻辑

java 复制代码
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
    SysUser user = userService.selectUserByUserName(username); // 查询用户信息
    if (StringUtils.isNull(user)) {
        log.info("登录用户:{} 不存在.", username);
        throw new ServiceException(MessageUtils.message("user.not.exists"));
    } else if (UserStatus.DELETED.getCode().equals(user.getDelFlag())) {
        log.info("登录用户:{} 已被删除.", username);
        throw new ServiceException(MessageUtils.message("user.password.delete"));
    } else if (UserStatus.DISABLE.getCode().equals(user.getStatus())) {
        log.info("登录用户:{} 已被停用.", username);
        throw new ServiceException(MessageUtils.message("user.blocked"));
    }
    // 验证用户密码输入是否正确
    passwordService.validate(user);
    // 创建并返回登录用户对象
    return createLoginUser(user);
}

来看看密码校验部分逻辑

java 复制代码
public void validate(SysUser user) {
     // 获取当前的认证信息,从认证信息中提取用户名和密码
     Authentication usernamePasswordAuthenticationToken = AuthenticationContextHolder.getContext();
     String username = usernamePasswordAuthenticationToken.getName();
     String password = usernamePasswordAuthenticationToken.getCredentials().toString();
     // 尝试从缓存中获取当前用户的密码重试次数
     Integer retryCount = redisCache.getCacheObject(getCacheKey(username));
     // 如果缓存中没有,则初始化 0
     if (retryCount == null) {
         retryCount = 0;
     }
     // 如果重试次数超过了配置文件中配置的最大重试次数(${user.password.maxRetryCount}),抛异常
     if (retryCount >= Integer.valueOf(maxRetryCount).intValue()) {
         throw new UserPasswordRetryLimitExceedException(maxRetryCount, lockTime);
     }

     if (!matches(user, password)) { // 对比用户密码和数据库密码,使用到 hash 散列算法处理
         retryCount = retryCount + 1; // 密码不匹配,增加重试次数
         redisCache.setCacheObject(getCacheKey(username), retryCount, lockTime, TimeUnit.MINUTES);
         throw new UserPasswordNotMatchException();
     } else {
         clearLoginRecordCache(username);
     }
 }
 public boolean matches(SysUser user, String rawPassword) {
     return this.matchesPassword(rawPassword, user.getPassword());
 }

 public static boolean matchesPassword(String rawPassword, String encodedPassword) {
     BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
     return passwordEncoder.matches(rawPassword, encodedPassword);
 }

创建登录用户对象返回

java 复制代码
public UserDetails createLoginUser(SysUser user) {
    return new LoginUser(user.getUserId(), user.getDeptId(), user, permissionService.getMenuPermission(user));
}

这个对象是这样的

token的生成

java 复制代码
 public String createToken(LoginUser loginUser) {
     String token = IdUtils.fastUUID();
     loginUser.setToken(token);
     // 设置用户登录信息,都是一些 setXX操作
     setUserAgent(loginUser);
     // 登录后,在 redis会缓存登录用户信息,key是 login_tokens+uuid
     refreshToken(loginUser);

     Map<String, Object> claims = new HashMap<>();
     claims.put(Constants.LOGIN_USER_KEY, token);
     return createToken(claims);
 }
 private String createToken(Map<String, Object> claims) {
     String token = Jwts.builder().setClaims(claims)
         // 使用 HS512 算法和 secret 密钥对 JWT 进行签名
         .signWith(SignatureAlgorithm.HS512, secret).compact();
     return token;
 }

最终生成的 token 会返回前端,存到 cookie中,所以登录后可以在 cooke 中看到这个 token

最后,总结一下登录流程

权限控制

前端页面,不同的用户登录会显示不同的操作权限,这是因为每个用户拥有的角色权限可能不一样,怎么判断的呢,使用 v-hasPermi指令,这个指令是若依框架自定义的组件

vue 复制代码
<el-button
 size="mini"
  type="text"
  icon="el-icon-edit"
  @click="handleUpdate(scope.row)"
  v-hasPermi="['system:dept:edit']"
>修改</el-button>

那用户权限怎么获取呢?会发现,每次刷新浏览器都会发送 http://localhost/dev-api/getInfo 查询用户信息,可以看下这个接口,其实就是获取当前用户拥有的角色和权限集合

java 复制代码
 @GetMapping("getInfo")
 public AjaxResult getInfo() {
     SysUser user = SecurityUtils.getLoginUser().getUser();
     // 角色集合
     Set<String> roles = permissionService.getRolePermission(user);
     // 权限集合
     Set<String> permissions = permissionService.getMenuPermission(user);
     AjaxResult ajax = AjaxResult.success();
     ajax.put("user", user);
     ajax.put("roles", roles);
     ajax.put("permissions", permissions);
     return ajax;
 }

整个流程大致是这样的

那后端权限怎么控制呢?SpringSecurity提供的@PreAuthorize 注解是实现方法级别访问控制的核心工具,它通过在方法前进行权限校验,确保只有符合条件的用户才能访问特定的功能

java 复制代码
@PreAuthorize("@ss.hasPermi('system:user:list')")
@GetMapping("/list")
 public TableDataInfo list(SysUser user) {
     startPage();
     List<SysUser> list = userService.selectUserList(user);
     return getDataTable(list);
 }

其中@ss代表 PermissionService 类,就是调用这个类的 各种方法,原理就是 AOP,之所以会在调用接口前先执行这个方法,是因为 SecurityConfig 配置类上加了@EnableMethodSecurity(prePostEnabled = true, securedEnabled = true) 注解,准确的说是prePostEnabled = true 这个属性起了作用

java 复制代码
@Service("ss")
public class PermissionService {
/**
 * 验证用户是否具备某权限
 *
 * @param permission 权限字符串
 * @return 用户是否具备某权限
 */
public boolean hasPermi(String permission) {
    // 判空
    if (StringUtils.isEmpty(permission)) {
        return false;
    }
    // 获取当前登录用户信息
    LoginUser loginUser = SecurityUtils.getLoginUser();
    // 为空直接返回 false
    if (StringUtils.isNull(loginUser) || CollectionUtils.isEmpty(loginUser.getPermissions())) {
        return false;
    }
    // 将权限信息设置到上下文中,供后续操作使用
    PermissionContextHolder.setContext(permission);
    // 检查用户权限集合中是否包含指定权限
    Set<String> permissions = loginUser.getPermissions();
    return permissions.contains(Constants.ALL_PERMISSION) || permissions.contains(StringUtils.trim(permission));
	}
}

PermissionService 类还定义了其它方法,如下

异步任务管理

来分析一下 登录的接口

java 复制代码
    @PostMapping("/login")
    public AjaxResult login(@RequestBody LoginBody loginBody)
    {
        AjaxResult ajax = AjaxResult.success();
        // 生成令牌
        String token = loginService.login(loginBody.getUsername(), loginBody.getPassword(), loginBody.getCode(),
                loginBody.getUuid());
        ajax.put(Constants.TOKEN, token);
        return ajax;
    }

接着定位到 loginService.login 方法

java 复制代码
public String login(String username, String password, String code, String uuid)
    {
        // 验证码校验
        validateCaptcha(username, code, uuid);
        // 登录前置校验
        loginPreCheck(username, password);
        // 用户验证
        Authentication authentication = null;
        ....
        // 这行就是异步生成任务(记录登录日志),然后调用线程池执行任务
        AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_SUCCESS, MessageUtils.message("user.login.success")));
        LoginUser loginUser = (LoginUser) authentication.getPrincipal();
        recordLoginInfo(loginUser.getUserId());
        // 生成token
        return tokenService.createToken(loginUser);
    }

记录的登录日志用于在这里查询

任务的生成,TimerTask 实现了 Runnable 接口,本质是个任务,这里是插入数据到库

java 复制代码
public static TimerTask recordLogininfor(final String username, final String status, final String message,
            final Object... args)
    {
        ...
        return new TimerTask()
        {
            @Override
            public void run()
            {
                ...
                // 封装对象
                SysLogininfor logininfor = new SysLogininfor();
                ...
                // 插入数据
                SpringUtils.getBean(ISysLogininforService.class).insertLogininfor(logininfor);
            }
        };
    }

而任务是由 AsyncManager.me().execute 调用线程池执行的

java 复制代码
/**
 * 异步操作任务调度线程池
 */
private ScheduledExecutorService executor = SpringUtils.getBean("scheduledExecutorService");

public void execute(TimerTask task)
{
    executor.schedule(task, OPERATE_DELAY_TIME, TimeUnit.MILLISECONDS);
}

整个过程流程图总结如下

操作日志

在日常编程中,记录日志是我们的得力助手,尤其在处理关键业务时,它能帮助我们追踪和审查操作过程,那 RuoYi 是怎么记录操作日志的呢?

在需要被记录日志的 Controller 方法上添加 @Log 注解,使用方法如下:

java 复制代码
// 删除用户
@PreAuthorize("@ss.hasPermi('system:user:remove')")
@Log(title = "用户管理", businessType = BusinessType.DELETE)
@DeleteMapping("/{userIds}")
public AjaxResult remove(@PathVariable Long[] userIds) {
    if (ArrayUtils.contains(userIds, getUserId())) {
        return error("当前用户不能删除");
    }
    return toAjax(userService.deleteUserByIds(userIds));
}

可以看到注解的定义

java 复制代码
@Target({ ElementType.PARAMETER, ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Log {
    /**
     * 模块
     */
    public String title() default "";

    /**
     * 功能
     */
    public BusinessType businessType() default BusinessType.OTHER;

    /**
     * 操作人类别
     */
    public OperatorType operatorType() default OperatorType.MANAGE;
    ...
}

使用注解的形式记录操作日志,肯定是用 AOP 思想来做的,所以一定有个切面,LogAspect 这个切面用是前置通知和后置通知组合,当然也可以使用 around 环绕通知来做

最终也是异步任何结合线程池来保存数据的

java 复制代码
protected void handleLog(final JoinPoint joinPoint, Log controllerLog, final Exception e, Object jsonResult) {
  ....  
  // 设置方法名称
  String className = joinPoint.getTarget().getClass().getName();
  String methodName = joinPoint.getSignature().getName();
  operLog.setMethod(className + "." + methodName + "()");
  // 设置请求方式
  operLog.setRequestMethod(ServletUtils.getRequest().getMethod());
  // 处理设置注解上的参数
  getControllerMethodDescription(joinPoint, controllerLog, operLog, jsonResult);
  // 设置消耗时间
  operLog.setCostTime(System.currentTimeMillis() - TIME_THREADLOCAL.get());
  // 异步保存数据库
  AsyncManager.me().execute(AsyncFactory.recordOper(operLog));
}

大致流程如下

数据权限

如何确保用户只能访问他们授权查看的数据?这就是我们所说的数据权限控制

数据权限的场景:

  • 部门级权限
  • 公司级权限
  • 跨部门权限

若依管理系统中,角色定义了用户的权限,包括菜单权限(RBAC)和数据权限,若依的用户管理和部门管理实现了数据权限的功能,下面来做个测试,定义三个角色(因为权限是基于角色来控制的)

然后给 ry 这个用户分别赋予这三个角色,观察一下数据权限的查询范围

若依系统的数据权限设计主要通过用户、角色、部门表建立关系,实现对数据的访问控制

在需要数据权限控制的方法上加上 @DataScop 注解,其中d和u用来表示表的别名

java 复制代码
 @Override
 @DataScope(deptAlias = "d", userAlias = "u")
 public List<SysUser> selectUserList(SysUser user)
 {
     return userMapper.selectUserList(user);
 }

总结下流程

相关推荐
小丁爱养花3 分钟前
前端三剑客(三):JavaScript
开发语言·前端·javascript
ZwaterZ12 分钟前
vue el-table表格点击某行触发事件&&操作栏点击和row-click冲突问题
前端·vue.js·elementui·c#·vue
码农六六12 分钟前
vue3封装Element Plus table表格组件
javascript·vue.js·elementui
西凉河的葛三叔16 分钟前
vue3+elementui-plus el-dialog全局配置点击空白处不关闭弹窗
前端·vue3·elementui-plus
徐同保16 分钟前
el-table 多选改成单选
javascript·vue.js·elementui
快乐小土豆~~17 分钟前
el-input绑定点击回车事件意外触发页面刷新
javascript·vue.js·elementui
周三有雨23 分钟前
【面试题系列Vue07】Vuex是什么?使用Vuex的好处有哪些?
前端·vue.js·面试·typescript
木古古1836 分钟前
使用chrome 访问虚拟机Apache2 的默认页面,出现了ERR_ADDRESS_UNREACHABLE这个鸟问题
前端·chrome·apache
爱米的前端小笔记1 小时前
前端八股自学笔记分享—页面布局(二)
前端·笔记·学习·面试·求职招聘
飞升不如收破烂~1 小时前
Spring boot常用注解和作用
java·spring boot·后端