likes
comments
collection
share

Spring Boot | 集成 Spring Security

作者站长头像
站长
· 阅读数 17

本示例采用的技术框架如下所示:

  • 基础框架:Spring Boot 2.7.7
  • 持久层框架:MyBatis Plus 3.5.3.1
  • 工具类库:Hutool 5.7.22
  • 缓存:Redis
  • 数据库:MySQL 8
  • 加密算法:国密SM4
  • 身份验证:JWT
  • 简化代码:lombok

JWT简介

JWT(JSON Web Token),是目前比较流行的用户身份验证解决方案

JWT生成和认证的基本流程

下面是一个简化的时序图,用于说明JWT生成和认证的基本流程。

浏览器服务器发送登录数据(用户名、密码)1验证用户名、密码,生成Token2将Token返给浏览器3调用接口,请求头中含有Token信息4验证Token5返回接口执行结果6浏览器服务器

笔者汪小成之前写的一篇介绍JWT的文章 —— 《JWT详解&工具类封装&自动续期》,有兴趣的朋友可以看一下。

Spring Security简介

Spring Security is a powerful and highly customizable authentication and access-control framework.

—— 引自官网

Spring Security 是一个功能强大高度可定制的安全框架。

引入 Spring Security 依赖

在 Spring Boot 项目中集成 Spring Security,需要在pom.xml文件中配置所需依赖。如下所示:

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-security</artifactId>
</dependency>

Spring Security 配置说明

**注意:**从 Spring Boot 2.7.0 版本开始,Spring Security 废弃用了WebSecurityConfigurerAdapter

在 Spring Security 配置文件中,我们通常需要做如下配置:

  • AuthenticationProvider实现类:用于自定义身份验证逻辑;
  • Filter:用于验证 token 有效性;
  • AuthenticationManager:用于接收并处理身份验证请求;
  • PasswordEncoder:用于密码加密和验证;
  • SecurityFilterChain:过滤器链;
Spring Boot | 集成 Spring Security

自定义PasswordEncoder

Spring Security的PasswordEncoder是用于进行密码加密和验证的接口。它是一个密码编码器,用于将用户的原始密码转换为安全的加密字符串,并在验证过程中将加密后的密码与用户提供的密码进行比较。PasswordEncoder接口的主要用于提供安全的密码存储和验证机制,以防止用户密码泄露时被恶意使用。它是一种重要的安全性措施,用于保护用户密码的安全性。

Spring Security 提供了多种PasswordEncoder接口的实现类,包括:

  1. BCryptPasswordEncoder:使用BCrypt算法进行密码哈希和验证。它是目前广泛使用的密码哈希算法之一,具有较高的安全性。
  2. NoOpPasswordEncoder:不进行任何密码编码和哈希操作,即明文存储密码。不推荐在生产环境中使用,仅用于测试目的。
  3. Pbkdf2PasswordEncoder:使用PBKDF2算法进行密码哈希和验证。它通过应用哈希函数多次迭代和盐值,增加了密码破解的难度。
  4. MessageDigestPasswordEncoder:使用指定的消息摘要算法(如MD5、SHA-1、SHA-256等)进行密码哈希和验证。

使用国密(SM4)算法实现自定义的 PasswordEncoder

使用国密(SM4)算法实现自定义的 PasswordEncoder,您需要执行以下步骤:

1、添加依赖

<!-- SM4依赖 -->
<dependency>
  <groupId>org.bouncycastle</groupId>
  <artifactId>bcprov-jdk15to18</artifactId>
  <version>1.71</version>
</dependency>

2、自定义的 PasswordEncoder —— Sm4PasswordEncoder.java

import cn.hutool.core.util.CharsetUtil;
import cn.hutool.crypto.SmUtil;
import org.springframework.security.crypto.password.PasswordEncoder;

import java.nio.charset.StandardCharsets;
import java.util.Objects;

public class Sm4PasswordEncoder implements PasswordEncoder {

	// key长度必须为16
	private static final String KEY = "KeyMustBe16Size.";

	@Override
	public String encode(CharSequence rawPassword) {
		return SmUtil.sm4(KEY.getBytes(StandardCharsets.UTF_8)).encryptHex(rawPassword.toString());
	}

	@Override
	public boolean matches(CharSequence rawPassword, String encodedPassword) {
		return Objects.equals(rawPassword.toString(),
			SmUtil.sm4(KEY.getBytes(StandardCharsets.UTF_8)).decryptStr(encodedPassword, StandardCharsets.UTF_8));
	}
}

需要实现PasswordEncoder接口的encode()matches()方法。encode()方法用于对明文密码进行加密处理,matches()方法用于比较明文密码与加密后的密码是否匹配。

3、在 Spring Security 配置文件中配置自定义的 PasswordEncoder

@Configuration
@EnableWebSecurity
public class WebSecurityConfig {
	// 其它代码
+	/**
+	 * 密码加密方式配置
+	 */
+	@Bean
+	public PasswordEncoder passwordEncoder() {
+		return new Sm4PasswordEncoder();
+	}
}

自定义 Filter 验证 token 有效性

1、实现UserDetailsService接口,用于获取用户详细信息

import cn.ddcherry.springboot.demo.entity.User;
import cn.ddcherry.springboot.demo.service.RoleService;
import cn.ddcherry.springboot.demo.service.UserService;
import cn.ddcherry.springboot.demo.security.model.AuthUser;
import cn.ddcherry.springboot.demo.util.WebUtil;
import lombok.AllArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
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 javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;

@Service
@AllArgsConstructor
public class UserDetailsServiceImpl implements UserDetailsService {

	@Resource
	private UserService userService;
	@Resource
	private RoleService roleService;

	@Override
	public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
		User user = userService.findByUsername(username);
		if (Objects.isNull(user)) {
			throw new UsernameNotFoundException("用户名或密码错误!");
		}
		List<String> roleCodeList = roleService.findRoleCodesByUsername(username);
		List<GrantedAuthority> authorities = roleCodeList.stream().map(SimpleGrantedAuthority::new).collect(Collectors.toList());
		return new AuthUser(user.getId(), user.getRealName(), user.getAvatar(), user.getPhone(),
			user.getUsername(), user.getPassword(), authorities);
	}
}

UserDetailsServiceImpl类实现了UserDetailsService接口,重写了loadUserByUsername方法,用于获取用户的详细信息。

获取用户详细信息的大体流程:

不存在
存在
根据用户名获取用户信息
用户信息是否存在
抛出异常
结束
根据用户名获取角色列表
将角色编码转换成SimpleGrantedAuthority对象
返回用户信息

其中AuthUser为自定义认证用户信息类,代码如下:

import lombok.Getter;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.User;

import java.util.Collection;

@Getter
public class AuthUser extends User {
	/**
	 * 用户ID
	 */
	private final String userId;
	/**
	 * 真实姓名
	 */
	private final String realName;
	/**
	 * 电话
	 */
	private final String phone;
	/**
	 * 头像
	 */
	private final String avatar;

	public AuthUser(String userId, String realName, String avatar, String phone, String username, String password,
					Collection<? extends GrantedAuthority> authorities) {
		super(username, password, true, true, true, true, authorities);
		this.userId = userId;
		this.realName = realName;
		this.avatar = avatar;
		this.phone = phone;
	}
}

AuthUser继续org.springframework.security.core.userdetails.User,添加了一些业务属性。

2、自定义 Filter 验证 token 有效性

import cn.ddcherry.springboot.demo.constant.AuthConstant;
import cn.ddcherry.springboot.demo.util.JwtUtil;
import cn.hutool.core.util.StrUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.annotation.Resource;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
 * 验证token有效性
 */
@Slf4j
public class TokenFilter extends OncePerRequestFilter {

	@Resource
	private UserDetailsService userDetailsService;

	@Override
	protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
		String token = getToken(request);
		if (StrUtil.isNotEmpty(token)) {
			// 从Token中获取username
			String username = JwtUtil.getUsernameFromToken(token);
			// 根据username获取用户信息
			UserDetails userDetails = userDetailsService.loadUserByUsername(username);
			// 创建身份验证对象
			Authentication authentication
				= new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
			// 设置身份验证对象
			SecurityContextHolder.getContext().setAuthentication(authentication);
		}
		// 过滤器链
		filterChain.doFilter(request, response);
	}

	private String getToken(HttpServletRequest request) {
		String bearerToken = request.getHeader("Authorization");
		if (StrUtil.isNotEmpty(bearerToken) && bearerToken.startsWith(AuthConstant.AUTHORIZATION_BEARER)) {
			// 去掉令牌前缀
			return bearerToken.replace(AuthConstant.AUTHORIZATION_BEARER, StrUtil.EMPTY);
		}
		return null;
	}
}

自定义过滤器验证 token 效率性流程图:

不存在
存在
从请求头中获取token信息
token是否为空
继续执行其它过滤器
解析token信息获取username
根据username获取用户信息
创建身份验证对象
设置身份验证对象

3、在 Spring Security 配置文件中配置自定义的自定义 Filter

@Configuration
@EnableWebSecurity
public class WebSecurityConfig {
	// 其它代码
+	@Bean
+	public TokenFilter tokenFilter() {
+		return new TokenFilter();
+	}
}

配置 AuthenticationProvider

在 Spring Security 配置文件中配置 AuthenticationProvider

@Configuration
@EnableWebSecurity
public class WebSecurityConfig {

	// 其它代码

+	@Resource
+	private UserDetailsServiceImpl userDetailsService;

+	@Bean
+	public DaoAuthenticationProvider authenticationProvider() {
+		DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
+		authProvider.setUserDetailsService(userDetailsService);
+		authProvider.setPasswordEncoder(passwordEncoder());
+		return authProvider;
+	}

}

DaoAuthenticationProvider是 Spring Security 提供的一个身份验证实现类,它使用数据库中的用户详细信息和密码加密器进行身份验证。

配置 AuthenticationManager

在 Spring Security 配置文件中配置 AuthenticationManager

@Configuration
@EnableWebSecurity
public class WebSecurityConfig {

	// 其它代码

+	@Bean
+	public AuthenticationManager authenticationManager(AuthenticationConfiguration authConfig) throws Exception {
+		return authConfig.getAuthenticationManager();
+	}
}

配置过滤器链

1、自定义类,处理未经身份验证或者身份验证失败的用户访问受保护资源时的行为

import cn.ddcherry.springboot.demo.api.Result;
import cn.ddcherry.springboot.demo.api.ResultCode;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONUtil;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
 * 处理未经身份验证或者身份验证失败的用户访问受保护资源时的行为
 */
@Component
public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint {
	@Override
	public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
		String msg = StrUtil.format("请求访问:{},认证失败,无法访问系统资源", request.getRequestURI());
		response.setStatus(200);
		response.setContentType("application/json");
		response.setCharacterEncoding("utf-8");
		response.getWriter().print(JSONUtil.toJsonStr(Result.fail(ResultCode.UNAUTHORIZED, msg)));
	}
}

2、在 Spring Security 配置文件中配置 AuthenticationManager

import cn.ddcherry.springboot.demo.security.crypto.Sm4PasswordEncoder;
import cn.ddcherry.springboot.demo.security.filter.TokenFilter;
import cn.ddcherry.springboot.demo.service.impl.UserDetailsServiceImpl;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

import javax.annotation.Resource;

@Configuration
@EnableWebSecurity
public class WebSecurityConfig {

	@Resource
	private UserDetailsServiceImpl userDetailsService;

+	@Resource
+	private AuthenticationEntryPoint authenticationEntryPoint;

	@Bean
	public DaoAuthenticationProvider authenticationProvider() {
		DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
		authProvider.setUserDetailsService(userDetailsService);
		authProvider.setPasswordEncoder(passwordEncoder());
		return authProvider;
	}

	@Bean
	public TokenFilter tokenFilter() {
		return new TokenFilter();
	}

	@Bean
	public AuthenticationManager authenticationManager(AuthenticationConfiguration authConfig) throws Exception {
		return authConfig.getAuthenticationManager();
	}

	/**
	 * 密码加密方式配置
	 */
	@Bean
	public PasswordEncoder passwordEncoder() {
		return new Sm4PasswordEncoder();
	}

+	@Bean
+	public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
+		// 启用跨域资源共享(CORS)支持
+		http.cors()
+			.and()
+			// 禁用跨站请求伪造(CSRF)保护
+			.csrf().disable()
+			// 配置异常处理和身份验证入口点
+			.exceptionHandling().authenticationEntryPoint(authenticationEntryPoint)
+			.and()
+			// 配置会话管理和会话创建策略:不使用会话
+			.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
+			.and()
+			// 配置请求授权规则
+			.authorizeRequests().antMatchers("/api/test/**").permitAll()
+			.antMatchers("/api/auth/**").permitAll()
+			// 所有其他请求需要进行身份验证
+			.anyRequest().authenticated();
+
+		// 配置用户身份验证逻辑
+		http.authenticationProvider(authenticationProvider());
+
+		// 在UsernamePasswordAuthenticationFilter过滤器之前添加TokenFilter
+		http.addFilterBefore(tokenFilter(), UsernamePasswordAuthenticationFilter.class);
+
+		return http.build();
+	}
}

上面的这段代码是最终的 Spring Security 配置类代码。

登录接口

LoginController 代码如下:

import cn.ddcherry.springboot.demo.api.Result;
import cn.ddcherry.springboot.demo.security.model.AuthUser;
import cn.ddcherry.springboot.demo.util.JwtUtil;
import lombok.AllArgsConstructor;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;

import java.util.HashMap;
import java.util.Map;

@RestController
@AllArgsConstructor
@RequestMapping("/api/auth")
public class LoginController {

	private final AuthenticationManager authenticationManager;

	@PostMapping("/login")
	public Result<Map<String, Object>> login(String username, String password) {
		UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(username, password);
		Authentication authentication = authenticationManager.authenticate(authenticationToken);
		SecurityContextHolder.getContext().setAuthentication(authentication);
		String token = JwtUtil.createToken(username, new HashMap<>());
		AuthUser authUser = (AuthUser) authentication.getPrincipal();

		Map<String, Object> resultMap = new HashMap<>(16);
		resultMap.put("token", token);
		resultMap.put("user", authUser);

		return Result.success(resultMap);
	}
}

login() 方法接收两个参数:usernamepassword,表示用户输入的用户名和密码。根据用户名、密码创建一个UsernamePasswordAuthenticationToken对象,然后调用authenticationManager.authenticate(authenticationToken)方法,使用AuthenticationManager对身份验证令牌进行身份验证,得到一个已经通过身份验证的Authentication对象。然后调用SecurityContextHolder.getContext().setAuthentication(authentication)方法,将验证后的Authentication对象存储到SecurityContextHolder中,以便对用户进行身份认证。调用 JWT 工具类生成 token 。调用authentication.getPrincipal()方法获取经过验证的用户信息,强制类型转换为AuthUser类型。统一放在 Map 中返回。

测试

启动项目,使用 ApiPost7 测试登录接口。

认证成功返回结果截图:

Spring Boot | 集成 Spring Security

认证失败返回结果截图:

Spring Boot | 集成 Spring Security