Spring Security入门指南
强烈推荐先观看视频教程
https://www.bilibili.com/video/BV1mm4y1X7Hc/
Spring Security基础入门
要快速上手Spring Security,只需在pom.xml文件中添加以下依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
然后启动Spring Boot应用即可,因为Spring Security已经内置了默认实现。
注意:默认用户名为user,密码会在控制台打印出来(如果控制台内容已清空,需要重新启动应用)
核心原理概述
首先需要理解,Spring Security的核心功能是认证与授权:
- 认证:验证用户身份是否属于当前系统,通常是通过查询数据库确认用户是否存在
- 授权:在认证通过后进行,用于规范用户可以执行的操作和禁止的操作(注意:这里指的是验证用户是否具有特定操作权限,而非授予权限)
登录验证流程图
初次登录时,系统会将传入的用户名密码与数据库中存储的数据进行比对。如果找到匹配的用户信息,系统会生成一个JWT(可理解为加密的用户标识)返回给用户。此后,用户的每次请求只需携带此JWT,无需重复输入用户名密码。
前文提到,Spring Security已有完整的默认实现,但我们通常需要自定义部分功能,例如:
- 使用自己的登录页面替代默认首页
- 从数据库而非内存验证用户凭据
因此,我们需要重写Spring Security的部分默认实现。关键问题是:需要重写哪些方法?让我们继续深入。
Spring Security本质上是一个过滤器链,简化结构如下:
众多过滤器中,以下三个特别重要:
UsernamePasswordAuthenticationFilter: 处理登录页面的用户名密码提交请求
ExceptionTranslationFilter: 处理过滤器链中抛出的AccessDeniedException和AuthenticationException异常
FilterSecurityInterceptor: 负责权限验证的过滤器,从SecurityContextHolder获取相关信息
现在我们重点关注UsernamePasswordAuthenticationFilter过滤器:
结合上述图表和Spring Security的不足之处,我们需要实现以下几个方面:
- 自定义登录接口(本质仍是使用Spring Security内置的authenticate方法)
- 自定义UserDetailsServiceImpl类(实现UserDetailsService接口),用于数据库查询
- 修改密码加密方式
- 添加自定义过滤器,在UsernamePasswordAuthenticationFilter之前处理JWT验证
- TODO
实践实现
基于MySQL数据库的用户查询实现
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.example.entity.LoginUser;
import com.example.entity.User;
import com.example.mapper.UserMapper;
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 java.util.ArrayList;
import java.util.List;
import java.util.Objects;
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
private UserMapper userMapper;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(User::getUserName, username);
User user = userMapper.selectOne(queryWrapper);
if (Objects.isNull(user)) {
throw new RuntimeException("用户名或密码错误");
}
// TODO: 根据用户查询权限信息并添加到LoginUser中
List<String> permissions = new ArrayList<>(List.of("test"));
return new LoginUser(user, permissions);
}
}
此代码中,loadUserByUsername方法是核心。我们通过创建LoginUser类继承UserDetails来实现自定义属性封装:
import com.alibaba.fastjson.annotation.JSONField;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.Collection;
import java.util.List;
import java.util.stream.Collectors;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class LoginUser implements UserDetails {
private User user;
// 存储权限信息
private List<String> permissions;
public LoginUser(User user, List<String> permissions) {
this.user = user;
this.permissions = permissions;
}
// 存储SpringSecurity所需的权限信息集合
@JSONField(serialize = false)
private List<GrantedAuthority> authorities;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
if (authorities != null) {
return authorities;
}
// 将permissions中的字符串权限信息转换为GrantedAuthority对象
authorities = permissions.stream()
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
return authorities;
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getUserName();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
@Data
@TableName("user")
public class User {
@TableId(type = IdType.AUTO)
private Integer id;
private String userName;
private String password;
}
自定义密码验证方式
@Component
public class SecurityConfig extends WebSecurityConfigurerAdapter {
// 注入AuthenticationManager
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}
// 修改密码加密方式
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
// 禁用CSRF
http.csrf().disable()
// 不需要session
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
.authorizeRequests()
// 相关路径可匿名访问
.antMatchers("/test/login", "/test/register").anonymous()
// 其余路径需要认证
.anyRequest().authenticated();
}
}
因此,数据库中的密码需要使用BCrypt加密存储。(登录时的密码验证由Spring Security内部实现,我们只需指定加密方式)
关于BCryptPasswordEncoder的更多信息,可参考:https://www.cnblogs.com/ReturnOfTheKing/p/17271746.html
自定义用户登录页面(本质是重写UsernamePasswordAuthenticationFilter)
import com.alibaba.fastjson.JSONObject;
import com.example.entity.User;
import com.example.service.IndexService;
import com.example.util.HttpUtil;
import com.example.util.ResultData;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.HashMap;
@Slf4j
@RestController
@RequestMapping("/index")
public class IndexController {
@Autowired
private IndexService indexService;
@PostMapping("/login")
public ResultData login(User user){
return indexService.login(user);
}
@GetMapping("/logout")
public ResultData logout(){
return indexService.logout();
}
}
import com.example.entity.User;
import com.example.util.ResultData;
public interface IndexService {
ResultData login(User user);
ResultData logout();
}
import com.example.entity.LoginUser;
import com.example.entity.User;
import com.example.service.IndexService;
import com.example.util.JwtUtil;
import com.example.util.RedisCache;
import com.example.util.ResultData;
import org.springframework.beans.factory.annotation.Autowired;
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.stereotype.Service;
import java.util.HashMap;
import java.util.Objects;
@Service
public class IndexServiceImpl implements IndexService {
@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private RedisCache redisCache;
@Override
public ResultData login(User user) {
// 用户名密码校验
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(user.getUserName(), user.getPassword());
Authentication authenticate = authenticationManager.authenticate(authenticationToken);
if (Objects.isNull(authenticate)) {
return ResultData.fail("用户名和密码错误");
}
// 使用userId生成token
LoginUser loginUser = (LoginUser)authenticate.getPrincipal();
String userId = loginUser.getUser().getId().toString();
String jwt = JwtUtil.createJWT(userId);
// 存储用户信息到Redis
redisCache.setCacheObject("login:" + userId, loginUser);
redisCache.expire("login:" + userId, 60);
// 把token响应给前端
HashMap<String, String> tokenMap = new HashMap<>();
tokenMap.put("token", jwt);
return ResultData.success(tokenMap);
}
@Override
public ResultData logout() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
LoginUser loginUser = (LoginUser) authentication.getPrincipal();
Integer userId = loginUser.getUser().getId();
redisCache.deleteObject("login:" + userId);
return new ResultData(200, "退出成功", null);
}
}
如前所述,我们本质上是使用AuthenticationManager的authenticate方法进行用户认证,因此需要在SecurityConfig中配置将AuthenticationManager注入容器:
需要强调的是,过滤器链是层层调用的,每个过滤器执行完成后会继续调用下一个,形成环路。
前面提到,UsernamePasswordAuthenticationFilter负责权限验证。如何完成这一验证?
SecurityContextHolder的作用:保存系统当前的安全上下文信息,包括当前登录用户的信息。项目中通常通过此方法获取当前登录用户。
用户首次登录成功后,服务器会将JWT返回给用户。当用户携带JWT进行后续请求时(即JWT过滤器认证时),Spring Security框架会将当前用户信息存入SecurityContextHolder中。
需要注意的是,每个请求对应的SecurityContextHolder是不同的(基于ThreadLocal,一次请求由一个线程处理,因此每次请求都会独立存储)。
核心理解:所有过滤器都会先检查SecurityContextHolder中是否有值,若有则直接放行。
这一理解基本正确:
- Spring Security判断是否认证的依据是SecurityContextHolder中的Authentication是否已设置且为已认证状态(isAuthenticated() == true)
- JWT过滤器通过后,其他认证过滤器会被跳过(因为已认证),但授权等其他安全过滤器仍会执行。整个机制依赖于SecurityContextHolder中的认证状态
JWT过滤器实现
import com.example.entity.LoginUser;
import com.example.util.JwtUtil;
import com.example.util.RedisCache;
import io.jsonwebtoken.Claims;
import lombok.SneakyThrows;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Objects;
@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
@Autowired
private RedisCache redisCache;
@SneakyThrows
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
// 获取请求头中的token
String token = request.getHeader("token");
if (!StringUtils.hasText(token)) {
// 没有token则放行,由Spring Security的其他过滤器拦截
filterChain.doFilter(request, response);
return;
}
// 解析token
Claims claims = JwtUtil.parseJWT(token);
String userId = claims.getSubject();
String redisKey = "login:" + userId;
LoginUser loginUser = redisCache.getCacheObject(redisKey);
if (Objects.isNull(loginUser)) {
throw new RuntimeException("用户未登录");
}
// 存入SecurityContextHolder
// 注意三参数构造函数会设置已授权标志位
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());
// 设置用户信息,方便后续读取
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
filterChain.doFilter(request, response);
}
}
此处需要特别注意UsernamePasswordAuthenticationToken的构造函数参数数量;三参数构造函数会内置一个已授权的标志位。
记得将此过滤器添加到Spring Security框架中:
// SecurityConfig配置类
httpSecurity.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
最后,还需注意以下Spring Security配置:
@Component
public class SecurityConfig extends WebSecurityConfigurerAdapter {
// 注入AuthenticationManager
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}
// 修改密码加密方式
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
// 禁用CSRF
http.csrf().disable()
// 不需要session
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
.authorizeRequests()
// 相关路径可匿名访问
.antMatchers("/test/login", "/test/register").anonymous()
// 其余路径需要认证
.anyRequest().authenticated();
}
}
启用注解方式权限校验
- 在SecurityConfig类上添加@EnableGlobalMethodSecurity注解
- 在目标方法上添加所需校验的权限注解
Redis自定义序列化方式
注意:不要使用阿里巴巴提供的FastJson序列化方法,否则反序列化会出错
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.serializer.SerializerFeature;
import com.fasterxml.jackson.databind.JavaType;
import com.fasterxml.jackson.databind.type.TypeFactory;
import org.springframework.data.redis.serializer.RedisSerializer;
import org.springframework.data.redis.serializer.SerializationException;
import com.alibaba.fastjson.parser.ParserConfig;
import java.nio.charset.Charset;
public class FastJsonRedisSerializer<T> implements RedisSerializer<T> {
public static final Charset DEFAULT_CHARSET = Charset.forName("UTF-8");
private Class<T> clazz;
static {
ParserConfig.getGlobalInstance().setAutoTypeSupport(true);
}
public FastJsonRedisSerializer(Class<T> clazz) {
this.clazz = clazz;
}
@Override
public byte[] serialize(T t) throws SerializationException {
if (t == null) {
return new byte[0];
}
return JSON.toJSONString(t, SerializerFeature.WriteClassName).getBytes(DEFAULT_CHARSET);
}
@Override
public T deserialize(byte[] bytes) throws SerializationException {
if (bytes == null || bytes.length <= 0) {
return null;
}
String str = new String(bytes, DEFAULT_CHARSET);
return JSON.parseObject(str, clazz);
}
protected JavaType getJavaType(Class<?> clazz) {
return TypeFactory.defaultInstance().constructType(clazz);
}
}
import org.springframework.context.annotation.Bean;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import org.springframework.stereotype.Component;
@Component
public class RedisConfig {
@Bean
public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
RedisTemplate<Object, Object> template = new RedisTemplate<>();
template.setConnectionFactory(redisConnectionFactory);
// 使用自定义序列化方式
FastJsonRedisSerializer<Object> fastJsonRedisSerializer =
new FastJsonRedisSerializer<>(Object.class);
// key的序列化采用StringRedisSerializer
template.setKeySerializer(new StringRedisSerializer());
template.setHashKeySerializer(new StringRedisSerializer());
// value值的序列化采用fastJsonRedisSerializer
template.setValueSerializer(fastJsonRedisSerializer);
template.setHashValueSerializer(fastJsonRedisSerializer);
return template;
}
}
基于角色的访问控制(RBAC)模型
本质:直接为每个用户分配权限,当用户和权限数量增多时操作会变得繁琐。因此提出为用户分配角色,为角色分配权限的解决方案:
JWT相关概念
JWT本质是对用户信息进行加密,类似于通行证或信物,全称为JSON Web Token。它通过数字签名方式,以JSON对象为载体进行数据传输。
JWT由三部分组成:
- Header:头部信息
- Payload:载荷(存放需要加密的信息)
- Signature:签名
代码实现
添加依赖:
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
生成JWT
public String createJwt(String userName, String passWd) {
Map<String, Object> claims = new HashMap<>();
claims.put("userName", userName);
claims.put("passWd", passWd);
return Jwts.builder()
// 设置claims
.setClaims(claims)
// 设置签名算法和密钥
.signWith(SignatureAlgorithm.HS256, signature)
// 设置过期时间
.setExpiration(new Date(System.currentTimeMillis() + expire))
.compact();
}
解析JWT
public Claims parserJwt(String token) {
return Jwts.parser()
.setSigningKey(signature)
.parseClaimsJws(token)
.getBody();
}
完整工具类示例
package com.example.utils;
import io.jsonwebtoken.*;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
/**
* JWT工具类
*/
@Component
public class JwtUtil {
@Value("${jwt.secret}")
private String signature;
@Value("${jwt.expire}")
private Long expire;
/**
* 生成JWT
* @param userName 用户名
* @param passWd 密码
* @return JWT字符串
*/
public String createJwt(String userName, String passWd) {
Map<String, Object> claims = new HashMap<>();
claims.put("userName", userName);
claims.put("passWd", passWd);
return Jwts.builder()
// 设置claims
.setClaims(claims)
// 设置签名算法和密钥
.signWith(SignatureAlgorithm.HS256, signature)
// 设置过期时间
.setExpiration(new Date(System.currentTimeMillis() + expire))
.compact();
}
/**
* 解析JWT
* @param token JWT字符串
* @return Claims对象
*/
public Claims parserJwt(String token) {
return Jwts.parser()
.setSigningKey(signature)
.parseClaimsJws(token)
.getBody();
}
}
参考资源
[1] Spring Boot Security默认用户名和密码 [2] https://blog.csdn.net/qq_45847507/article/details/126681110 [3] 配置类相关函数介绍 [4] UsernamePasswordAuthenticationToken函数介绍