做基于Spring Security Jwt认证的Starter包

以前老的项目大多采用的是session进行用户身份的管理,目前很多公司的项目都是前后端分离, 这样使用session 成本就会变高,然后多数项目会采用Jwt进行用户的身份的管理。

JSON Web Token(JWT)是目前最流行的跨域身份验证解决方案。有不清楚的同学可以先看看者篇文章:10分钟了解JSON Web令牌(JWT)

今天要做的事就是将Jwt认证和授权的过程封装成一个Springboot 的Stater包,只需要使用EnableJwtSecurity注解,就可以一键开启Jwt认证功能,无需在每个项目都拷贝同样的认证代码,非常的麻烦。代码已经放在github上,链接地址在文末,有兴趣的可以亲自使用一下,欢迎提出您的宝贵意见。
该Starter包含的功能
1、支持白名单过滤功能
2、支持配置token刷新时间
3、支持生成随机盐的方式生成Jwt(默认是使用的固定的盐)
4、最重要的当然是Jwt认证功能啦

接下来我们开始讲解该Starter的认证思路,篇幅有点长,请耐心阅读

Spring Security 核心类简介

AuthenticationManager, 用户认证的管理类,所有的认证请求(比如login)都会通过提交一个token给AuthenticationManager的authenticate()方法来实现。当然事情肯定不是它来做,具体校验动作会由AuthenticationManager将请求转发给具体的实现类来做。根据实现反馈的结果再调用具体的Handler来给用户以反馈。这个类基本等同于shiro的SecurityManager。

AuthenticationProvider, 认证的具体实现类,一个provider是一种认证方式的实现,比如提交的用户名密码我是通过和DB中查出的user记录做比对实现的,那就有一个DaoProvider;如果我是通过CAS请求单点登录系统实现,那就有一个CASProvider。这个是不是和shiro的Realm的定义很像?基本上你可以帮他们当成同一个东西。按照Spring一贯的作风,主流的认证方式它都已经提供了默认实现,比如DAO、LDAP、CAS、OAuth2等。。

UserDetailService, 用户认证通过Provider来做,所以Provider需要拿到系统已经保存的认证信息,获取用户信息的接口spring-security抽象成UserDetailService。虽然叫Service,但是我更愿意把它认为是我们系统里经常有的UserDao。

AuthenticationToken, 所有提交给AuthenticationManager的认证请求都会被封装成一个Token的实现,比如最容易理解的UsernamePasswordAuthenticationToken。这个就不多讲了,连名字都跟Shiro中一样。

SecurityContext,当用户通过认证之后,就会为这个用户生成一个唯一的SecurityContext,里面包含用户的认证信息Authentication。通过SecurityContext我们可以获取到用户的标识Principle和授权信息GrantedAuthrity。在系统的任何地方只要通过SecurityHolder.getSecruityContext()就可以获取到SecurityContext。在Shiro中通过SecurityUtils.getSubject()到达同样的目的。

Spring Security Filter

Spring Security 的底层是通过一系列的 Filter 来管理的,每个 Filter 都有其自身的功能,而且各个 Filter 在功能上还有关联关系,所以它们的顺序也是非常重要的。

Filter 顺序
1.Spring Security 已经定义了一些 Filter,不管实际应用中你用到了哪些,它们应当保持如下顺序。

2.ChannelProcessingFilter,如果你访问的 channel 错了,那首先就会在 channel 之间进行跳转,如 http 变为 https。

3.SecurityContextPersistenceFilter,这样的话在一开始进行 request 的时候就可以在 SecurityContextHolder 中建立一个 SecurityContext,然后在请求结束的时候,任何对 SecurityContext 的改变都可以被 copy 到 HttpSession。

4.ConcurrentSessionFilter,因为它需要使用 SecurityContextHolder 的功能,而且更新对应 session 的最后更新时间,以及通过 SessionRegistry 获取当前的 SessionInformation 以检查当前的 session 是否已经过期,过期则会调用 LogoutHandler。

认证处理机制,如 UsernamePasswordAuthenticationFilter,CasAuthenticationFilter,BasicAuthenticationFilter 等,以至于 SecurityContextHolder 可以被更新为包含一个有效的 Authentication 请求。

5.SecurityContextHolderAwareRequestFilter,它将会把 HttpServletRequest 封装成一个继承自 HttpServletRequestWrapper 的 SecurityContextHolderAwareRequestWrapper,同时使用 SecurityContext 实现了 HttpServletRequest 中与安全相关的方法。

6.JaasApiIntegrationFilter,如果 SecurityContextHolder 中拥有的 Authentication 是一个 JaasAuthenticationToken,那么该 Filter 将使用包含在 JaasAuthenticationToken 中的 Subject 继续执行 FilterChain。

7.RememberMeAuthenticationFilter,如果之前的认证处理机制没有更新 SecurityContextHolder,并且用户请求包含了一个 Remember-Me 对应的 cookie,那么一个对应的 Authentication 将会设给 SecurityContextHolder。

8.AnonymousAuthenticationFilter,如果之前的认证机制都没有更新 SecurityContextHolder 拥有的 Authentication,那么一个 AnonymousAuthenticationToken 将会设给 SecurityContextHolder。

9.ExceptionTransactionFilter,用于处理在 FilterChain 范围内抛出的 AccessDeniedException 和 AuthenticationException,并把它们转换为对应的 Http 错误码返回或者对应的页面。

10.FilterSecurityInterceptor,保护 Web URI,并且在访问被拒绝时抛出异常。

  • 以下是Spring Security 大致的认证流程:


    做基于Spring Security Jwt认证的Starter包_第1张图片
    认证流程

Jwt 认证流程

看了以上的知识我们要做的无非就是定义一个Filter用于过滤需要认证的请求,再定义一个Provider用于做真正的认证操作,我们来看一下我们要做的具体步骤:

1、首先定义一个JwtAuthenticationToken 继承于 AbstractAuthenticationToken

2、定义一个JwtAuthenticationFilter,过滤需要认证url及token

3、定义JwtAuthenticationConfigurer,用于注册上面定义的Filter

4、定义JwtAuthenticationProvider,主要认证逻辑在这里

做完这些,我们主要的认证过程基本就已经实现了,其它的就是成功或失败等那些操作可以根据自己项目需求来定义。接下来我们将按上面具体介绍这些类。

Step.1

public class JwtAuthenticationToken extends AbstractAuthenticationToken {

private static final long serialVersionUID = 1L;

private UserDetails principal;

private String credentials;

private DecodedJWT token;
...

这里的作用有两个,第一用于我们自定义Provider中的Supports方法的对比;第二,用于保存Jwt token

Step.2

这一步总的来说只需要做两件事
第一,验证此次请求是否需要拦截,也就是我们配置的白名单
第二,将token 提取出来交给我们自定义的Provider进行处理

由于篇幅原因,这里我只贴出主要逻辑,github地址在文末

public class JwtAuthenticationFilter extends OncePerRequestFilter {
   ....
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        // 验证是否是白名单
        if (permissiveRequest(request)) {
            filterChain.doFilter(request, response);
            return;
        }
        Authentication authResult = null;
        AuthenticationException failed = null;
        
        //验证是否是登录状态
        failed =  requireLogin();
        if (failed == null) {
            try {
                // 提取token 并委托给JwtAuthenticationProvider进行认证
                String token = getJwtToken(request);
                if (StringUtils.isNotBlank(token)) {
                    JwtAuthenticationToken authToken = new JwtAuthenticationToken(JWT.decode(token));
                    authResult = this.getAuthenticationManager().authenticate(authToken);
                } else {
                    failed = new InsufficientAuthenticationException("JWT is Empty");
                }
            } catch (JWTDecodeException e) {
                logger.error("JWT format error", e);
                failed = new InsufficientAuthenticationException("JWT format error", failed);
            } catch (InternalAuthenticationServiceException e) {
                logger.error("An internal error occurred while trying to authenticate the user.", failed);
                failed = e;
            } catch (AuthenticationException e) {
                failed = e;
            }
        }
        if (authResult != null) {
            successfulAuthentication(request, response, filterChain, authResult);
        } else if (!permissiveRequest(request)) {
            unsuccessfulAuthentication(request, response, failed);
            return;
        }

        filterChain.doFilter(request, response);
    }

    protected boolean permissiveRequest(HttpServletRequest request) {
        if (permissiveRequestMatchers == null)
            return false;
        for (RequestMatcher permissiveMatcher : permissiveRequestMatchers) {
            if (permissiveMatcher.matches(request))
                return true;
        }
        return false;
    }

    private AuthenticationException requireLogin() {
        SecurityContext context = SecurityContextHolder.getContext();
        if (context.getAuthentication() == null) {
            return new BadCredentialsException("Login expired or logout");
        }
        return null;
    }

    protected String getJwtToken(HttpServletRequest request) {
        String authInfo = request.getHeader(AUTHORIZATION_HEADER);
        return StringUtils.removeStart(authInfo, AUTHORIZATION_START_STRING);
    }
...
}

Step.3

这一步主要向Spring Security 过滤器链注册我们刚才定义的Filter,以及向Filter中注入我们需要使用的Bean,如AuthenticationManager

public class JwtAuthenticationConfigurer, B extends HttpSecurityBuilder> extends AbstractHttpConfigurer {
    
    private JwtAuthenticationFilter authFilter;
    
    public JwtAuthenticationConfigurer() {
        authFilter = new JwtAuthenticationFilter();
    }
    
    @Override
    public void configure(B builder) throws Exception {
        authFilter.setAuthenticationManager(builder.getSharedObject(AuthenticationManager.class));
        authFilter.setAuthenticationFailureHandler(new JwtAuthenticationFailureHandler());
        
        authFilter = postProcess(authFilter);
        //这里将该过滤器放在logout 后面 是因为会判断前面的login和logout 中的token
        builder.addFilterAfter(authFilter, LogoutFilter.class);
    }
    
    /**
     * 设置白名单
     * @param urls
     * @return
     */
    public JwtAuthenticationConfigurer permissiveRequestUrls(String ... urls){
        authFilter.setPermissiveUrl(urls);
        return this;
    }
    
    /**
     * 设置认证成功后的操作
     * @param successHandler
     * @return
     */
    public JwtAuthenticationConfigurer authenticationSuccessHandler(AuthenticationSuccessHandler successHandler){
        authFilter.setAuthenticationSuccessHandler(successHandler);
        return this;
    }
}

Step.4

这里就是最重要的一步,Jwt token 的认证逻辑就在这里,在我们自定义的Filter中调用的 this.getAuthenticationManager().authenticate(authToken) 最终调用的就是我们这个Provider,可能有同学会有这样的疑问,为什么一定会调用我们自定义的Provider,其实步骤1我们也提到了,主要就是在该Provider 中的supports(Class authentication)方法中,具体我们可以看一下代码:


public class JwtAuthenticationProvider implements AuthenticationProvider, InitializingBean {
    
    private JwtUserDetailsService userDetailsService;
    
    private UserCache userCache = new NullUserCache();
    

    @Override
    public void afterPropertiesSet() throws Exception {
        Assert.notNull(this.userCache, "A user cache must be set");
        Assert.notNull(this.userDetailsService, "A UserDetailsService must be set");
    }
    
    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        DecodedJWT jwt = ((JwtAuthenticationToken) authentication).getToken();
        Assert.notNull(jwt, "Jwt token is null");
        //验证token是否过期
        if(jwt.getExpiresAt().before(Calendar.getInstance().getTime()))
            throw new BadCredentialsException("Jwt is expired");
        
        //从缓存或数据库中获取user对象
        String username = jwt.getSubject();
        UserDetails user = userCache.getUserFromCache(username);
        
        if (user == null) {
            user = userDetailsService.loadUserByUsername(username);
            if (user == null) {
                return null;
            }
        }
        String salt = getSalt(username);
        try {
            JwtUtils.checkJWT(jwt.getToken(), salt, username);
        } catch (Exception e) {
            throw new BadCredentialsException("Jwt verify fail", e);
        }
        
        JwtAuthenticationToken token = new JwtAuthenticationToken(user, jwt, user.getAuthorities());
        return token;
    }
    //这里就会判断是否是我们自己定义的token,如果返回true 才会进入authenticate方法中
    @Override
    public boolean supports(Class authentication) {
        return authentication.isAssignableFrom(JwtAuthenticationToken.class);
    }

    public void setUserDetailsService(JwtUserDetailsService userDetailsService) {
        this.userDetailsService = userDetailsService;
    }

    public void setUserCache(UserCache userCache) {
        this.userCache = userCache;
    }
    
    protected String getSalt(String username) {
        String salt = userDetailsService.getSalt(username);
        if (StringUtils.isBlank(salt)) {
            salt = JwtUtils.TOKEN_SALT;
        }
        return salt;
    }
    
}

至此 Jwt认证的主要逻辑已经实现了,但还有一些细节需要我们完善,不然怎么做到开箱即用呢,比如我们还需要配置一个用于登录验证Filter,Let's do it!

定义LoginAuthenticationFilter

这个过滤器的作用只会拦截登录请求,验证用户名密码,这里我们只重新定义了Filter和Configurer,为什么呢?因为Spring Security已经为我们提供了默认的认证实现方式,具体实现逻辑在AbstractUserDetailsAuthenticationProvider中的authenticate(Authentication authentication)方法中,有兴趣的同学可以翻看下源码,里面会调用retrieveUser; 这个方法在子类中实现,其中会调用我们一会会定义的UserDetailsSevice。
所以我们这个过滤器只需要把用户名和密码提取至UsernamePasswordAuthenticationToken中,就可以完成认证。具体代码如下:

public class LoginAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
    
    public static final String SPRING_SECURITY_FORM_USERNAME_KEY = "username";
    public static final String SPRING_SECURITY_FORM_PASSWORD_KEY = "password";

    private String usernameParameter = SPRING_SECURITY_FORM_USERNAME_KEY;
    private String passwordParameter = SPRING_SECURITY_FORM_PASSWORD_KEY;
    
    private JwtUserDetailsService jwtUserDetailsService;
    
    public LoginAuthenticationFilter() {
        super(new AntPathRequestMatcher("/**/login", "POST"));
    }
    
    @Override
    public void afterPropertiesSet() {
        Assert.notNull(getAuthenticationManager(), "authenticationManager must be specified");
        Assert.notNull(getSuccessHandler(), "AuthenticationSuccessHandler must be specified");
        Assert.notNull(getFailureHandler(), "AuthenticationFailureHandler must be specified");
    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
            throws AuthenticationException, IOException, ServletException {
        String body = StreamUtils.copyToString(request.getInputStream(), Charset.forName("UTF-8"));
        
        String username = obtainUsername(body);
        String password = obtainPassword(body);
        
        if (username == null) 
            username = "";
        if (password == null)
            password = "";
        username = username.trim();

        UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(
                username, password);
        
        return this.getAuthenticationManager().authenticate(authRequest);
    }

    protected String obtainPassword(String body) throws IOException {
        return getStringFromRequest(body, passwordParameter);
    }

    protected String obtainUsername(String body) throws IOException {
        return getStringFromRequest(body, usernameParameter);
    }
    
    protected String getStringFromRequest(String body, String key) throws IOException {
        String result = null;
        if(StringUtils.hasText(body) && !StringUtils.isEmpty(key)) {
            JSONObject jsonObj = JSON.parseObject(body);
            result = jsonObj.getString(key);
        }
        return result;
    }

    public JwtUserDetailsService getJwtUserDetailsService() {
        return jwtUserDetailsService;
    }

    public void setJwtUserDetailsService(JwtUserDetailsService jwtUserDetailsService) {
        this.jwtUserDetailsService = jwtUserDetailsService;
    }
    
    
}

定义LoginConfigurer

定义了过滤器 当然也需要定义Configurer,当然你也可以直接在HttpSecurity中添加到指定位置,但是这样做的区别就是无法注入自己想用的Bean,这部分代码比较简单,直接上代码:


public class LoginConfigurer, B extends HttpSecurityBuilder> extends AbstractHttpConfigurer {
    
    private LoginAuthenticationFilter loginAuthenticationFilter;
    private JwtUserDetailsService jwtUserDetailsService;
    
    public LoginConfigurer(JwtUserDetailsService jwtUserDetailsService) {
        loginAuthenticationFilter = new LoginAuthenticationFilter();
        this.jwtUserDetailsService = jwtUserDetailsService;
    }
    
    @Override
    public void configure(B builder) throws Exception {
        loginAuthenticationFilter.setAuthenticationManager(builder.getSharedObject(AuthenticationManager.class));
        loginAuthenticationFilter.setAuthenticationFailureHandler(new JwtAuthenticationFailureHandler());
        loginAuthenticationFilter.setJwtUserDetailsService(jwtUserDetailsService);
        
        loginAuthenticationFilter = postProcess(loginAuthenticationFilter);
        builder.addFilterBefore(loginAuthenticationFilter, LogoutFilter.class);
    }
    
    public LoginConfigurer authenticationSuccessHandler(AuthenticationSuccessHandler successHandler){
        loginAuthenticationFilter.setAuthenticationSuccessHandler(successHandler);
        return this;
    }

}

定义JwtUserDetailsService

我在这里仅仅使用了抽象类实现UserDetailsService,这样做的目标就是让开发者自己定义loadUserByUsername(String username),而我在这里添加了三个方法,如果项目不使用随机盐可以不用管这几个方法,这里提供了默认实现,如果需要在数据库或缓存保存中实现可以重写这几个方法。

划重点,loadUserByUsername(String username)必须要实现,不然框架会报错

配置WebSecurityConfiguration

前面写了那么多,如果不配置进Spring Security中那是不起作用的,所以我们需要把这些类配置进去。这些配置也包括了我们的白名单,token刷新时间等,看代码

@Configuration
@EnableWebSecurity
public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter {
    
    @Autowired
    private JwtUserDetailsService userDetailsService;
    
    @Autowired(required = false)
    private UserCache userCache;
    
    @Autowired
    private JwtProperties p;
    
    private static final String DEFAULT_PERMITURL = "/login/**,/logout/**";

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.authenticationProvider(getJwtAuthenticationProvider())
            .authenticationProvider(getDaoAuthenticationProvider());
    }

    @Override
    public void configure(WebSecurity web) throws Exception {
        super.configure(web);
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
            .antMatchers(getPermitUrls()).permitAll()
            .anyRequest().authenticated()
            .and()
            .csrf().disable()
            .formLogin().disable()
            .sessionManagement().disable()
            .cors()
            .and()
            .headers().addHeaderWriter(new StaticHeadersWriter(Arrays.asList(
                    new Header("Access-control-Allow-Origin","*"),
                    new Header("Access-Control-Expose-Headers","Authorization"))))
            .and()
            .addFilterAfter(new OptionsRequestFilter(), CorsFilter.class)
            .apply(new LoginConfigurer<>(userDetailsService)).authenticationSuccessHandler(loginSuccessHandler())
            .and()
            .apply(new JwtAuthenticationConfigurer<>())
                .authenticationSuccessHandler(jwtRefreshSuccessHandler())
                .permissiveRequestUrls(getPermitUrls())
            .and()
            .logout()
                .addLogoutHandler(tokenClearLogoutHandler())
                .logoutSuccessHandler(new HttpStatusReturningLogoutSuccessHandler())
            .and()
            .sessionManagement().disable();
    }
    
    @Bean("jwtAuthenticationProvider")
    public AuthenticationProvider getJwtAuthenticationProvider() {
        JwtAuthenticationProvider authenticationProvider = new JwtAuthenticationProvider();
        authenticationProvider.setUserDetailsService(userDetailsService);
        if (userCache != null) {
            authenticationProvider.setUserCache(userCache);
        }
        return authenticationProvider;
    }
    
    @Bean("daoAuthenticationProvider")
    protected AuthenticationProvider getDaoAuthenticationProvider() throws Exception{
        DaoAuthenticationProvider daoProvider = new DaoAuthenticationProvider();
        daoProvider.setUserDetailsService(userDetailsService);
        daoProvider.setPasswordEncoder(new BCryptPasswordEncoder());
        return daoProvider;
    }
    
    @Bean
    protected JwtRefreshSuccessHandler jwtRefreshSuccessHandler() {
        JwtRefreshSuccessHandler refreshSuccessHandler = new JwtRefreshSuccessHandler(userDetailsService);
        refreshSuccessHandler.setTokenRefreshInterval(p.getTokenRefreshInterval());
        return refreshSuccessHandler;
    }
    
    @Bean 
    public LoginSuccessHandler loginSuccessHandler() {
        return new LoginSuccessHandler(userDetailsService);
    }
    
    @Bean
    public TokenClearLogoutHandler tokenClearLogoutHandler() {
        return new TokenClearLogoutHandler(userDetailsService);
    }
    
    String[] getPermitUrls() {
        String urls = p.getPermitUrls() + DEFAULT_PERMITURL;
        return StringUtils.split(urls.trim(), ",");
    }
    
//跨域支持,配置了CorsConfigurationSource ,CorsFilter 会去读取该Bean中的配置
    @Bean
    protected CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();
        configuration.setAllowedOrigins(Arrays.asList("*"));
        configuration.setAllowedMethods(Arrays.asList("GET","POST","DELETE","PUT","HEAD", "OPTION"));
        configuration.setAllowedHeaders(Arrays.asList("*"));
        configuration.addExposedHeader("Authorization");
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", configuration);
        return source;
    }
    
}

当然还有一个程序的入口,项目只需要加入这个在Springboot启动类加上该注解,就会启用Spring Security 包括我们定义的Jwt的相关功能。


@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Import({WebSecurityConfiguration.class, JwtProperties.class})
public @interface EnableJwtSecurity {

}

到这里, 我们基本已经完成了所有逻辑,还有些成功,失败的Handler我就不在这里贴了,有兴趣的同学可以进github看相应的源码。

如果有什么疑问或者写的不好也或者有错误的地方,可以提出来,我会一一为各位答复,努力改进。

最后附上github 项目地址: springboot-security-jwt-starter
喜欢的可以留下你的小Star哦,万分感谢。

参考资料:
初识Spring Security
Spring Security 官方指南
Spring Security做JWT认证和授权

你可能感兴趣的:(做基于Spring Security Jwt认证的Starter包)