RuoYi-Vue 最新 SpringBoot3 前后端分离版本源码分析
- RuoYi-Vue 本地环境部署
- 若依菜单类型
- 权限管理
- SpringSecurity 配置
- 登录接口(认证管理)
- Authentication 认证
- token的生成
- 权限控制
- 异步任务管理
- 操作日志
- 数据权限
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 应用程序的安全性,包括认证、授权、回话管理、过滤器添加等.
// 表示开启方法级别的权限控制=> @PreAuthorize
@EnableMethodSecurity(prePostEnabled = true, securedEnabled = true)
@Configuration
public class SecurityConfig {//身份验证实现@Beanpublic AuthenticationManager authenticationManager() {DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();// 这句代码很重要,后续登录的时候会进入到UserDetailsServiceImpl#loadUserByUsername方法进行认证daoAuthenticationProvider.setUserDetailsService(userDetailsService);daoAuthenticationProvider.setPasswordEncoder(bCryptPasswordEncoder());return new ProviderManager(daoAuthenticationProvider);}@Beanprotected 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
做啥了
@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
拦截
@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);
}
登录接口(认证管理)
@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
方法
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.loadUserByUsernameauthentication = 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());// 生成tokenreturn tokenService.createToken(loginUser);
}
public void validateCaptcha(String username, String code, String uuid) {// 检查是否启用了验证码功能boolean captchaEnabled = configService.selectCaptchaEnabled();if (captchaEnabled) {// 构建验证码缓存 keyString 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
逻辑
@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);
}
来看看密码校验部分逻辑
public void validate(SysUser user) {// 获取当前的认证信息,从认证信息中提取用户名和密码Authentication usernamePasswordAuthenticationToken = AuthenticationContextHolder.getContext();String username = usernamePasswordAuthenticationToken.getName();String password = usernamePasswordAuthenticationToken.getCredentials().toString();// 尝试从缓存中获取当前用户的密码重试次数Integer retryCount = redisCache.getCacheObject(getCacheKey(username));// 如果缓存中没有,则初始化 0if (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);}
创建登录用户对象返回
public UserDetails createLoginUser(SysUser user) {return new LoginUser(user.getUserId(), user.getDeptId(), user, permissionService.getMenuPermission(user));
}
这个对象是这样的
token的生成
public String createToken(LoginUser loginUser) {String token = IdUtils.fastUUID();loginUser.setToken(token);// 设置用户登录信息,都是一些 setXX操作setUserAgent(loginUser);// 登录后,在 redis会缓存登录用户信息,key是 login_tokens+uuidrefreshToken(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
指令,这个指令是若依框架自定义的组件
<el-buttonsize="mini"type="text"icon="el-icon-edit"@click="handleUpdate(scope.row)"v-hasPermi="['system:dept:edit']"
>修改</el-button>
那用户权限怎么获取呢?会发现,每次刷新浏览器都会发送 http://localhost/dev-api/getInfo
查询用户信息,可以看下这个接口,其实就是获取当前用户拥有的角色和权限集合
@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 注解是实现方法级别访问控制的核心工具,它通过在方法前进行权限校验,确保只有符合条件的用户才能访问特定的功能
@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
这个属性起了作用
@Service("ss")
public class PermissionService {
/*** 验证用户是否具备某权限** @param permission 权限字符串* @return 用户是否具备某权限*/
public boolean hasPermi(String permission) {// 判空if (StringUtils.isEmpty(permission)) {return false;}// 获取当前登录用户信息LoginUser loginUser = SecurityUtils.getLoginUser();// 为空直接返回 falseif (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 类还定义了其它方法,如下
异步任务管理
来分析一下 登录的接口
@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
方法
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());// 生成tokenreturn tokenService.createToken(loginUser);}
记录的登录日志用于在这里查询
任务的生成,TimerTask 实现了 Runnable 接口,本质是个任务,这里是插入数据到库
public static TimerTask recordLogininfor(final String username, final String status, final String message,final Object... args){...return new TimerTask(){@Overridepublic void run(){...// 封装对象SysLogininfor logininfor = new SysLogininfor();...// 插入数据SpringUtils.getBean(ISysLogininforService.class).insertLogininfor(logininfor);}};}
而任务是由 AsyncManager.me().execute
调用线程池执行的
/*** 异步操作任务调度线程池*/
private ScheduledExecutorService executor = SpringUtils.getBean("scheduledExecutorService");public void execute(TimerTask task)
{executor.schedule(task, OPERATE_DELAY_TIME, TimeUnit.MILLISECONDS);
}
整个过程流程图总结如下
操作日志
在日常编程中,记录日志是我们的得力助手,尤其在处理关键业务时,它能帮助我们追踪和审查操作过程,那 RuoYi 是怎么记录操作日志的呢?
在需要被记录日志的 Controller 方法上添加 @Log 注解,使用方法如下:
// 删除用户
@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));
}
可以看到注解的定义
@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 环绕通知来做
最终也是异步任何结合线程池来保存数据的
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用来表示表的别名
@Override@DataScope(deptAlias = "d", userAlias = "u")public List<SysUser> selectUserList(SysUser user){return userMapper.selectUserList(user);}
总结下流程