整合SpringSecurity——自定义登录流程(SpringSecurity + JWT + Redis)
前言
自定义登录流程是整合 SpringSecurity 开发必不可少的一步。上篇文章我们介绍了整合数据库的登录,本篇文章在此基础上整理了 SpringSecurity + JWT + Redis 的登录流程。
整体流程图
登录及认证的整体流程如下图:
依赖
除了 SpringSecurity 的相关依赖外,还需要 Redis 和 hutool (强大且全面的工具包,本篇文章中 JWT 的相关类也来自该包) 的依赖。
<!-- springboot整合的redis依赖,里面集成了 spring-data-redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.13</version>
</dependency>
配置
在整个流程中,我们用到了 SpringSecurity + JWT + Redis ,需要配置的是SpringSecurity 和 Redis。
SpringSecurity 配置
@Configuration
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {
@Resource
private JwtFilter jwtFilter;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
/**
* 注入 AuthenticationManager 对象,用于调用认证方法
*
* @return
* @throws Exception
*/
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
/**
* 放行登录接口
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
http
// 关闭csrf
.csrf().disable()
// 不通过Session获取SecurityContext
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
// 把jwt过滤器放到UsernamePasswordAuthenticationFilter前,便于先判断用户是否登录,再决定是否登录
.authorizeRequests()
// 对于登录接口允许匿名访问
.antMatchers("/user/login").anonymous()
// 除上面外的所有请求全部需要鉴权认证
.anyRequest().authenticated();
http.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
}
}
在配置类中,我们注入了 AuthenticationManager 对象。这个对象在上篇文章我们提到过,用于调用认证方法。但是在父类 WebSecurityConfigurerAdapter 中并没有将它注入到容器中,而我们又需要在自己的登录接口中调用它,因此需要重写 authenticationManagerBean 方法并将返回的对象注入到容器中。 configure(HttpSecurity http) 方法用于配置路由,只开放登录接口,其他接口都需要认证。
Redis配置
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(LettuceConnectionFactory lettuceConnectionFactory) {
// 设置序列化
Jackson2JsonRedisSerializer<Object> jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer<Object>(
Object.class);
ObjectMapper om = new ObjectMapper();
om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
om.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
om.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
jackson2JsonRedisSerializer.setObjectMapper(om);
// 配置redisTemplate
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<String, Object>();
redisTemplate.setConnectionFactory(lettuceConnectionFactory);
RedisSerializer<?> stringSerializer = new StringRedisSerializer();
redisTemplate.setKeySerializer(stringSerializer);// key序列化
redisTemplate.setValueSerializer(jackson2JsonRedisSerializer);// value序列化
redisTemplate.setHashKeySerializer(stringSerializer);// Hash key序列化
redisTemplate.setHashValueSerializer(jackson2JsonRedisSerializer);// Hash value序列化
redisTemplate.afterPropertiesSet();
return redisTemplate;
}
}
在 Redis 配置中大部分是常规的序列化配置,特殊的是 ObjectMapper ,这个配置是为因为存储在 Redis 的实体类中除了成员变量的 get 方法外,其他方法不能有返回值,否则会导致反序列化异常,而 ObjectMapper 就是为了解决这个问题。 反序列化异常的问题可以通过下面的代码验证
@Test
public void test(){
RedisTestEntity entity = new RedisTestEntity();
entity.setName("111");
redisTemplate.opsForValue().set("name", entity);
}
@Test
public void get(){
Object name = redisTemplate.opsForValue().get("name");
System.out.println(name);
}
@Data
public class RedisTestEntity {
private String name;
public Integer requireAge(){
return 18;
}
}
定义登录接口
参数的接收
@Data
public class UserLogin {
private String username;
private String password;
}
controller部分
@PostMapping("/login")
public R<String> login(@RequestBody UserLogin userLogin){
String jwt = securityService.login(userLogin);
return R.success().data(jwt);
}
controller 只是负责匹配路由和返回数据,业务通过 service 的相关方法完成,因此 controller 中没有太多代码
service部分
@Resource
private AuthenticationManager manager;
@Resource
private RedisTemplate redisTemplate;
@Override
public String login(UserLogin userLogin) {
Authentication userAuthentication = new UsernamePasswordAuthenticationToken(userLogin.getUsername(), userLogin.getPassword());
Authentication authenticate = manager.authenticate(userAuthentication);
// 如果认证成功则进入生成token的逻辑
if (authenticate.isAuthenticated()) {
LoginUser loginUser = (LoginUser) authenticate.getPrincipal();
SysUser user = loginUser.getUser();
// 将登录成功的对象存入redis
redisTemplate.opsForValue().set(KeyUtil.getLoginUserKey(user.getUserId()), loginUser);
// 生成token
String token = JWT
.create()
.setPayload(userLoginId, user.getUserId())
// (签发时间)---------(生效时间)---------(当前时间)---------(失效时间)
.setIssuedAt(new Date())
// 过期时间七天
.setExpiresAt(new Date(System.currentTimeMillis() + DateUnit.WEEK.getMillis()))
// // 设置HS256为加密算法,以用户的密码为盐(密钥)
.setSigner("HMD5", salt.getBytes(StandardCharsets.UTF_8))
.sign();
return token;
}
throw new RuntimeException("用户名或密码错误");
}
service 完成了登录的主要流程,包括:
- 调用 AuthenticationManager 实例的 authenticate 方法对用户的账号密码进行验证,该方法会调用到我们上篇文章自定义的方法,通过查询数据库的数据完成校验
- 如果校验成功,则将用户信息存入 Redis 并生成相应 token ,同时将用户 id 存入 token 的荷载中,失败则抛出异常
登录过滤器
完成登录后,在以后的每次请求都需要在请求头中带上 token 以便于认证,认证操作通过过滤器完成(关于 jwt 的具体知识本篇文章不做探讨,不熟悉请自行查阅相关资料)。
@Component
public class JwtFilter extends OncePerRequestFilter {
@Resource
private RedisTemplate redisTemplate;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String token = request.getHeader("token");
// 没有token,去走登录流程
if (StrUtil.isBlank(token)) {
filterChain.doFilter(request, response);
return;
}
// token不能为空
JWT jwt = JWTUtil.parseToken(token);
// 验证token是否合法
HMacJWTSigner singer = new HMacJWTSigner(AlgorithmUtil.getAlgorithm("HMD5"), salt.getBytes(StandardCharsets.UTF_8));
boolean common = jwt.verify(singer);
// 验证时间,失败会抛出异常
try {
JWTValidator.of(jwt).validateDate(DateUtil.date());
} catch (ValidateException exception) {
throw new TokenInvalidException("token异常");
}
if (common){
NumberWithFormat userIdObj = (NumberWithFormat)jwt.getPayload(userLoginId);
Integer userId = userIdObj.intValue();
LoginUser loginUser = (LoginUser) redisTemplate.opsForValue().get(KeyUtil.getLoginUserKey(userId));
// 如果用户不存在,说明token异常
if (loginUser == null) {
throw new TokenInvalidException("token异常");
}
// 将用户信息存入 SecurityContextHolder ,以便本次在请求中使用
UsernamePasswordAuthenticationToken authenticationLoginUser = new UsernamePasswordAuthenticationToken(loginUser, null, null);
SecurityContextHolder.getContext().setAuthentication(authenticationLoginUser);
filterChain.doFilter(request, response);
}
}
}
我们自定义的过滤器类继承了 OncePerRequestFilter 类并重写了 doFilterInternal 方法,然后在 SpringSecurity 的配置类中将其添加到 UsernamePasswordAuthenticationFilter 前面(校验账号密码之前),对应配置类中的如下代码
http.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
在过滤器中,我们首先校验了 token 是否存在。如果不存在则直接放行,然后在后续校验权限时会被自动拦截下来。存在则验证 token 的合法性,通过后从 token 拿到用户 id ,从 Redis 中获取到用户信息,如果不存在说明用户的登录状态异常(可能是退出了登录状态,后文会说)。从 Redis 中取到用户信息后,就可以将用户信息存到 SecurityContextHolder 中,方便后续进行认证、授权以及使用。最后放行,执行后续操作。
退出登录
在做完前面的操作后,退出登录的操作就很简单了。
@PostMapping("/logout")
public R<String> logout(){
Boolean delete = securityService.logout();
return R.success().data(delete);
}
@Override
public Boolean logout() {
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
Integer userId = loginUser.getUser().getUserId();
Boolean delete = redisTemplate.delete(KeyUtil.getLoginUserKey(userId));
return delete;
}
controller 和 service 的代码分别如图。 在 service 中我们删除了 Redis 中的用户信息,这也就解释了为什么过滤器中会存在 token 合法但是用户信息不存在的情况(退出登录),当然也可能有其它的特殊情况。
结语
本篇文章就到这了,下一篇应该是关于授权的内容,我们下次再见。
转载自:https://juejin.cn/post/7228960688164896826