likes
comments
collection
share

整合SpringSecurity——自定义登录流程(SpringSecurity + JWT + Redis)

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

前言

自定义登录流程是整合 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 完成了登录的主要流程,包括:

  1. 调用 AuthenticationManager 实例的 authenticate 方法对用户的账号密码进行验证,该方法会调用到我们上篇文章自定义的方法,通过查询数据库的数据完成校验
  2. 如果校验成功,则将用户信息存入 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 合法但是用户信息不存在的情况(退出登录),当然也可能有其它的特殊情况。

结语

本篇文章就到这了,下一篇应该是关于授权的内容,我们下次再见。