若依前后端分离版学习笔记(五)——Spring Boot简介与Spring Security
一 Spring Boot 简介
1、介绍
Spring Boot
是一款开箱即用框架,提供各种默认配置来简化项目配置。让我们的Spring
应用变的更轻量化、更快的入门。 在主程序执行main
函数就可以运行。你也可以打包你的应用为jar
并通过使用java -jar
来运行你的Web应用。它遵循\"约定优先于配置\"的原则, 使用SpringBoot
只需很少的配置,大部分的时候直接使用默认的配置即可。同时可以与Spring Cloud
的微服务无缝结合。
Spring Boot2.x版本环境要求必须是jdk8或以上版本,服务器Tomcat8或以上版本
2、优点
- 使编码变得简单: 推荐使用注解
- 使配置变得简单: 自动配置、快速集成新技术能力 没有冗余代码生成和XML配置的要求
- 使部署变得简单: 内嵌Tomcat、Jetty、Undertow等web容器,无需以war包形式部署
- 使监控变得简单: 提供运行时的应用监控
- 使集成变得简单: 对主流开发框架的无配置集成
- 使开发变得简单: 极大地提高了开发快速构建项目、部署效率
二 Spring Security安全控制
1、介绍
Spring Security
是一个能够为基于Spring的企业应用系统提供声明式的安全访问控制解决方案的安全框架
2、功能
Authentication
认证,就是用户登录
Authorization
授权,判断用户拥有什么权限,可以访问什么资源
安全防护,跨站脚本攻击,session
攻击等
非常容易结合Spring
进行使用
3、Spring Security与Shiro的区别
- 相同点
1.认证功能
2.授权功能
3.加密功能
4.会话管理
5.缓存支持
6.rememberMe功能
… - 不同点
优点:
1.Spring Security基于Spring开发,项目如果使用Spring作为基础,配合Spring Security做权限更加方便。而Shiro需要和Spring进行整合开发
2.Spring Security功能比Shiro更加丰富,例如安全防护方面
3.Spring Security社区资源相对比Shiro更加丰富
缺点:
1.Shiro的配置和使用比较简单,Spring Security上手复杂些
2.Shiro依赖性低,不需要依赖任何框架和容器,可以独立运行。Spring Security依赖Spring容器
上述所有内容来自ruoyi官方文档
4、Spring Security配置介绍
配置类为ruoyi-framework模块中com.ruoyi.framework.config下的SecurityConfig类
package com.ruoyi.framework.config;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;import org.springframework.http.HttpMethod;import org.springframework.security.authentication.AuthenticationManager;import org.springframework.security.authentication.ProviderManager;import org.springframework.security.authentication.dao.DaoAuthenticationProvider;import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;import org.springframework.security.config.annotation.web.builders.HttpSecurity;import org.springframework.security.config.http.SessionCreationPolicy;import org.springframework.security.core.userdetails.UserDetailsService;import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;import org.springframework.security.web.SecurityFilterChain;import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;import org.springframework.security.web.authentication.logout.LogoutFilter;import org.springframework.web.filter.CorsFilter;import com.ruoyi.framework.config.properties.PermitAllUrlProperties;import com.ruoyi.framework.security.filter.JwtAuthenticationTokenFilter;import com.ruoyi.framework.security.handle.AuthenticationEntryPointImpl;import com.ruoyi.framework.security.handle.LogoutSuccessHandlerImpl;/** * spring security配置 * * @author ruoyi */@EnableMethodSecurity(prePostEnabled = true, securedEnabled = true)@Configurationpublic class SecurityConfig{ /** * 自定义用户认证逻辑 */ @Autowired private UserDetailsService userDetailsService; /** * 认证失败处理类 */ @Autowired private AuthenticationEntryPointImpl unauthorizedHandler; /** * 退出处理类 */ @Autowired private LogoutSuccessHandlerImpl logoutSuccessHandler; /** * token认证过滤器 */ @Autowired private JwtAuthenticationTokenFilter authenticationTokenFilter; /** * 跨域过滤器 */ @Autowired private CorsFilter corsFilter; /** * 允许匿名访问的地址 */ @Autowired private PermitAllUrlProperties permitAllUrl; /** * 身份验证实现 */ @Bean public AuthenticationManager authenticationManager() { DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider(); daoAuthenticationProvider.setUserDetailsService(userDetailsService); daoAuthenticationProvider.setPasswordEncoder(bCryptPasswordEncoder()); return new ProviderManager(daoAuthenticationProvider); } /** * anyRequest | 匹配所有请求路径 * access | SpringEl表达式结果为true时可以访问 * anonymous | 匿名可以访问 * denyAll | 用户不能访问 * fullyAuthenticated | 用户完全认证可以访问(非remember-me下自动登录) * hasAnyAuthority | 如果有参数,参数表示权限,则其中任何一个权限可以访问 * hasAnyRole | 如果有参数,参数表示角色,则其中任何一个角色可以访问 * hasAuthority | 如果有参数,参数表示权限,则其权限可以访问 * hasIpAddress | 如果有参数,参数表示IP地址,如果用户IP和参数匹配,则可以访问 * hasRole | 如果有参数,参数表示角色,则其角色可以访问 * permitAll | 用户可以任意访问 * rememberMe | 允许通过remember-me登录的用户访问 * authenticated | 用户登录后可访问 */ @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()); }) // 认证失败处理类 .exceptionHandling(exception -> exception.authenticationEntryPoint(unauthorizedHandler)) // 基于token,所以不需要session .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // 注解标记允许匿名访问的url .authorizeHttpRequests((requests) -> { permitAllUrl.getUrls().forEach(url -> requests.antMatchers(url).permitAll()); // 对于登录login 注册register 验证码captchaImage 允许匿名访问 requests.antMatchers(\"/login\", \"/register\", \"/captchaImage\").permitAll() // 静态资源,可匿名访问 .antMatchers(HttpMethod.GET, \"/\", \"/*.html\", \"/**/*.html\", \"/**/*.css\", \"/**/*.js\", \"/profile/**\").permitAll() .antMatchers(\"/swagger-ui.html\", \"/swagger-resources/**\", \"/webjars/**\", \"/*/api-docs\", \"/druid/**\").permitAll() // 除上面外的所有请求全部需要鉴权认证 .anyRequest().authenticated(); }) // 添加Logout filter .logout(logout -> logout.logoutUrl(\"/logout\").logoutSuccessHandler(logoutSuccessHandler)) // 添加JWT filter .addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class) // 添加CORS filter .addFilterBefore(corsFilter, JwtAuthenticationTokenFilter.class) .addFilterBefore(corsFilter, LogoutFilter.class) .build(); } /** * 强散列哈希加密实现 */ @Bean public BCryptPasswordEncoder bCryptPasswordEncoder() { return new BCryptPasswordEncoder(); }}
核心配置都在这里
开启安全注解@EnableMethodSecurity(prePostEnabled = true, securedEnabled = true),默认是禁用的。prePostEnabled方法级的控制访问权限
4.1 强散列哈希加密实现
方法bCryptPasswordEncoder()返回强散列哈希加密对象
4.2 身份认证实现
新建一个Spring Security内置的对象DaoAuthenticationProvider,set自定义的用户验证处理UserDetailsService以及密码加密方式
我们再来看一下用户验证处理UserDetailsService的实现
package com.ruoyi.framework.web.service;import org.slf4j.Logger;import org.slf4j.LoggerFactory;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.security.core.userdetails.UserDetails;import org.springframework.security.core.userdetails.UserDetailsService;import org.springframework.security.core.userdetails.UsernameNotFoundException;import org.springframework.stereotype.Service;import com.ruoyi.common.core.domain.entity.SysUser;import com.ruoyi.common.core.domain.model.LoginUser;import com.ruoyi.common.enums.UserStatus;import com.ruoyi.common.exception.ServiceException;import com.ruoyi.common.utils.MessageUtils;import com.ruoyi.common.utils.StringUtils;import com.ruoyi.system.service.ISysUserService;/** * 用户验证处理 * * @author ruoyi */@Servicepublic class UserDetailsServiceImpl implements UserDetailsService{ private static final Logger log = LoggerFactory.getLogger(UserDetailsServiceImpl.class); @Autowired private ISysUserService userService; @Autowired private SysPasswordService passwordService; @Autowired private SysPermissionService permissionService; @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\")); } // 根据当前代码,不会有该情况,从userService.selectUserByUserName(username)查询里已经将删除状态的用户过滤掉了 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); // 密码正确返回新建LoginUser() return createLoginUser(user); } public UserDetails createLoginUser(SysUser user) { return new LoginUser(user.getUserId(), user.getDeptId(), user, permissionService.getMenuPermission(user)); }}
4.3 Spring Security相关配置
返回一个配置好的HttpSecurity,其中配置了哪些内容,代码上都有注释。我们来看一下自定义的认证失败处理类AuthenticationEntryPointImpl
package com.ruoyi.framework.security.handle;import java.io.IOException;import java.io.Serializable;import javax.servlet.http.HttpServletRequest;import javax.servlet.http.HttpServletResponse;import org.springframework.security.core.AuthenticationException;import org.springframework.security.web.AuthenticationEntryPoint;import org.springframework.stereotype.Component;import com.alibaba.fastjson2.JSON;import com.ruoyi.common.constant.HttpStatus;import com.ruoyi.common.core.domain.AjaxResult;import com.ruoyi.common.utils.ServletUtils;import com.ruoyi.common.utils.StringUtils;/** * 认证失败处理类 返回未授权 * * @author ruoyi */@Componentpublic class AuthenticationEntryPointImpl implements AuthenticationEntryPoint, Serializable{ private static final long serialVersionUID = -8970718410437077606L; @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException { // 返回错误消息和状态码 int code = HttpStatus.UNAUTHORIZED; String msg = StringUtils.format(\"请求访问:{},认证失败,无法访问系统资源\", request.getRequestURI()); ServletUtils.renderString(response, JSON.toJSONString(AjaxResult.error(code, msg))); }}
自定义退出处理类LogoutSuccessHandlerImpl
package com.ruoyi.framework.security.handle;import java.io.IOException;import javax.servlet.ServletException;import javax.servlet.http.HttpServletRequest;import javax.servlet.http.HttpServletResponse;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.context.annotation.Configuration;import org.springframework.security.core.Authentication;import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;import com.alibaba.fastjson2.JSON;import com.ruoyi.common.constant.Constants;import com.ruoyi.common.core.domain.AjaxResult;import com.ruoyi.common.core.domain.model.LoginUser;import com.ruoyi.common.utils.MessageUtils;import com.ruoyi.common.utils.ServletUtils;import com.ruoyi.common.utils.StringUtils;import com.ruoyi.framework.manager.AsyncManager;import com.ruoyi.framework.manager.factory.AsyncFactory;import com.ruoyi.framework.web.service.TokenService;/** * 自定义退出处理类 返回成功 * * @author ruoyi */@Configurationpublic class LogoutSuccessHandlerImpl implements LogoutSuccessHandler{ @Autowired private TokenService tokenService; /** * 退出处理 * * @return */ @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\")))); }}
JWT过滤,自定义的token认证过滤器 JwtAuthenticationTokenFilter
package com.ruoyi.framework.security.filter;import java.io.IOException;import javax.servlet.FilterChain;import javax.servlet.ServletException;import javax.servlet.http.HttpServletRequest;import javax.servlet.http.HttpServletResponse;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;import org.springframework.security.core.context.SecurityContextHolder;import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;import org.springframework.stereotype.Component;import org.springframework.web.filter.OncePerRequestFilter;import com.ruoyi.common.core.domain.model.LoginUser;import com.ruoyi.common.utils.SecurityUtils;import com.ruoyi.common.utils.StringUtils;import com.ruoyi.framework.web.service.TokenService;/** * token过滤器 验证token有效性 * * @author ruoyi */@Componentpublic class JwtAuthenticationTokenFilter extends OncePerRequestFilter{ @Autowired private TokenService tokenService; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { // 从请求中获取用户信息 LoginUser loginUser = tokenService.getLoginUser(request); // 如果用户信息不为null且未进行认证(安全认证为null) if (StringUtils.isNotNull(loginUser) && StringUtils.isNull(SecurityUtils.getAuthentication())) { // 验证token是否有效并重置token有效时间 tokenService.verifyToken(loginUser); // 进行安全认证并set到Spring Security上下文中 UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities()); authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authenticationToken); } // 继续过滤器链 chain.doFilter(request, response); }}
关于CSRF、JWT以及CORS的一些概念
- 关于CSRF:
- CSRF(跨站请求伪造)是一种攻击,攻击者诱导用户在当前已登录的Web应用上执行非本意的操作。这种攻击通常依赖于浏览器会自动携带与目标站点相关的Cookie(包括身份验证的Cookie)的特性。
- 在传统的基于Session的认证中,服务器会为每个用户创建一个Session,并返回一个Session ID(通常通过Cookie存储)。当用户发起请求时,浏览器会自动携带这个Cookie,服务器通过Session ID来识别用户。因此,如果用户已经登录,攻击者可以构造一个恶意请求,诱使用户点击,浏览器会自动携带Cookie,从而以用户身份执行操作。
- 为了防止CSRF攻击,常见的做法是使用CSRF Token。服务器生成一个Token,通常放在表单的隐藏字段中(或者作为请求头),然后服务器验证请求中的Token是否与Session中存储的Token一致。因为攻击者无法获取到Token(由于浏览器的同源策略),所以无法构造出合法的请求。
- 关于JWT:
- JWT(JSON Web Token)是一种无状态的认证机制。服务器在用户登录后生成一个Token(包含用户信息、过期时间等),并返回给客户端。客户端在后续请求中需要携带这个Token(通常放在Authorization头中)。服务器验证Token的签名和有效性,从而识别用户身份。
- 由于JWT不需要服务器保存Session,因此被称为无状态的。每次请求都携带Token,服务器通过验证Token来确认用户身份,而不是通过Cookie中的Session ID。因此,在JWT模式下,通常不需要担心CSRF攻击,因为:
- 浏览器不会自动在跨域请求中携带Authorization头(除非使用Credentialed请求,但即使这样,攻击者也无法构造出合法的Authorization头,因为他们不知道Token)。
- 但是,如果Token是存储在Cookie中(而不是使用LocalStorage然后通过JS手动添加到请求头),那么仍然可能受到CSRF攻击。因为攻击者可以诱导用户发起一个请求,浏览器会自动携带Cookie,从而将Token发送到服务器。所以,如果使用Cookie存储JWT,仍然需要CSRF保护。
- 关于CORS:
- CORS(跨域资源共享)是一种机制,它允许在浏览器中运行的脚本从不同的源(域名、协议、端口)访问资源。浏览器出于安全考虑,会限制跨域请求(如同源策略)。CORS通过设置一些HTTP头(如
Access-Control-Allow-Origin
)来告诉浏览器该资源允许被哪些源访问。 - 在前后端分离的架构中,前端和后端通常部署在不同的源(比如不同的端口或域名),因此需要配置CORS以允许跨域请求。否则,浏览器会阻止跨域请求的响应数据。
- 在Spring Security中,可以通过
CorsFilter
或者配置http.cors()
来启用CORS支持。这样,服务器会在响应中添加必要的CORS头。
我的理解: - 在传统的基于Session认证的web项目中,用户的信息会存在浏览器cookie里,CSRF是一种通过诱导用户点击使浏览器发送请求从而从cookie中获取用户的信息。
- JWT是每次请求都需要在发送请求的请求头中携带表明身份(如用户名,有效时间等)的请求头来进行身份验证,通常是Authorization: Bearer 。
- CORS跨域过滤器是为了避免通过JWT方式验证身份时遭遇跨域限制,通过HTTP响应头声明允许的跨域规则,浏览器先发OPTIONS请求检查CORS规则,通过后才发真实请求。
5、Spring Security 密码加密
在Spring Security 配置类SecurityConfig中,有一个bCryptPasswordEncoder()方法。
/** * 强散列哈希加密实现 */@Beanpublic BCryptPasswordEncoder bCryptPasswordEncoder(){ return new BCryptPasswordEncoder();}
BCryptPasswordEncoder实现PasswordEncoder接口
package org.springframework.security.crypto.password;public interface PasswordEncoder { /** * 将原始密码加密为密文 */ String encode(CharSequence rawPassword); /** * 验证原始密码与加密密文是否匹配 */ boolean matches(CharSequence rawPassword, String encodedPassword); /** * 判断已加密密码是否需要重新加密(默认不需要) */ default boolean upgradeEncoding(String encodedPassword) { return false; }}
身份认证流程
在SecurityConfig中配置了AuthenticationManager bean,它使用了DaoAuthenticationProvider,并设置了UserDetailsService(即UserDetailsServiceImpl)和密码编码器bCryptPasswordEncoder()。
- 在 SysLoginService 的login()方法中创建UsernamePasswordAuthenticationToken对象authenticationToken,把用户名,密码传入其中并set进AuthenticationContextHolder上下文,
- 当SysLoginService的登录方法调用authenticationManager.authenticate(authenticationToken)时,实际调用的是ProviderManager(Spring Security默认的AuthenticationManager实现)
- ProviderManager会遍历其配置的AuthenticationProvider列表(这里是DaoAuthenticationProvider)
- DaoAuthenticationProvider会调用我们配置的UserDetailsService(即UserDetailsServiceImpl)的loadUserByUsername方法来加载用户信息
- 加载到用户信息后,DaoAuthenticationProvider会使用配置的密码编码器(BCryptPasswordEncoder)来验证密码是否匹配
将相关代码贴在这里
/*** SysLoginService的login()方法中身份验证部分*/// 用户验证Authentication authentication = null;try{ UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(username, password); AuthenticationContextHolder.setContext(authenticationToken); // 该方法会去调用UserDetailsServiceImpl.loadUserByUsername authentication = authenticationManager.authenticate(authenticationToken);}catch (Exception e){ if (e instanceof BadCredentialsException) { AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message(\"user.password.not.match\"))); throw new UserPasswordNotMatchException(); } else { AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, e.getMessage())); throw new ServiceException(e.getMessage()); }}finally{ AuthenticationContextHolder.clearContext();}/*** SecurityConfig中的身份验证配置*/@Beanpublic AuthenticationManager authenticationManager(){ DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider(); daoAuthenticationProvider.setUserDetailsService(userDetailsService); daoAuthenticationProvider.setPasswordEncoder(bCryptPasswordEncoder()); return new ProviderManager(daoAuthenticationProvider);}
6、Spring Security 退出配置
在SecurityConfig中有一行退出登录的配置,Spring Security提供了一个logout方法,这里设置一个拦截的url以及退出成功后的处理。
logout(logout -> logout.logoutUrl(\"/logout\").logoutSuccessHandler(logoutSuccessHandler))
我们来看一下这个logoutSuccessHandler
package com.ruoyi.framework.security.handle;import java.io.IOException;import javax.servlet.ServletException;import javax.servlet.http.HttpServletRequest;import javax.servlet.http.HttpServletResponse;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.context.annotation.Configuration;import org.springframework.security.core.Authentication;import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;import com.alibaba.fastjson2.JSON;import com.ruoyi.common.constant.Constants;import com.ruoyi.common.core.domain.AjaxResult;import com.ruoyi.common.core.domain.model.LoginUser;import com.ruoyi.common.utils.MessageUtils;import com.ruoyi.common.utils.ServletUtils;import com.ruoyi.common.utils.StringUtils;import com.ruoyi.framework.manager.AsyncManager;import com.ruoyi.framework.manager.factory.AsyncFactory;import com.ruoyi.framework.web.service.TokenService;/** * 自定义退出处理类 返回成功 * * @author ruoyi */@Configurationpublic class LogoutSuccessHandlerImpl implements LogoutSuccessHandler{ @Autowired private TokenService tokenService; /** * 退出处理 * * @return */ @Override public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { // 从request拿到用户信息 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\")))); }}
LogoutSuccessHandlerImpl实现了LogoutSuccessHandler,LogoutSuccessHandler是Spring Security默认启动的,这里重写了onLogoutSuccess()方法。
前端api下的login.js中的退出方法即访问的/logout url进行退出操作
// 退出方法export function logout() { return request({ url: \'/logout\', method: \'post\' })}
7、Spring Security 登录配置
在SecurityConfig已经定义了身份认证实现,在4.3节我们已经查看了身份认证实现authenticationManager()方法以及自定义身份验证处理UserDetailsService。现在我们从登录接口来查看其登录配置。
首先查看SysLoginController中的login()方法
/** * 登录方法 * * @param loginBody 登录信息 * @return 结果 */@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()登录逻辑,我们来查看其自定义的登录服务
package com.ruoyi.framework.web.service;import javax.annotation.Resource;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.security.authentication.AuthenticationManager;import org.springframework.security.authentication.BadCredentialsException;import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;import org.springframework.security.core.Authentication;import org.springframework.stereotype.Component;import com.ruoyi.common.constant.CacheConstants;import com.ruoyi.common.constant.Constants;import com.ruoyi.common.constant.UserConstants;import com.ruoyi.common.core.domain.entity.SysUser;import com.ruoyi.common.core.domain.model.LoginUser;import com.ruoyi.common.core.redis.RedisCache;import com.ruoyi.common.exception.ServiceException;import com.ruoyi.common.exception.user.BlackListException;import com.ruoyi.common.exception.user.CaptchaException;import com.ruoyi.common.exception.user.CaptchaExpireException;import com.ruoyi.common.exception.user.UserNotExistsException;import com.ruoyi.common.exception.user.UserPasswordNotMatchException;import com.ruoyi.common.utils.DateUtils;import com.ruoyi.common.utils.MessageUtils;import com.ruoyi.common.utils.StringUtils;import com.ruoyi.common.utils.ip.IpUtils;import com.ruoyi.framework.manager.AsyncManager;import com.ruoyi.framework.manager.factory.AsyncFactory;import com.ruoyi.framework.security.context.AuthenticationContextHolder;import com.ruoyi.system.service.ISysConfigService;import com.ruoyi.system.service.ISysUserService;/** * 登录校验方法 * * @author ruoyi */@Componentpublic class SysLoginService{ @Autowired private TokenService tokenService; @Resource private AuthenticationManager authenticationManager; @Autowired private RedisCache redisCache; @Autowired private ISysUserService userService; @Autowired private ISysConfigService configService; /** * 登录验证 * * @param username 用户名 * @param password 密码 * @param code 验证码 * @param uuid 唯一标识 * @return 结果 */ public String login(String username, String password, String code, String uuid) { // 验证码校验 validateCaptcha(username, code, uuid); // 登录前置校验 loginPreCheck(username, password); // 用户验证 Authentication authentication = null; try { // 用户身份验证 UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(username, password); AuthenticationContextHolder.setContext(authenticationToken); // 该方法会去调用UserDetailsServiceImpl.loadUserByUsername authentication = authenticationManager.authenticate(authenticationToken); } catch (Exception e) { // 如果是密码错误,抛出UserPasswordNotMatchException()异常,其它错误抛出具体异常信息 if (e instanceof BadCredentialsException) { AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message(\"user.password.not.match\"))); throw new UserPasswordNotMatchException(); } else { AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, e.getMessage())); throw new ServiceException(e.getMessage()); } } // 清理AuthenticationContextHolder上下文 finally { AuthenticationContextHolder.clearContext(); } // 登录成功后的操作,更新ip,登录时间,记录登录日志 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); } /** * 校验验证码 * * @param username 用户名 * @param code 验证码 * @param uuid 唯一标识 * @return 结果 */ public void validateCaptcha(String username, String code, String uuid) { // 检查系统是否开启了验证码功能(从redis中查询配置) boolean captchaEnabled = configService.selectCaptchaEnabled(); if (captchaEnabled) { // 从redis中获取对应uuid的验证码 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(); } // 从redis中删除过期验证码的key redisCache.deleteObject(verifyKey); // 用户输入验证码与redis中对应验证码不一致,抛出对应异常 if (!code.equalsIgnoreCase(captcha)) { AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message(\"user.jcaptcha.error\"))); throw new CaptchaException(); } } } /** * 登录前置校验 * @param username 用户名 * @param password 用户密码 */ public void loginPreCheck(String username, String password) { // 用户名或密码为空 错误 if (StringUtils.isEmpty(username) || StringUtils.isEmpty(password)) { AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message(\"not.null\"))); throw new UserNotExistsException(); } // 密码如果不在指定范围内 错误 if (password.length() < UserConstants.PASSWORD_MIN_LENGTH || password.length() > UserConstants.PASSWORD_MAX_LENGTH) { AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message(\"user.password.not.match\"))); throw new UserPasswordNotMatchException(); } // 用户名不在指定范围内 错误 if (username.length() < UserConstants.USERNAME_MIN_LENGTH || username.length() > UserConstants.USERNAME_MAX_LENGTH) { AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message(\"user.password.not.match\"))); throw new UserPasswordNotMatchException(); } // IP黑名单校验 String blackStr = configService.selectConfigByKey(\"sys.login.blackIPList\"); if (IpUtils.isMatchedIp(blackStr, IpUtils.getIpAddr())) { AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message(\"login.blocked\"))); throw new BlackListException(); } } /** * 记录登录信息 * * @param userId 用户ID */ public void recordLoginInfo(Long userId) { SysUser sysUser = new SysUser(); sysUser.setUserId(userId); sysUser.setLoginIp(IpUtils.getIpAddr()); sysUser.setLoginDate(DateUtils.getNowDate()); userService.updateUserProfile(sysUser); }}
我们再看一下这里的AsyncManager,在这里是进行异步执行,这里的核心逻辑是进行用户登录验证,为了快速响应这里异步执行日志记录等辅助功能操作。既保证了主流程的高效执行,又完成了必要的辅助操作。
package com.ruoyi.framework.manager;import java.util.TimerTask;import java.util.concurrent.ScheduledExecutorService;import java.util.concurrent.TimeUnit;import com.ruoyi.common.utils.Threads;import com.ruoyi.common.utils.spring.SpringUtils;/** * 异步任务管理器 * * @author ruoyi */public class AsyncManager{ /** * 操作延迟10毫秒 */ private final int OPERATE_DELAY_TIME = 10; /** * 异步操作任务调度线程池 */ private ScheduledExecutorService executor = SpringUtils.getBean(\"scheduledExecutorService\"); /** * 单例模式 */ private AsyncManager(){} private static AsyncManager me = new AsyncManager(); public static AsyncManager me() { return me; } /** * 执行任务 * * @param task 任务 */ public void execute(TimerTask task) { executor.schedule(task, OPERATE_DELAY_TIME, TimeUnit.MILLISECONDS); } /** * 停止任务线程池 */ public void shutdown() { Threads.shutdownAndAwaitTermination(executor); }}
单例模式: AsyncManager.me() 返回 AsyncManager 的单例实例,保证线程安全。
延迟执行: execute() 方法将任务提交到调度线程池,延迟10毫秒执行
线程池管理:使用共享的ScheduledExecutorService线程池来控制并发执行的线程数量,避免创建过多线程导致资源耗尽,统一管理和监控异步任务
8、权限配置
在我们上述提到的UserDetailsServiceImpl实现类中有一个createLoginUser方法,当用户验证通过后来创建一个LoginUser对象,其中permissionService.getMenuPermission(user)即为获取该用户的权限。
public UserDetails createLoginUser(SysUser user){ return new LoginUser(user.getUserId(), user.getDeptId(), user, permissionService.getMenuPermission(user));}
我们来看SysPermissionService的代码
package com.ruoyi.framework.web.service;import java.util.HashSet;import java.util.List;import java.util.Set;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.stereotype.Component;import org.springframework.util.CollectionUtils;import com.ruoyi.common.constant.UserConstants;import com.ruoyi.common.core.domain.entity.SysRole;import com.ruoyi.common.core.domain.entity.SysUser;import com.ruoyi.common.utils.StringUtils;import com.ruoyi.system.service.ISysMenuService;import com.ruoyi.system.service.ISysRoleService;/** * 用户权限处理 * * @author ruoyi */@Componentpublic class SysPermissionService{ @Autowired private ISysRoleService roleService; @Autowired private ISysMenuService menuService; /** * 获取角色数据权限 * * @param user 用户信息 * @return 角色权限信息 */ public Set<String> getRolePermission(SysUser user) { Set<String> roles = new HashSet<String>(); // 管理员拥有所有权限 if (user.isAdmin()) { roles.add(\"admin\"); } else { roles.addAll(roleService.selectRolePermissionByUserId(user.getUserId())); } return roles; } /** * 获取菜单数据权限 * * @param user 用户信息 * @return 菜单权限信息 */ public Set<String> getMenuPermission(SysUser user) { Set<String> perms = new HashSet<String>(); // 管理员拥有所有权限 if (user.isAdmin()) { perms.add(\"*:*:*\"); } else { // 获取用户的角色列表 List<SysRole> roles = user.getRoles(); if (!CollectionUtils.isEmpty(roles)) { // 多角色设置permissions属性,以便数据权限匹配权限 for (SysRole role : roles) { // 如果角色状态正常且不是admin角色 if (StringUtils.equals(role.getStatus(), UserConstants.ROLE_NORMAL) && !role.isAdmin()) { // 通过角色id从数据库中查询权限 Set<String> rolePerms = menuService.selectMenuPermsByRoleId(role.getRoleId()); role.setPermissions(rolePerms); perms.addAll(rolePerms); } } } // 如果没有角色则通过用户id从数据库中查询权限 else { perms.addAll(menuService.selectMenuPermsByUserId(user.getUserId())); } } return perms; }}
这里我们看一下这两个查询权限的sql及结果
<select id=\"selectMenuPermsByRoleId\" parameterType=\"Long\" resultType=\"String\"> select distinct m.perms from sys_menu m left join sys_role_menu rm on m.menu_id = rm.menu_id where m.status = \'0\' and rm.role_id = #{roleId}</select><select id=\"selectMenuPermsByUserId\" parameterType=\"Long\" resultType=\"String\"> select distinct m.perms from sys_menu m left join sys_role_menu rm on m.menu_id = rm.menu_id left join sys_user_role ur on rm.role_id = ur.role_id left join sys_role r on r.role_id = ur.role_id where m.status = \'0\' and r.status = \'0\' and ur.user_id = #{userId}</select>
9、权限注解
在ruoyi-framework模块中com.ruoyi.framework.web.service下有一个PermissionService,来判断是否具有权限等。
package com.ruoyi.framework.web.service;import java.util.Set;import org.springframework.stereotype.Service;import org.springframework.util.CollectionUtils;import com.ruoyi.common.constant.Constants;import com.ruoyi.common.core.domain.entity.SysRole;import com.ruoyi.common.core.domain.model.LoginUser;import com.ruoyi.common.utils.SecurityUtils;import com.ruoyi.common.utils.StringUtils;import com.ruoyi.framework.security.context.PermissionContextHolder;/** * RuoYi首创 自定义权限实现,ss取自SpringSecurity首字母 * * @author ruoyi */@Service(\"ss\")public class PermissionService{ /** * 验证用户是否具备某权限,有权限返回true,没权限返回false * * @param permission 权限字符串 * @return 用户是否具备某权限 */ public boolean hasPermi(String permission) { if (StringUtils.isEmpty(permission)) { return false; } // 获取用户信息 LoginUser loginUser = SecurityUtils.getLoginUser(); if (StringUtils.isNull(loginUser) || CollectionUtils.isEmpty(loginUser.getPermissions())) { return false; } // 将当前检查权限set进上下文 PermissionContextHolder.setContext(permission); // 返回是否具备该权限 return hasPermissions(loginUser.getPermissions(), permission); } /** * 验证用户是否不具备某权限,与 hasPermi逻辑相反 * * @param permission 权限字符串 * @return 用户是否不具备某权限 */ public boolean lacksPermi(String permission) { return hasPermi(permission) != true; } /** * 验证用户是否具有以下任意一个权限 * * @param permissions 以 PERMISSION_DELIMETER 为分隔符的权限列表 * @return 用户是否具有以下任意一个权限 */ public boolean hasAnyPermi(String permissions) { if (StringUtils.isEmpty(permissions)) { return false; } LoginUser loginUser = SecurityUtils.getLoginUser(); if (StringUtils.isNull(loginUser) || CollectionUtils.isEmpty(loginUser.getPermissions())) { return false; } PermissionContextHolder.setContext(permissions); Set<String> authorities = loginUser.getPermissions(); // 通过“,”分割permissions权限字符串,循环判断是否有该权限 for (String permission : permissions.split(Constants.PERMISSION_DELIMETER)) { if (permission != null && hasPermissions(authorities, permission)) { return true; } } return false; } /** * 判断用户是否拥有某个角色 * * @param role 角色字符串 * @return 用户是否具备某角色 */ public boolean hasRole(String role) { if (StringUtils.isEmpty(role)) { return false; } // 获取用户信息 LoginUser loginUser = SecurityUtils.getLoginUser(); if (StringUtils.isNull(loginUser) || CollectionUtils.isEmpty(loginUser.getUser().getRoles())) { return false; } // 遍历该用户的角色列表,判断是否包括该角色 for (SysRole sysRole : loginUser.getUser().getRoles()) { String roleKey = sysRole.getRoleKey(); // 是否是管理员角色或包括该角色 if (Constants.SUPER_ADMIN.equals(roleKey) || roleKey.equals(StringUtils.trim(role))) { return true; } } return false; } /** * 验证用户是否不具备某角色,与 isRole逻辑相反。 * * @param role 角色名称 * @return 用户是否不具备某角色 */ public boolean lacksRole(String role) { return hasRole(role) != true; } /** * 验证用户是否具有以下任意一个角色 * * @param roles 以 ROLE_NAMES_DELIMETER 为分隔符的角色列表 * @return 用户是否具有以下任意一个角色 */ public boolean hasAnyRoles(String roles) { if (StringUtils.isEmpty(roles)) { return false; } LoginUser loginUser = SecurityUtils.getLoginUser(); if (StringUtils.isNull(loginUser) || CollectionUtils.isEmpty(loginUser.getUser().getRoles())) { return false; } for (String role : roles.split(Constants.ROLE_DELIMETER)) { if (hasRole(role)) { return true; } } return false; } /** * 判断是否包含权限 * * @param permissions 权限列表 * @param permission 权限字符串 * @return 用户是否具备某权限 */ private boolean hasPermissions(Set<String> permissions, String permission) { // 返回用户的权限列表中是否包含所有权限标识符或该权限标识符 return permissions.contains(Constants.ALL_PERMISSION) || permissions.contains(StringUtils.trim(permission)); }}
其中PermissionService通过@Service注册到容器中并命名“ss”,可以通过 @ss 来引用这个权限服务,用于进行权限判断和控制。set 为不允许重复的字符串集合,查询效率更高。
下面我们看一下如何应用PermissionService服务,查看ruoyi-admin模块com.ruoyi.web.controller.system下的SysUserController的部分代码。
/** * 获取用户列表 */ // 直接@ss.hasPermi进行调用,传入权限字符串,判断是否有获取用户列表权限@PreAuthorize(\"@ss.hasPermi(\'system:user:list\')\")@GetMapping(\"/list\")public TableDataInfo list(SysUser user){ startPage(); List<SysUser> list = userService.selectUserList(user); return getDataTable(list);}@Log(title = \"用户管理\", businessType = BusinessType.EXPORT)@PreAuthorize(\"@ss.hasPermi(\'system:user:export\')\")@PostMapping(\"/export\")public void export(HttpServletResponse response, SysUser user){ List<SysUser> list = userService.selectUserList(user); ExcelUtil<SysUser> util = new ExcelUtil<SysUser>(SysUser.class); util.exportExcel(response, list, \"用户数据\");}
@PreAuthorize 是 Spring Security 框架提供的一个方法级安全注解,用于在方法执行前进行权限检查。
- 作用:在方法执行前验证用户是否具有指定权限,如果没有相应权限则拒绝访问
- 位置:可以放在类或方法上,方法上的注解优先级更高
在这里的具体作用为
- @ss 引用之前注册的名为\"ss\"的PermissionService Bean
- .hasPermi(‘system:user:list’) 调用该服务的hasPermi方法检查用户是否具有\"system:user:list\"权限
- 只有当权限检查通过时,才会执行被注解的方法list(SysUser user)