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 应用程序的安全性,包括认证、授权、回话管理、过滤器添加等.
// 表示开启方法级别的权限控制=> @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
做啥了
@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.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
逻辑
@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));
// 如果缓存中没有,则初始化 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);
}
创建登录用户对象返回
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+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
指令,这个指令是若依框架自定义的组件
<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
查询用户信息,可以看下这个接口,其实就是获取当前用户拥有的角色和权限集合
@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();
// 为空直接返回 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 类还定义了其它方法,如下
异步任务管理
来分析一下 登录的接口
@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());
// 生成token
return tokenService.createToken(loginUser);
}
记录的登录日志用于在这里查询
任务的生成,TimerTask 实现了 Runnable 接口,本质是个任务,这里是插入数据到库
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
调用线程池执行的
/**
* 异步操作任务调度线程池
*/
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);
}
总结下流程