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

总结下流程

相关推荐
!!!52516 分钟前
日志技术-LogBack入门程序&Log配置文件&日志级别
spring boot
桂月二二24 分钟前
探索前端开发中的 Web Vitals —— 提升用户体验的关键技术
前端·ux
沈梦研1 小时前
【Vscode】Vscode不能执行vue脚本的原因及解决方法
ide·vue.js·vscode
hunter2062062 小时前
ubuntu向一个pc主机通过web发送数据,pc端通过工具直接查看收到的数据
linux·前端·ubuntu
qzhqbb2 小时前
web服务器 网站部署的架构
服务器·前端·架构
刻刻帝的海角2 小时前
CSS 颜色
前端·css
轻口味2 小时前
Vue.js 组件之间的通信模式
vue.js
浪浪山小白兔3 小时前
HTML5 新表单属性详解
前端·html·html5
feilieren3 小时前
SpringBoot 搭建 SSE
java·spring boot·spring
lee5763 小时前
npm run dev 时直接打开Chrome浏览器
前端·chrome·npm