之所以想写这一系列,是因为之前工作过程中使用Spring Security,但当时基于spring-boot 2.3.x,其默认的Spring Security是5.3.x。之后新项目升级到了spring-boot 3.3.0,结果一看Spring Security也升级为6.3.0,关键是其风格和内部一些关键Filter大改,导致在配置同样功能时,多费了些手脚,因此花费了些时间研究新版本的底层原理,这里将一些学习经验分享给大家。
注意:由于框架不同版本改造会有些使用的不同,因此本次系列中使用基本框架是 spring-boo-3.3.0(默认引入的Spring Security是6.3.0),JDK版本使用的是19,所有代码都在spring-security-study项目上:https://github.com/forever1986/spring-security-study.git
目录
- 1 JWT
- 1.1 为什么使用JWT
- 1.2 JWT格式
- 2 Spring Security集成JWT
- 2.1 项目新建
- 2.2 返回异常处理类
- 2.3 自定义基于数据库的用户
- 2.4 Redis配置
- 2.5 自定义登录和登出接口
- 2.6 JWT配置
- 2.7 SecurityConfig的配置
- 2.8 测试
上两章中,我们讲了异常处理和前后端分离,其实都是为了本章做准备的。在系列6中我们说很多业务已经开始摒弃Session,而是采用JWT方式。这一章就从JWT开始讲,到最后实现一个Spring Security+JWT完整方案。
1 JWT
1.1 为什么使用JWT
最开始的前后端分离之间的交互是通过保持状态的Session方式,也就服务器通过生成Session信息保存在服务器中并返回给客户端,客户端(通常是浏览器)将信息存入Cookie里面,每次访问都是拿着Cookie访问(一般浏览器自动完成),服务器通过客户端传输的信息与服务器的Session进行比对验证。因为Session属于有状态的,因此有这么两个大缺点:
- 缺点一:服务器要存储Session信息,如果并发量较大,对于服务器来说需要占用很大内存
- 缺点二:Session信息默认存储在服务器上,这对于集群部署来说就不适用,还需要一个同一个存储的地方,比如Redis(系列6中已经讲过)
基于以上两个缺点,JWT就出现了。
- JWT是一种无状态,也就是认证的时候,服务器给客户端一个token之后,服务器无需保留token
- 由于无状态,因此解决集群部署问题,客户端携带token的访问,在任何服务器都可以鉴权
- JWT包括3部分:header(声明和加密算法)、Payload(有效数据)、Signature(签名,是整个数据的认证信息,保证认证安全性和有效性)。
1.2 JWT格式
JWT 包含三部分数据:
Header:头部,通常头部有两部分信息:
声明类型(typ),这里是JWT
加密算法(alg),自定义
这部分会采用 Base64编码,得到数据。
Payload:载荷,就是设置想要存储的数据,你可以将部分信息放在token中,以下是规范中的内置对象:
- iss (issuer):表示签发人
- exp (expiration time):表示token过期时间
- sub (subject):内容,可以使用json放置用户基本信息
- aud (audience):受众
- nbf (Not Before):生效时间
- iat (Issued At):签发时间
- jti (JWT ID):编号
这部分会采用 Base64编码,得到数据。
Signature:签名,是对数据进行认证的签名。根据前两步的数据+密钥 secret(密码可以是对称或非对称),通过 Header 中配置的加密算法(alg)进行验证。用于验证整个数据完整和可靠性。
以下是一个JWT数据,可以使用在线JWT解析工具解析:
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJqdGkiOiJkMDk0MDBmODY5ZmY0MjgxOGYxOTFjZTE4ZDEwNjU2ZiIsInN1YiI6InVzZXI6NCIsImlzcyI6InNwcmluZy1zZWN1cml0eS1zdHVkeSIsImlhdCI6MTczNDQ4ODkzNywiZXhwIjoxNzM0NDg5MjM3fQ.X9V8DgX8ay8vQRLdA7tV8-MHKJxPv9e2gLqDs3Fpk9s
2 Spring Security集成JWT
代码参考lesson09子模块
本项目说明:
- 本项目依赖mysq和Redis,其中mysq中创建一个数据库,名为spring_security_study,创建用户表t_user(参考系列2)
- 采用基于数据库用户的认证
- 采用前后端分离,会自定义登录和登出接口,同时会屏蔽默认登录登出页面
- 使用JWT生成token认证,因此会屏蔽默认Session管理,由于没有Session管理,因此我们需要自己做token管理。这里使用Redis存储token,同时自定义JwtAuthenticationTokenFilter过滤器,用于在访问时验证token,设置SecurityContext。
2.1 项目新建
1)新建lesson09子模块,其pom引入依赖如下:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!--Spring Boot 提供的 Security 启动器 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>javax.xml.bind</groupId>
<artifactId>jaxb-api</artifactId>
</dependency>
</dependencies>
2)创建com.demo.lesson09包,并创建启动类SecurityLesson09Application
3)在controller包下,新增DemoController,用于测试
@RestController
public class DemoController {
@GetMapping("/demo")
public String demo() {
return"demo";
}
}
4)在resources下新增yaml,配置Mysql、redis和mybatis配置
server:
port: 8080
spring:
# 配置数据源
datasource:
type: com.alibaba.druid.pool.DruidDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://127.0.0.1:3306/spring_security_study?serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf-8&zeroDateTimeBehavior=convertToNull&useSSL=false&allowPublicKeyRetrieval=true
username: root
password: root
druid:
initial-size: 5
min-idle: 5
maxActive: 20
maxWait: 3000
timeBetweenEvictionRunsMillis: 60000
minEvictableIdleTimeMillis: 300000
validationQuery: select 'x'
testWhileIdle: true
testOnBorrow: false
testOnReturn: false
poolPreparedStatements: false
filters: stat,wall,slf4j
connectionProperties: druid.stat.mergeSql=true;druid.stat.slowSqlMillis=5000;socketTimeout=10000;connectTimeout=1200
#redis配置
data:
redis:
host: 127.0.0.1
mybatis-plus:
global-config:
banner: false
mapper-locations: classpath:mappers/*.xml
type-aliases-package: com.demo.lesson05.entity
configuration:
cache-enabled: false
local-cache-scope: statement
2.2 返回异常处理类
1)增加result包和handler包,里面的类参考lesson07子模块,拷贝以下类:
- IResultCode
- Result
- ResultCode
- DemoAccessDeniedHandler : 授权异常处理
- DemoAuthenticationEntryPoint :认证异常处理
2.3 自定义基于数据库的用户
1)不清楚的可以回顾lesson03子模块,这里做了一些小改动
2) 在entity包下新增LoginUserDetails类,自定义类为了能够Redis序列化,之前使用Security默认的User,在Redis反序列化会报错
@Data
@NoArgsConstructor
@AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public class LoginUserDetails implements UserDetails {
private TUser tUser;
@Override
@JsonIgnore
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of();
}
@Override
public String getPassword() {
return tUser.getPassword();
}
@Override
public String getUsername() {
return tUser.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;
}
}
3)定义entity包下的TUser和对应mapper包下的TUserMapper
@Data
public class TUser {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private String username;
private String password;
private String email;
private String phone;
}
@Mapper
public interface TUserMapper {
// 根据用户名,查询用户信息
@Select("select * from t_user where username = #{username}")
TUser selectByUsername(String username);
}
4)在service包下,建立JdbcUserDetailsServiceImpl(实现UserDetailsService),用于基于数据库查询用户
@Service
public class JdbcUserDetailsServiceImpl implements UserDetailsService {
@Autowired
private TUserMapper tUserMapper;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// 查询自己数据库的用户信息
TUser user = tUserMapper.selectByUsername(username);
if(user == null){
throw new UsernameNotFoundException(username);
}
return new LoginUserDetails(user);
}
}
2.4 Redis配置
1)在config包下,新增RedisConfiguration,用于Redis的序列化配置
@Configuration
public class RedisConfiguration {
/**
* 主要做redis配置。redis有2种不同的template(2种的key不能共享)
* 1.StringRedisTemplate:以String作为存储方式:默认使用StringRedisTemplate,其value都是以String方式存储
* 2.RedisTemplate:
* 1)使用默认RedisTemplate时,其value都是根据jdk序列化的方式存储
* 2)自定义Jackson2JsonRedisSerializer序列化,以json格式存储,其key与StringRedisTemplate共享,返回值是LinkedHashMap
* 3)自定义GenericJackson2JsonRedisSerializer序列化,以json格式存储,其key与StringRedisTemplate共享,返回值是原先对象(因为保存了classname)
*/
@Bean
@ConditionalOnMissingBean({RedisTemplate.class})
public RedisTemplate redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate();
template.setConnectionFactory(factory);
//本实例采用GenericJackson2JsonRedisSerializer
ObjectMapper om = new ObjectMapper();
om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
GenericJackson2JsonRedisSerializer jackson2JsonRedisSerializer = new GenericJackson2JsonRedisSerializer();
StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
template.setKeySerializer(stringRedisSerializer);
template.setHashKeySerializer(stringRedisSerializer);
template.setValueSerializer(jackson2JsonRedisSerializer);
template.setHashValueSerializer(jackson2JsonRedisSerializer);
template.afterPropertiesSet();
return template;
}
@Bean
@ConditionalOnMissingBean({StringRedisTemplate.class})
public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory factory) {
StringRedisTemplate template = new StringRedisTemplate();
template.setConnectionFactory(factory);
return template;
}
}
2.5 自定义登录和登出接口
1)在entity包下定义LoginDTO类,用于前后端参数传输
@Data
@AllArgsConstructor
@NoArgsConstructor
public class LoginDTO {
private String username;
private String password;
}
2)在service包下,定义LoginService接口以及实现类LoginServiceImpl,做登录和登出逻辑
public interface LoginService {
Result<String> login(LoginDTO loginDTO);
Result<String> logout();
}
@Service
public class LoginServiceImpl implements LoginService {
// 注入AuthenticationManagerBuilder,用于获得authenticationManager
@Autowired
private AuthenticationManagerBuilder authenticationManagerBuilder;
@Autowired
private RedisTemplate redisTemplate;
private static String PRE_KEY = "user:";
@Override
public Result<String> login(LoginDTO loginDTO) {
String username = loginDTO.getUsername();
String password = loginDTO.getPassword();
UsernamePasswordAuthenticationToken authenticationToken = UsernamePasswordAuthenticationToken.unauthenticated(username, password);
try {
Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
if(authentication!=null && authentication.isAuthenticated()){
SecurityContextHolder.getContext().setAuthentication(authentication);
LoginUserDetails user = (LoginUserDetails)authentication.getPrincipal();
String subject = PRE_KEY + user.getTUser().getId();
String token = JwtUtil.createToken(subject, 1000*60*5L);
redisTemplate.opsForValue().set(subject, user, 1000*60*5L, TimeUnit.MILLISECONDS);
return Result.success(token);
}
}catch (AuthenticationException e){
return Result.failed(e.getLocalizedMessage());
}
catch (Exception e){
e.printStackTrace();
}
return Result.failed("认证失败");
}
@Override
public Result<String> logout() {
if(SecurityContextHolder.getContext().getAuthentication()!=null){
LoginUserDetails user = (LoginUserDetails)SecurityContextHolder.getContext().getAuthentication().getPrincipal();
if(user!=null){
String key = PRE_KEY + user.getTUser().getId();
redisTemplate.delete(key);
}else {
return Result.failed("登出失败,用户不存在");
}
}
return Result.success("登出成功");
}
}
3)在controller包下,新增LoginController ,对外发布登录和登出
@RestController
public class LoginController {
@Autowired
private LoginService loginService;
@PostMapping("/login")
public Result<String> login(@RequestBody LoginDTO loginDTO) {
return loginService.login(loginDTO);
}
@PostMapping("/logout")
public Result<String> logout() {
return loginService.logout();
}
}
2.6 JWT配置
1)在utils包下面新建JwtUtil工具类,用来生成token和验证token。
这里采用HS256对称加密,密钥我们这里就直接写在代码中,有兴趣朋友可以改为如AES非对称加密算法
/**
* JWT工具类
*/
public class JwtUtil {
//有效期为
public static final Long JWT_TTL = 60 * 60 * 1000L; // 60 * 60 *1000 一个小时
//设置秘钥明文
public static final String JWT_KEY = "moo";
public static String getUUID(){
return UUID.randomUUID().toString().replaceAll("-", "");
}
/**
* 创建token
*/
public static String createToken(String subject) {
return getJwtBuilder(subject, null, getUUID());// 设置过期时间
}
/**
* 创建token
*/
public static String createToken(String subject, Long ttlMillis) {
return getJwtBuilder(subject, ttlMillis, getUUID());// 设置过期时间
}
/**
* 创建token
*/
public static String createToken(String id, String subject, Long ttlMillis) {
return getJwtBuilder(subject, ttlMillis, id);// 设置过期时间
}
/**
* 解析token
*/
public static String parseJWT(String token) throws Exception {
SecretKey secretKey = generalKey();
return Jwts.parser()
.setSigningKey(secretKey)
.parseClaimsJws(token)
.getBody().getSubject();
}
private static String getJwtBuilder(String subject, Long ttlMillis, String uuid) {
SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
SecretKey secretKey = generalKey();
long nowMillis = System.currentTimeMillis();
Date now = new Date(nowMillis);
if(ttlMillis==null){
ttlMillis=JwtUtil.JWT_TTL;
}
long expMillis = nowMillis + ttlMillis;
Date expDate = new Date(expMillis);
return Jwts.builder()
.setHeaderParam(Header.TYPE, Header.JWT_TYPE)
.setId(uuid) //唯一的ID
.setSubject(subject) // 主题 可以是JSON数据
.setIssuer("spring-security-study") // 签发者
.setIssuedAt(now) // 签发时间
.signWith(signatureAlgorithm, secretKey) //使用HS256对称加密算法签名, 第二个参数为秘钥
.setExpiration(expDate)
.compact();
}
/**
* 生成加密后的秘钥 secretKey
*/
private static SecretKey generalKey() {
byte[] encodedKey = Base64.getDecoder().decode(JwtUtil.JWT_KEY);
SecretKey key = new SecretKeySpec(encodedKey, 0, encodedKey.length, "AES");
return key;
}
}
2)定义自己的Filter(JwtAuthenticationTokenFilter),通过该Filter进行token验证,并模拟设置SecurityContext。
我们知道访问资源最终在AuthorizationFilter过滤器会验证你的权限,是通过获取SecurityContext得到登录信息
但是使用前后端分离,且不使用Session管理,这就意味着登录之后,第二次请求Security会认为没有登录(这个可以去看看SecurityContextHolderFilter,会发现不再使用HttpSessionSecurityContextRepository)这也就意味着SecurityContext不会被设置
从Security Context原理中我们知道每个请求都是通过Filter过滤链验证,那么只需要我们在链中加入自己的认证,并设置SecurityContext,就能够达到一样的效果。
注意:
- 这里JwtAuthenticationTokenFilter 继承OncePerRequestFilter 而不是GenericFilterBean,只是为了保证每次请求只走一次JwtAuthenticationTokenFilter
- JWT验证失败返回BadCredentialsException异常,这个在自定义异常处理讲过其原理,通过抛出BadCredentialsException可以被exceptionHandling处理
@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
@Autowired
private RedisTemplate redisTemplate;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
// 过滤login接口
if("/login".equals(request.getRequestURI())){
filterChain.doFilter(request, response);
return;
}
// 从请求头获取token
String token = request.getHeader("access_token");
// 检查获取到的token是否为空或空白字符串。(判断给定的字符串是否包含文本)
if (!StringUtils.hasText(token)) {
// 如果token为空,则直接放行请求到下一个过滤器,不做进一步处理并结束当前方法,不继续执行下面代码。
filterChain.doFilter(request, response);
return;
}
// 解析token
String userAccount;
try {
userAccount = JwtUtil.parseJWT(token);
} catch (Exception e) {
e.printStackTrace();
throw new BadCredentialsException("token非法");
}
// 临时缓存中 获取 键 对应 数据
Object object = redisTemplate.opsForValue().get(userAccount);
LoginUserDetails loginUser = (LoginUserDetails)object;
if (Objects.isNull(loginUser)) {
throw new BadCredentialsException("用户未登录");
}
// 将用户信息存入 SecurityConText
// UsernamePasswordAuthenticationToken 存储用户名 密码 权限的集合
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser, null, null);
// SecurityContextHolder是Spring Security用来存储当前线程安全的认证信息的容器。
// 将用户名 密码 权限的集合存入SecurityContextHolder
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
// 放行
filterChain.doFilter(request, response);
}
}
2.7 SecurityConfig的配置
1)在config包下,配置SecurityConfig
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Autowired
private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(auth->auth
//允许/login访问
.requestMatchers("/login").permitAll().anyRequest().authenticated())
// 异常处理
.exceptionHandling(handling-> handling
.accessDeniedHandler(new DemoAccessDeniedHandler())
.authenticationEntryPoint(new DemoAuthenticationEntryPoint()))
// 禁用csrf,因为登录和登出是post请求,csrf会屏蔽掉post请求
.csrf(AbstractHttpConfigurer::disable)
// 添加到过滤器链路中,确保在AuthorizationFilter过滤器之前
.addFilterBefore(jwtAuthenticationTokenFilter, AuthorizationFilter.class)
// 由于采用token方式认证,因此可以关闭session管理
.sessionManagement(SessionManagementConfigurer::disable)
// 禁用原来登录页面
.formLogin(AbstractHttpConfigurer::disable)
// 禁用系统原有的登出
.logout(LogoutConfigurer::disable);
return http.build();
}
}
2.8 测试
1)直接访问:http://127.0.0.1:8080/demo 会给出一个无权限的返回Json数据
2)访问登录接口:http://127.0.0.1:8080/login
3)再次访问:http://127.0.0.1:8080/demo ,注意加入第二步中的token
4)访问登出:http://127.0.0.1:8080/logout,注意加入第二步中的token
5)再次:http://127.0.0.1:8080/demo ,注意加入第二步中的token
结语:至此,我们就完整实现了基于JWT的Spring Security认证过程。这实际上已经接近生产使用情况。到目前为止,我们了解了认证、授权、前后端分离、JWT等内容,接下来,我们将会讲述Spring Security更高级的一些功能。