Spring Security系列之四 前后端分离项目用jwt做认证

x33g5p2x  于2021-07-11 转载在 Java  
字(12.8k)|赞(0)|评价(0)|浏览(702)

经过上面的自定义短信登录认证,想必大家对自定义登录实现已经比较熟悉了。

我们想要实现一个自定义登录组件,无非就分为五步:

  1. 自定义filter,获取request中的参数,并组装成一个token
  2. 自定义provider,解析传过来的token中的参数,校验身份是否正确
  3. 创建登录成功和登录失败的回调
  4. 创建一个配置类,将上面的几个类组合起来
  5. 将配置类添加到spring security配置中

记住上面的五个步骤,实现任意的自定义登录就没有问题了,接下来我们就按照上面的顺序,实现jwt方式的登录。

登录认证

自定义登录filter

前后端分离项目传递参数一般都使用json方法,不使用默认的表单登录,所以我们需要自定义一个filter,从request中获取用户名密码:

public class JwtAuthenticationLoginFilter extends AbstractAuthenticationProcessingFilter {

    public JwtAuthenticationLoginFilter() {
        super(new AntPathRequestMatcher("/jwtLogin", "POST"));
    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException {
        //从json中获取username和password
        String body = StreamUtils.copyToString(request.getInputStream(), StandardCharsets.UTF_8);
        String username = "";
        String password = "";
        if(StringUtils.hasText(body)) {
            JsonObject jsonObject = new JsonParser().parse(body).getAsJsonObject();
            username = jsonObject.get("username").getAsString().trim();
            password = jsonObject.get("password").getAsString().trim();
        }
        //封装到token中提交
        UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(
                username, password);
        return getAuthenticationManager().authenticate(authRequest);
    }
}

自定义provider

其实仔细想一下,我们这里使用的还是用户名密码登录,没有说要用其他参数校验,所以为什么不直接使用系统提供的DaoAuthenticationProvider呢,所以就不需要再自定义实现了~~(并不是因为懒)~~,直接跳过~

登录结果回调

不论是登录成功还是失败,都需要传给前端json格式的数据,而不是像之前一样后端自己做重定向。

如果说用户身份验证通过了,那么我们需要生成一个token放入到header中,所以对于登录成功的回调:

@Slf4j
@Component
public class JwtAuthSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler {
    @Autowired
    private UserService userService;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws ServletException, IOException {
        User user = (User) authentication.getPrincipal();
        String generateToken = userService.generateToken(user);
        response.setHeader("jwt-token",generateToken);
        log.info("生成token并设置给header: {}",generateToken);
        //登录成功将用户信息返回
        PrintWriter out = response.getWriter();
        user.setPassword("");
        String userJson = new Gson().toJson(user, User.class);
        out.write(userJson);
        out.flush();
        out.close();
    }
}

登录失败也要返回失败原因:

@Slf4j
@Component
public class JwtAuthFailHandler extends SimpleUrlAuthenticationFailureHandler {

    @Data
    @AllArgsConstructor
    private static class ExceptionResult{
        private int code;
        private String message;

    }
    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse resp, AuthenticationException e) throws IOException, ServletException {
        resp.setContentType("application/json;charset=utf-8");
        log.error("用户登录失败, {}",e.getMessage());
        PrintWriter out = resp.getWriter();
        ExceptionResult respBean = new ExceptionResult(HttpStatus.UNAUTHORIZED.value(), e.getMessage());
        resp.setStatus(HttpStatus.UNAUTHORIZED.value());
        if (e instanceof LockedException) {
            respBean.setMessage("账户被锁定,请联系管理员!");
        } else if (e instanceof CredentialsExpiredException) {
            respBean.setMessage("密码过期,请联系管理员!");
        } else if (e instanceof AccountExpiredException) {
            respBean.setMessage("账户过期,请联系管理员!");
        } else if (e instanceof DisabledException) {
            respBean.setMessage("账户被禁用,请联系管理员!");
        } else if (e instanceof BadCredentialsException) {
            respBean.setMessage("用户名或者密码输入错误,请重新输入!");
        }
        String json = new Gson().toJson(respBean,ExceptionResult.class);
        out.write(json);
        out.flush();
        out.close();
    }
}

登录配置类

使用系统提供的DaoAuthenticationProvider,需要将UserDetailsServicePasswordEncoder手动set一下,别问为什么,问就是启动系统会报错:

@Component
public class JwtAuthenticationSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
    @Autowired
    private JwtAuthSuccessHandler jwtAuthSuccessHandler;
    @Autowired
    private JwtAuthFailHandler jwtAuthFailHandler;
    @Autowired
    private UserService userService;
    @Autowired
    private PasswordEncoder passwordEncoder;

    @Override
    public void configure(HttpSecurity builder) throws Exception {
        JwtAuthenticationLoginFilter filter = new JwtAuthenticationLoginFilter();
        filter.setAuthenticationManager(builder.getSharedObject(AuthenticationManager.class));
        filter.setAuthenticationSuccessHandler(jwtAuthSuccessHandler);
        filter.setAuthenticationFailureHandler(jwtAuthFailHandler);
        DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
        provider.setUserDetailsService(userService);
        provider.setPasswordEncoder(passwordEncoder);
        builder.authenticationProvider(provider);
        builder.addFilterAfter(filter, UsernamePasswordAuthenticationFilter.class);
    }
}

添加登录认证配置到security

当当当当,上面的组件已经搞定了,最后配置一下我们的security:

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    private SmsAuthenticationSecurityConfig smsAuthenticationSecurityConfig;
    @Autowired
    private JwtAuthenticationSecurityConfig jwtAuthenticationSecurityConfig;


    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.formLogin()
                .disable()
                .apply(smsAuthenticationSecurityConfig)
                .and()
                .apply(jwtAuthenticationSecurityConfig)
                .and()
                // 设置URL的授权
                .authorizeRequests()
                // 这里需要将登录页面放行
                .antMatchers("/login","/verifyCode","/smsLogin","/failure","/jwtLogin")
                .permitAll()
                // anyRequest() 所有请求   authenticated() 必须被认证
                .anyRequest()
                .authenticated()
                .and()
                // 关闭csrf
                .csrf().disable();
    }

    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    @Bean
    public PasswordEncoder getPasswordEncoder(){
        return new BCryptPasswordEncoder();
    }
}

测试

现在我们可以启动项目用postman验证一下,登录接口:

登录成功,并且返回用户信息,header也成功添加了:

我们在controller中添加一个测试接口:

@GetMapping("/getResource")
@ResponseBody
public ResponseEntity<String> getResource(){
return ResponseEntity.ok("获取资源成功");
}

这个接口应该是只有用户登录之后才能访问的,但是这个时候你会发现,登录完成之后,不需要携带header也能访问这个接口,并且服务器重启之后就不能访问了。这是什么情况?就这都能写一个bug出来,我果然不适合做开发,回家种地吧。

好在经过各位朋友的~~点赞~~哦不支持,我决定继续坚持下去。为什么会发生这种情况呢,因为spring security的登录信息是保存在session当中的,这显然是和jwt相悖的。所以我们还需要做两件事:

  1. 禁用session
  2. 拦截除了登录之外的其他请求,并且必须携带token

接下来就解决这两个问题吧。

请求认证

禁用session

调用HttpSecurity的方法:

http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)

这样就禁用掉session了。是不是非常的简单呢?

那怎么做到拦截请求,验证是否携带token呢。和登录认证一样的,我们要实现请求校验也通过上面的五个步骤。

自定义请求拦截filter

我们需要另外一个Filter对这些请求做一个拦截。这个拦截器主要是提取header中的token:

public class JwtAuthenticationRequestFilter extends OncePerRequestFilter {
    private final RequestHeaderRequestMatcher requestHeaderRequestMatcher = new RequestHeaderRequestMatcher("jwt-token");
    @Autowired
    private UserService userService;
    @Autowired
    private JwtAuthFailHandler failHandler;
    @Autowired
    private JwtAuthRequestSuccessHandler successHandler;

    private AuthenticationManager authenticationManager;

    public void setAuthenticationManager(AuthenticationManager authenticationManager) {
        this.authenticationManager = authenticationManager;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        //如果需要不支持匿名用户的请求没带token,这里放过也没问题,因为SecurityContext中没有认证信息,后面会被权限控制模块拦截
        if (!requestHeaderRequestMatcher.matches(request)){
            filterChain.doFilter(request,response);
            return;
        }
        String token = request.getHeader("jwt-token");
        if (StringUtils.isBlank(token)) {
            AuthenticationException failed = new InsufficientAuthenticationException("token is empty");
            SecurityContextHolder.clearContext();
            failHandler.onAuthenticationFailure(request, response, failed);
            return;
        }else {
            User user = userService.getUserInfoFromToken(token);
            JwtAuthenticationToken authenticationToken = new JwtAuthenticationToken(user.getAuthorities(),user,token);
            Authentication authResult = authenticationManager.authenticate(authenticationToken);
            //成功的回调方法
            SecurityContextHolder.getContext().setAuthentication(authResult);
            successHandler.onAuthenticationSuccess(request, response, authResult);
        }
        filterChain.doFilter(request,response);
    }
}

和登录的filter稍微有点区别,这个filter在这里其实就已经完成了jwt token的校验了,所以我们这里生成的JwtAuthenticationToken其实已经经过验证了。

public class JwtAuthenticationToken extends AbstractAuthenticationToken {
    private final Object principal;
    private Object credentials;

    public JwtAuthenticationToken(Object principal, Object credentials) {
        super(null);
        this.principal = principal;
        this.credentials = credentials;
        //初始化完成,但是还未认证
        setAuthenticated(false);
    }

    public JwtAuthenticationToken(Collection<? extends GrantedAuthority> authorities, Object principal, Object credentials) {
        super(authorities);
        this.principal = principal;
        this.credentials = credentials;
        setAuthenticated(true);
    }

    @Override
    public Object getCredentials() {
        return credentials;
    }

    @Override
    public Object getPrincipal() {
        return principal;
    }
}

自定义请求校验provider

在这个provider中不用做校验了,直接返回这个authentication就行了:

@Component
public class JwtAuthenticationProvider implements AuthenticationProvider {
    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        return authentication;
    }

    @Override
    public boolean supports(Class<?> authentication) {
        return authentication.isAssignableFrom(JwtAuthenticationToken.class);
    }
}

请求校验结果回调

对于token认证失败而言,无非就是返回401状态认证失败的信息给用户,这个和登录的handler是一样的,所以可以直接复用。对于token认证成功,在继续执行filterchain中的其他filter之前,先要检查这个token是否需要刷新,并且不像登录要返回用户信息,所以要重新定义一个handler:

@Slf4j
@Component
@EnableConfigurationProperties(JwtProperties.class)
public class JwtAuthRequestSuccessHandler implements AuthenticationSuccessHandler {
    /**
     * 刷新间隔5分钟
     */
    private final long tokenRefreshInterval = TimeUnit.MINUTES.toSeconds(5);
    @Autowired
    private UserService userService;
    @Autowired
    private JwtProperties jwtProperties;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        User user = (User) authentication.getPrincipal();
        String oldToken = (String) authentication.getCredentials();
        Date expireDate = JwtUtils.getExpireDate(oldToken, jwtProperties.getPublicKey());
        if (shouldTokenRefresh(expireDate)){
            //重新生成一个token,重置token失效时间
            String generateToken = userService.generateToken(user);
            log.debug("重新生成token: {}",generateToken);
            response.setHeader("jwt-token",generateToken);
        }
    }

    protected boolean shouldTokenRefresh(Date issueAt){
        LocalDateTime issueTime = LocalDateTime.ofInstant(issueAt.toInstant(), ZoneId.systemDefault());
        return LocalDateTime.now().minusSeconds(tokenRefreshInterval).isAfter(issueTime);
    }
}

添加请求校验配置到security

上面的认证组件准备就绪,接下来还需要一个配置类:

@Component
public class JwtRequestSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
    @Autowired
    private JwtAuthenticationProvider provider;
    @Override
    public void configure(HttpSecurity builder) throws Exception {
        JwtAuthenticationRequestFilter filter = new JwtAuthenticationRequestFilter();
        filter.setAuthenticationManager(builder.getSharedObject(AuthenticationManager.class));
        //将这个filter加入到spring容器中,这样就能在filter中注入了
        postProcess(filter);
        builder.authenticationProvider(provider);
        builder.addFilterAfter(filter, UsernamePasswordAuthenticationFilter.class);
    }
}

因为我们的filter使用了spring容器中的对象,所以要调用postProcess方法,将filter加入到spring容器中。

和登录一样,将这个配置加入到spring security配置类中:

//先注入
@Autowired
private JwtRequestSecurityConfig jwtRequestSecurityConfig; 
//然后在http中添加
http.apply(jwtRequestSecurityConfig);

这样一个~~完整~~的spring security的jwt方式的实现登录认证就完成了,使用上面的那个接口测试也完全没有问题,但是啊,这个项目还是没法正式使用,因为没有解决前后端分离项目中的一个最最基本的问题:跨域。

跨域

前后端分离项目必然要解决跨域问题,spring security中添加跨域支持:

@Bean
public CorsFilter corsFilter(){
    //1.添加cors配置信息
    CorsConfiguration config = new CorsConfiguration();
    //.1 允许的域,不要写*
    List<String> allowedOrigins = new ArrayList<>();
    allowedOrigins.add("http://localhost:8080");
    allowedOrigins.forEach(config::addAllowedOrigin);
    //.2是否发送cookie信息
    config.setAllowCredentials(true);
    //.3允许的请求方式
    List<String> allowedMethods = new ArrayList<>();
    allowedMethods.add("GET");
    allowedMethods.add("POST");
    allowedMethods.add("OPTIONS");
    allowedMethods.add("HEAD");

    allowedMethods.forEach(config::addAllowedMethod);
    //.4允许的头信息
    config.addAllowedHeader("*");
    config.addExposedHeader("jwt-token");
    //.5添加有效时长
    config.setMaxAge(3600L);

    //2.添加映射路径
    UrlBasedCorsConfigurationSource configurationSource = new UrlBasedCorsConfigurationSource();
    configurationSource.registerCorsConfiguration("/**",config);

    //3.返回新的CorsFilter
    return new CorsFilter(configurationSource);
}

然后在http中添加:

http.cors().and().addFilterAfter(corsFilter(), CorsFilter.class)

补一个完整的配置:

@Override
protected void configure(HttpSecurity http) throws Exception {
    //super.configure(http);
    http.formLogin()
        .disable()
        //添加header设置,支持跨域和ajax请求
        .cors().and()
        .addFilterAfter(corsFilter(), CorsFilter.class)
        .apply(smsAuthenticationSecurityConfig)
        .and()
        .apply(jwtAuthenticationSecurityConfig)
        .and()
        .apply(jwtRequestSecurityConfig)
        .and()
        // 设置URL的授权
        .authorizeRequests()
        // 这里需要将登录页面放行
        .antMatchers("/login","/verifyCode","/smsLogin","/failure","/jwtLogin")
        .permitAll()
        // anyRequest() 所有请求   authenticated() 必须被认证
        .anyRequest()
        .authenticated()
        .and()
        .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
        // 关闭csrf
        .csrf().disable();
}

总结

经过上面的一系列自定义认证,相信大家对Spring security已经比较熟悉了。对于自定义登录实现想必也是信手拈来。

对于jwt token相关的生成和校验由于不是本文的重点,所以这里也没有提及,大家可以去gitee查看所有源码:spring security combat,登录认证也不多说了,下篇文章开始讲授权认证了。

相关文章