本文主要有以下内容:
- SpringBoot 整合 spring- security
- spring-security 在 RuoYi 中的使用
SpringBoot整合Spring-Security
spring-security简介
Spring Security
是一个 Java
框架,用于保护应用程序的安全性。它提供了一套全面的安全解决方案,包括身份验证、授权、防止攻击等功能。Spring Security
基于过滤器链的概念,可以轻松地集成到任何基于Spring的应用程序中。以上内容来自 spring- security
官网。
与 shiro
相比,spring- security
与 spring boot
的整合更为简单、方便。
spring boot 整合 spring security
首先创建 springboot
工程,在 pom
文件中引入如下依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
<version>2.7.18</version>
</dependency>
不需要进行任何配置,启动项目即可。
项目启动后,在控制台就会打印出默认账户 user
的登录密码(临时的)。通过 localhost:port
就可以看到默认的登录页面。
在密码框输入上面的密码即可登录。与整合 shiro
相比,简单不少。
ps:Spring Boot
整合 Shiro + Jwt
实现简单权鉴功能请参看这篇文章
在项目中如上功能肯定是无法满足需要的,因此就需要思考一个问题,如何与项目本身的用户模块整合在一起?
spring-security的用户配置
在 spring-security
框架中,支持四种方式的用户配置,我们需要编写一个配置类,用于配置我们想要的用户配置方式;配置类需要继承 WebSecurityConfigurerAdapter
类、重写 configure()
方法用于配置。
spring-security
支持如下的四种配置:
- 内存用户存储:基于内存的
- 数据库用户存储:基于数据库的
- LDAP用户存储:基于LADP的
- 自定义用户存储:用户自定义的
前三种基本上都很少用,这里以内存为例,代码配置如下:
package com.tutorial.security.config;
/**
* @author: suchao
* 创建时间: 2024年05月05日 15:36
* 文件描述:
*/
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity httpSecurity) throws Exception {
// 注解标记允许匿名访问的url
httpSecurity.authorizeRequests()
.antMatchers("/user/getUser.do").hasRole("ADMIN")
.antMatchers("/sec/login.do").permitAll()
.anyRequest().authenticated();
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication().passwordEncoder(passwordEncoder())
.withUser("supersist").password(passwordEncoder().encode("123456")).authorities("ADMIN")
.and()
.withUser("superman").password(passwordEncoder().encode("123456")).authorities("ORDINARY");
}
// 密码加密方式
private PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
在实际的开发中,最常用的还是用户自定义的方式,spring-security
框架给我们提供了一个接口UserDetailsService
、我们只需要实现loadUserByUsername()
方法即可,示例代码如下:
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Resource
private UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
if (userRepository.findUserEntityByUsername(username) != null) {
return userRepository.findUserEntityByUsername(username);
}
return null;
}
}
UserDetails
是一个接口,我们返回的查询对象需要实现此接口,在这里图简单、在DO就实现了此接口。实体类代码如下:
@Entity
@Data
@Table(name = "user")
public class UserEntity implements UserDetails {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private Long userId;
private String username;
private String password;
private String email;
private String phone;
private String address;
private String avatar;
private String role;
private String status;
private Date createTime;
private Date updateTime;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
// 获取用户权限
return null;
}
@Override
public boolean isAccountNonExpired() {
return false;
}
@Override
public boolean isAccountNonLocked() {
return false;
}
@Override
public boolean isCredentialsNonExpired() {
return false;
}
@Override
public boolean isEnabled() {
return false;
}
}
接下来就需要修改 SecurityConfig
的代码,在修改之前我们需要思考这几个问题:
security
是基于filter
的,用户认证和用户授权是在哪里执行的?- 认证和授权失败该如何响应客户端?
- 现在的项目大多都为前后端分离,权限大都采用
JWT
进行token
的创建与验证,如何与JWT
连用?
基于第一个问题:
UsernamePasswordAuthenticationFilter
: 是 Spring Security
中用于处理基于用户名和密码进行身份验证的过滤器。当用户尝试通过提交用户名和密码的方式进行身份验证时,这个过滤器会拦截请求并处理相应的身份验证逻辑。
UsernamePasswordAuthenticationToken
: 是 Spring Security
中用于表示基于用户名和密码进行身份验证的对象。当用户使用用户名和密码进行身份验证时,通常会创建一个 UsernamePasswordAuthenticationToken
对象,并通过 Spring Security
的认证流程进行验证。
第二个问题:
认证未通过的情况处理:
AuthenticationEntryPoint
是 Spring Security
中用于处理未经身份验证的用户尝试访问受保护资源的接口。当用户尝试访问需要进行身份验证的资源,但未提供有效的凭据时,AuthenticationEntryPoint
负责返回适当的响应,提示用户进行身份验证。
主要功能包括:
- Commence 方法:
AuthenticationEntryPoint
接口只有一个方法commence
,该方法接收HttpServletRequest、HttpServletResponse
和AuthenticationException
对象作为参数。在此方法中,开发人员可以自定义响应行为,例如返回自定义的错误页面、JSON
响应或重定向到登录页面等。 - 处理未认证的请求:当未经认证的用户尝试访问需要身份验证的资源时,
AuthenticationEntryPoint
负责响应,提示用户进行身份验证。
@Component
public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint, Serializable
{
private static final long serialVersionUID = -8970718410437077606L;
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e)
throws IOException
{
HttpStatus unauthorized = HttpStatus.UNAUTHORIZED;
String msg = String.format("请求访问,认证失败,无法访问系统资源");
HashMap<String, String> hashMap = new HashMap<>();
hashMap.put("code", String.valueOf(unauthorized.value()));
hashMap.put("msg", msg);
response.setStatus(200);
response.setContentType("application/json");
response.setCharacterEncoding("utf-8");
response.getWriter().print(hashMap);
}
}
认证通过但是授权未通过的情况:
AccessDeniedHandler
是 Spring Security
中用于处理已经认证但无权访问资源的用户尝试访问受保护资源的接口。AccessDeniedHandler
负责返回适当的响应,通常是一个访问拒绝页面或错误消息。
主要功能包括:
- Handle 方法:
AccessDeniedHandler
接口只有一个方法handle
,该方法接收 HttpServletRequest、HttpServletResponse 和 AccessDeniedException 对象作为参数。在此方法中,开发人员可以自定义拒绝访问的响应行为,例如返回自定义的错误页面、JSON 响应或重定向到错误页面等。 - 处理访问被拒绝的情况:当已认证的用户尝试访问其没有权限的资源时,
AccessDeniedHandler
负责响应,提示用户访问被拒绝。
private AccessDeniedHandler accessDeniedHandler() {
return (request, response, accessDeniedException) -> {
// 设置响应的状态码为 403 Forbidden
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
// 返回自定义的错误消息或重定向到访问拒绝页面等
String msg = "权限不足,请联系管理员!" + "Access denied: " + accessDeniedException.getMessage();
HashMap<String, String> hashMap = new HashMap<>();
hashMap.put("code", "401");
hashMap.put("msg", msg);
response.setStatus(200);
response.setContentType("application/json");
response.setCharacterEncoding("utf-8");
response.getWriter().write(String.valueOf(hashMap));
};
}
第三个问题:
JWT token
通常会包含用户的唯一性标识,用于判断用户是否为系统用户进而判断用户的一些基本信息,如权限、token
是否过期等。因此 JWTFilter
必定配置在 UsernamePasswordAuthenticationFilter
之前。
这里就需要考虑另外的问题:
- 如何写这个
JWTFilter
- 如何在
SecurityConfig
中配置
第一个小问题:这里需要使用到另外的Filter: OncePerRequestFilter
是 Spring Security
中的一个过滤器基类,它确保在请求处理过程中只执行一次过滤逻辑。即使请求经过多个过滤器链,也只会执行一次该过滤器的逻辑。
@Component
@Slf4j
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
@Resource
IUserService userService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
log.info("JwtAuthenticationTokenFilter");
// 模拟验证:没有实现具体的jwtToken的创建和验证,可在这里进行判断token是否合法
String token = request.getHeader("Authorization");
UserEntity loginUser = new UserEntity();
loginUser.setUsername("admin");
loginUser.setUserId(1L);
// 合法就需要创建一个authenticationToken给UsernamePasswordAuthenticationFilter使用。如果没有则进入AuthenticationEntryPointImpl的逻辑
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken("admin", loginUser, loginUser.getAuthorities());
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
chain.doFilter(request, response);
}
}
// 自定义的IUserService接口
public interface IUserService {
void createUser(String username,String password);
UserEntity findUserByUsername(String username);
}
接着在配置类中修改代码如下:
package com.tutorial.security.config;
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Resource
private UserDetailsServiceImpl userDetailsService;
@Resource
private AuthenticationEntryPointImpl unauthorizedHandler;
@Override
protected void configure(HttpSecurity httpSecurity) throws Exception {
httpSecurity
// CSRF禁用,因为不使用session
.csrf().disable()
// 禁用HTTP响应标头
.headers().cacheControl().disable().and()
// 认证失败处理类
.exceptionHandling().authenticationEntryPoint(unauthorizedHandler)
.accessDeniedHandler(accessDeniedHandler())
.and()
// 基于token,所以不需要session
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
// 过滤请求
.authorizeRequests()
// 对于登录login 允许匿名访问
.antMatchers("/sec/login.do").permitAll()
// 静态资源,可匿名访问
.antMatchers(HttpMethod.GET, "/", "/*.html", "/**/*.html", "/**/*.css", "/**/*.js", "/profile/**").permitAll()
//.antMatchers("/swagger-ui.html", "/swagger-resources/**", "/webjars/**", "/*/api-docs", "/druid/**").permitAll()
.antMatchers("/user/getUser.do").hasAuthority("ADMIN")
// 除上面外的所有请求全部需要鉴权认证
.anyRequest().authenticated()
.and()
.headers().frameOptions().disable();
// JWT Filter配置
httpSecurity.addFilterBefore(new JwtAuthenticationTokenFilter(), UsernamePasswordAuthenticationFilter.class);
}
// userDetailService 配置
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService)
.passwordEncoder(passwordEncoder());
}
private PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
private AccessDeniedHandler accessDeniedHandler() {
return (request, response, accessDeniedException) -> {
// 设置响应的状态码为 403 Forbidden
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
// 返回自定义的错误消息或重定向到访问拒绝页面等
String msg = "权限不足,请联系管理员!" + "Access denied: " + accessDeniedException.getMessage();
HashMap<String, String> hashMap = new HashMap<>();
hashMap.put("code", "401");
hashMap.put("msg", msg);
response.setStatus(200);
response.setContentType("application/json");
response.setCharacterEncoding("utf-8");
//response.getWriter().print(hashMap);
response.getWriter().write(String.valueOf(hashMap));
};
}
}
编写如下的两个 controller
:
@RestController
@Slf4j
@RequestMapping("user/")
@CrossOrigin(allowedHeaders = "*",origins = "*")
public class UserController {
@Resource
private IUserService userService;
@GetMapping("getUser.do")
//@PreAuthorize("hasRole('ROLE_ADMIN')")
public UserEntity register(String username)
{
log.info("username:{}",username);
return userService.findUserByUsername(username);
}
@GetMapping("getUser2.do")
public UserEntity register2(String username)
{
log.info("username:{}",username);
return userService.findUserByUsername(username);
}
}
@RestController
@Slf4j
@RequestMapping("sec/")
@CrossOrigin(allowedHeaders = "*",origins = "*")
public class SecurityController {
@Resource
private IUserService userService;
@PostMapping("login.do")
public UserEntity login(@RequestBody UserEntity loginUser)
{
log.info("username:{},password:{}",loginUser.getUsername(),loginUser.getPassword());
return userService.findUserByUsername(loginUser.getUsername());
}
}
路由说明:
/sec/login.do
:是不需要权鉴的可直接访问。/user/getUser.do
:需要admin的权限才能访问。/user/getUser2.do
: 不需要admin权限就可访问。
启动项目:通过postman进行验证,可以发现在访问getUser.do
的接口时返回如下的信息:
{msg=权限不足,请联系管理员!Access denied: Access is denied, code=401}
:说明验证已通过但是权限不足。
接着修改 UserEntity
的这部分代码如下:
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
// 获取用户权限 假定用户有admin权限。
ArrayList<GrantedAuthority> grantedAuthorities = new ArrayList<>();
grantedAuthorities.add(new GrantedAuthority() {
@Override
public String getAuthority() {
return "ADMIN";
}
});
return grantedAuthorities;
}
继续访问 getUser.do
就可以得到正常的访问结果。以上便是整合的简单示例代码
需要说明的是如果在配置类使用的 hasRole("ADMIN")
,则 UserEntity
需要修改为ROLE_ADMIN
;需要一个 ROLE
的前缀。
RuoYi是如何使用spring-security的
有了上面的理论基础,就看看实际项目中是如何使用的,首先便是看一下相关的配置文件SecurityConfig
,这里只展示主要配置代码:
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
,@EnableGlobalMethodSecurity
是 Spring Security 中用于启用方法级别的安全性的注解。通过该注解,可以在方法级别上进行安全性配置,例如控制哪些方法需要特定的权限、启用或禁用方法级别的安全注解等。
securedEnabled = true
启用了@Secured
注解,允许在方法上使用@Secured
进行安全配置。prePostEnabled = true
启用了@PreAuthorize
和@PostAuthorize
注解,允许在方法上使用@PreAuthorize
和@PostAuthorize
进行安全配置。
@Override
protected void configure(HttpSecurity httpSecurity) throws Exception
{
// 注解标记允许匿名访问的url
ExpressionUrlAuthorizationConfigurer<HttpSecurity>.ExpressionInterceptUrlRegistry registry = httpSecurity.authorizeRequests();
permitAllUrl.getUrls().forEach(url -> registry.antMatchers(url).permitAll());
httpSecurity
// CSRF禁用,因为不使用session
.csrf().disable()
// 禁用HTTP响应标头
.headers().cacheControl().disable().and()
// 认证失败处理类
.exceptionHandling().authenticationEntryPoint(unauthorizedHandler).
// 认证成功,授权失败处理类: 这里是我我自己加的!
accessDeniedHandler(accessDeniedHandler()).
and()
// 基于token,所以不需要session
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
// 过滤请求
.authorizeRequests()
// 对于登录login 注册register 验证码captchaImage 允许匿名访问
.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()
.and()
.headers().frameOptions().disable();
// 添加Logout filter
httpSecurity.logout().logoutUrl("/logout").logoutSuccessHandler(logoutSuccessHandler);
// 添加JWT filter
httpSecurity.addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
// 添加CORS filter
httpSecurity.addFilterBefore(corsFilter, JwtAuthenticationTokenFilter.class);
httpSecurity.addFilterBefore(corsFilter, LogoutFilter.class);
}
首先是Anonymous
注解配置的可以匿名访问URL:
//PermitAllUrlProperties.java
@Override
public void afterPropertiesSet()
{
RequestMappingHandlerMapping mapping = applicationContext.getBean(RequestMappingHandlerMapping.class);
Map<RequestMappingInfo, HandlerMethod> map = mapping.getHandlerMethods();
map.keySet().forEach(info -> {
HandlerMethod handlerMethod = map.get(info);
// 获取方法上边的注解 替代path variable 为 *
Anonymous method = AnnotationUtils.findAnnotation(handlerMethod.getMethod(), Anonymous.class);
Optional.ofNullable(method).ifPresent(anonymous -> Objects.requireNonNull(info.getPatternsCondition().getPatterns())
.forEach(url -> urls.add(RegExUtils.replaceAll(url, PATTERN, ASTERISK))));
// 获取类上边的注解, 替代path variable 为 *
Anonymous controller = AnnotationUtils.findAnnotation(handlerMethod.getBeanType(), Anonymous.class);
Optional.ofNullable(controller).ifPresent(anonymous -> Objects.requireNonNull(info.getPatternsCondition().getPatterns())
.forEach(url -> urls.add(RegExUtils.replaceAll(url, PATTERN, ASTERISK))));
});
}
接着看 JwtAuthenticationTokenFilter
:
@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter
{
private static final Logger log = LoggerFactory.getLogger(JwtAuthenticationTokenFilter.class);
@Autowired
private TokenService tokenService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException
{
// 得到loginUser的信息
LoginUser loginUser = tokenService.getLoginUser(request);
// 如果没有authenticationToken 则UsernamePasswordAuthenticationFilter验证失败就会进入认证失败的处理类中
if (StringUtils.isNotNull(loginUser) && StringUtils.isNull(SecurityUtils.getAuthentication()))
{
tokenService.verifyToken(loginUser);// 验证通过 刷新redis缓存的过期时间
log.info("新建authenticationToken: {}", JSON.toJSONString(loginUser));
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
}
chain.doFilter(request, response);
}
}
接着看一下 RuoYi 的登录逻辑:登录之后然后创建一个 token 并给前端返回。前端在请求时会携带此token。
@PostMapping("/login")
public AjaxResult login(@RequestBody LoginBody loginBody)
{
log.info("登录请求:" + loginBody.getUsername() + " " + loginBody.getPassword());
AjaxResult ajax = AjaxResult.success();
// 生成令牌
String token = loginService.login(loginBody.getUsername(), loginBody.getPassword(), loginBody.getCode(),
loginBody.getUuid());
// 添加创建的token
ajax.put(Constants.TOKEN, token);
return ajax;
}
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)
{
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();
}
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);
}
ps:在新增用户或者修改用户时,都是调用了对应密码的加密的。
public AjaxResult add(@Validated @RequestBody SysUser user)
{
if (!userService.checkUserNameUnique(user))
{
return error("新增用户'" + user.getUserName() + "'失败,登录账号已存在");
}
else if (StringUtils.isNotEmpty(user.getPhonenumber()) && !userService.checkPhoneUnique(user))
{
return error("新增用户'" + user.getUserName() + "'失败,手机号码已存在");
}
else if (StringUtils.isNotEmpty(user.getEmail()) && !userService.checkEmailUnique(user))
{
return error("新增用户'" + user.getUserName() + "'失败,邮箱账号已存在");
}
user.setCreateBy(getUsername());
user.setPassword(SecurityUtils.encryptPassword(user.getPassword()));
return toAjax(userService.insertUser(user));
}
// SecurityUtils.java
public static String encryptPassword(String password)
{
BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
return passwordEncoder.encode(password);
}
这上面是认证的过程,接着看一下授权相关的逻辑,首先看是如何使用的:
@PreAuthorize("@ss.hasPermi('system:dept:list')")
@GetMapping("/list")
public AjaxResult list(SysDept dept)
{
List<SysDept> depts = deptService.selectDeptList(dept);
return success(depts);
}
在配置类上开启了相关的注解的使用,这里通过 spel
表达式调用ss.hasPermi()
方法。hasPermi
在类 PermissionService
实现的,逻辑如下:
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;
}
PermissionContextHolder.setContext(permission);
return hasPermissions(loginUser.getPermissions(), permission);
}
/**
* 判断是否包含权限
*
* @param permissions 权限列表
* @param permission 权限字符串
* @return 用户是否具备某权限
*/
private boolean hasPermissions(Set<String> permissions, String permission)
{
return permissions.contains(Constants.ALL_PERMISSION) || permissions.contains(StringUtils.trim(permission));
}
以上便是 RuoYi 中对权鉴相关功能的实现。
参考资料:
- RuoYi分离版源码
- www.cnblogs.com/yychuyu/p/1…