java - SpringBoot3.x接入Security6.x实现JWT认证


文章目录

  • java - SpringBoot3.x接入Security6.x实现JWT认证
  • 一、引言
  • 二、环境
  • 三、Maven依赖
  • 四、认识JWT
  • 1. JWT组成
  • 五、认识Security6.x
  • 1. 和旧版本的区别(Security5.7以前的版本)
  • 2. Security6.x的默认筛选器
  • 3. 注册SecurityFilterChain
  • 六、基于OncePerRequestFilter自定义JWT认证筛选器
  • 1. 标记认证成功
  • 七、遇到的问题
  • 1. 加入Security6后,一直出现登录页
  • 2. 配置完匿名访问的URL后,仍然执行自定的筛选器
  • 八、完成JWT认证的主要代码
  • 1. JwtUtil
  • 2. JwtTokenFilter
  • 3. SecuritConfig
  • 总结


一、引言

SpringBoot3.x的安全默认依赖Security6.x,Security6.x于Security5.7以前的配置有了很大区别。我们将深入探讨这两个版本之间的差异,以及它们如何影响现代Web应用的安全架构。特别是,我们将重点分析JWT(JSON Web Tokens)过滤器的工作原理,以及它是如何与匿名访问相结合,为应用提供更加灵活的安全控制。

二、环境

  • JDK 17
  • SpringBoot 3.2
  • Security 6.3

三、Maven依赖

<!-- Security安全 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
    <version>3.2.2</version>
</dependency>
<!-- jwt接口认证 -->
<dependency>
    <groupId>com.auth0</groupId>
    <artifactId>java-jwt</artifactId>
    <version>4.4.0</version>
</dependency>

四、认识JWT

JSON Web Token (JWT)是一个开放标准(RFC 7519),它定义了一种紧凑的、自包含的方式,用于作为JSON对象在各方之间安全地传输信息。该信息可以被验证和信任,因为它是数字签名的。

1. JWT组成

JSON Web Token由三部分组成,它们之间用圆点(.)连接,一个典型的JWT看起来是这个样子的:

  • 第一部分:header典型的由两部分组成:token的类型(“JWT”)和算法名称(比如:HMAC SHA256或者RSA等等),然后,用Base64对这个JSON编码就得到JWT的第一部分。
  • 第二部分:payload它包含声明(要求),声明是关于实体(通常是用户)和其他数据的声明。
  • 第三部分:签名是用于验证消息在传递过程中有没有被更改,并且对于使用私钥签名的token,它还可以验证JWT的发送方是否为它所称的发送方。

注意:不要在JWT的payload或header中放置敏感信息,除非它们是加密的。

{
 alg: "RS256"
}.
{
//存储自定义的用户信息,属性可以自定扩充
 login_name: "admin",
 user_id: "xxxxx",
 ...
}.
[signature]
  • 请求header应该是这样的:Authorization: Bearer

五、认识Security6.x

1. 和旧版本的区别(Security5.7以前的版本)

SpringBoot3中默认Security升级到了6.x写法上发生了很大的变化,最显著的变化之一就是对WebSecurityConfigurerAdapter类的使用方式的改变。这个类在 Spring Security 中被广泛用于自定义安全配置。以下是主要的差异和写法上的变化:

  • 废弃WebSecurityConfigurerAdapter:

在Security5.x 版本中,WebSecurityConfigurerAdapter 是实现安全配置的常用方法。用户通过继承这个类,并覆盖其方法来自定义安全配置。到了 Spring Security 6.x,WebSecurityConfigurerAdapter 被标记为过时(deprecated),意味着它可能在未来的版本中被移除。这一变化是为了推动使用更现代的配置方法,即使用组件式配置。

  • 新版本建议使用组件式配置:

在 Spring Security 6.x 中,推荐使用组件式配置。这意味着你可以创建一个配置类,该类不再需要继承 WebSecurityConfigurerAdapter。
你可以直接定义一个或多个 SecurityFilterChain Bean来配置安全规则。这种方式更加灵活,并且与 Spring Framework 的整体风格更加一致。

2. Security6.x的默认筛选器

支持的所有筛选器在spring-security-config-6.2.1.jar包的org.springframework.security.config.annotation.web.builders.FilterOrderRegistration类的构造函数中定义,并确定了执行顺序。

FilterOrderRegistration() {
    Step order = new Step(INITIAL_ORDER, ORDER_STEP);
    put(DisableEncodeUrlFilter.class, order.next());
    put(ForceEagerSessionCreationFilter.class, order.next());
    put(ChannelProcessingFilter.class, order.next());
    order.next(); // gh-8105
    put(WebAsyncManagerIntegrationFilter.class, order.next());
    put(SecurityContextHolderFilter.class, order.next());
    put(SecurityContextPersistenceFilter.class, order.next());
    put(HeaderWriterFilter.class, order.next());
    put(CorsFilter.class, order.next());
    put(CsrfFilter.class, order.next());
    put(LogoutFilter.class, order.next());
    this.filterToOrder.put(
            "org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestRedirectFilter",
            order.next());
    this.filterToOrder.put(
            "org.springframework.security.saml2.provider.service.web.Saml2WebSsoAuthenticationRequestFilter",
            order.next());
    put(X509AuthenticationFilter.class, order.next());
    put(AbstractPreAuthenticatedProcessingFilter.class, order.next());
    this.filterToOrder.put("org.springframework.security.cas.web.CasAuthenticationFilter", order.next());
    this.filterToOrder.put("org.springframework.security.oauth2.client.web.OAuth2LoginAuthenticationFilter",
            order.next());
    this.filterToOrder.put(
            "org.springframework.security.saml2.provider.service.web.authentication.Saml2WebSsoAuthenticationFilter",
            order.next());
    put(UsernamePasswordAuthenticationFilter.class, order.next());
    order.next(); // gh-8105
    put(DefaultLoginPageGeneratingFilter.class, order.next());
    put(DefaultLogoutPageGeneratingFilter.class, order.next());
    put(ConcurrentSessionFilter.class, order.next());
    put(DigestAuthenticationFilter.class, order.next());
    this.filterToOrder.put(
            "org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter",
            order.next());
    put(BasicAuthenticationFilter.class, order.next());
    put(RequestCacheAwareFilter.class, order.next());
    put(SecurityContextHolderAwareRequestFilter.class, order.next());
    put(JaasApiIntegrationFilter.class, order.next());
    put(RememberMeAuthenticationFilter.class, order.next());
    put(AnonymousAuthenticationFilter.class, order.next());
    this.filterToOrder.put("org.springframework.security.oauth2.client.web.OAuth2AuthorizationCodeGrantFilter",
            order.next());
    put(SessionManagementFilter.class, order.next());
    put(ExceptionTranslationFilter.class, order.next());
    put(FilterSecurityInterceptor.class, order.next());
    put(AuthorizationFilter.class, order.next());
    put(SwitchUserFilter.class, order.next());
}

3. 注册SecurityFilterChain

private final String[] permitUrlArr = new String[]{"xxx"};
    /**
     * 配置Spring Security安全链。
     */
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
        //初始化jwt过滤器,并设置jwt公钥
        var jwtTokenFilter = new JwtTokenFilter();
        //Security6.x关闭默认登录页
        httpSecurity.removeConfigurers(DefaultLoginPageConfigurer.class);
        logger.info("注册JWT认证SecurityFilterChain");
        var chain = httpSecurity
                // 自定义权限拦截规则
                .authorizeHttpRequests((requests) -> {
                    //requests.anyRequest().permitAll(); //放行所有请求!!!
                    //允许匿名访问
                    requests
                            //自定可匿名访问地址,放到permitAllUrl中即可
                            .requestMatchers(permitUrlArr).permitAll()
                            //除上面声明的可匿名访问地址,其它所有请求全部需要进行认证
                            .anyRequest()
                            .authenticated();
                })
                // 禁用HTTP响应标头
                .headers(headersCustomizer -> {headersCustomizer
                            .cacheControl(cache -> cache.disable())
                            .frameOptions(options -> options.sameOrigin());})
                //会话设为无状态,基于token,所以不需要session
                .sessionManagement(session -> session
                        .sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                //添加自定义的JWT认证筛选器,验证header中jwt有效性,将插入到UsernamePasswordAuthenticationFilter之前 
                .addFilterBefore(jwtTokenFilter, UsernamePasswordAuthenticationFilter.class)
                //禁用表单登录
                .formLogin(formLogin -> formLogin.disable())
                //禁用httpBasic登录
                .httpBasic(httpBasic -> httpBasic.disable())
                //禁用rememberMe
                .rememberMe(rememberMe -> rememberMe.disable())
                // 禁用CSRF,因为不使用session
                .csrf(csrf -> csrf.disable())
                //允许跨域请求
                .cors(Customizer.withDefaults())
                .build();
        return chain;
    }

六、基于OncePerRequestFilter自定义JWT认证筛选器

使用OncePerRequestFilter的优点是,能保证一个请求只过一次筛选器。可以在filter中实现对jwt的校验,验证成功后需要对Security上下文进行标注。标记认证已经通过,这点非常重要。如果认证完了不标注,后边的过滤器还是认为未认证导致无权限失败。

1. 标记认证成功

//接入Spring Security6.x上下文,标记为已认证状态
JwtAuthenticationToken jwtToken = new JwtAuthenticationToken(null);
jwtToken.setAuthenticated(true); //标记认证通过
SecurityContextHolder.getContext().setAuthentication(jwtToken);

七、遇到的问题

1. 加入Security6后,一直出现登录页

关闭默认登录页有两个设置可以完成,可以删除DefaultLoginPageConfigurer类的加载,或者调用formLogin()函数,具体如下:

@Bean
    public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
        //Security6.x关闭默认登录页
        httpSecurity.removeConfigurers(DefaultLoginPageConfigurer.class);
        var chain = httpSecurity
                //禁用表单登录
                .formLogin(formLogin -> formLogin.disable())
                .build();
        return chain;
    }

2. 配置完匿名访问的URL后,仍然执行自定的筛选器

如果出现配置完匿名访问的URL后,仍然执行自定的筛选器,的问题。那原因就在于这个自定义筛选器上了,
只通过requests.requestMatchers(…).permitAll(); 配置的匿名访问只能对默认筛选器起效,如果想
对自定义删除器起效,还需要构建WebSecurityCustomizer Bean对象,基于匿名函数配置要匿名访问的地址。
一下是官网推荐的一个写法,这里建议把两个位置,配置的匿名访问地址,使用一个公共数组进行管理,这样
能保证两个位置配置的一致性。

/** 其它不需要认证的地址 */
    private final String[] permitUrlArr = new String[]{
            "/login"
            ,"/error"
            //静态资源
            ,"/static/**.ico"
            ,"/static/**.js"
            ,"/static/**.css"
            //匹配springdoc
            ,"/doc.html"
            ,"/webjars/**"
            //匹配swagger路径(默认)
            , "/swagger-ui.html"
            , "/swagger-ui/index.html"
            , "/v3/api-docs/**"
            , "/swagger-ui/**"
            //监控检测
            , "/actuator/**"
    };
    @Bean
    public WebSecurityCustomizer ignoringCustomize(){
        return (web) -> web.ignoring()
                .requestMatchers(permitUrlArr);
    }
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
        //初始化jwt过滤器,并设置jwt公钥
        var jwtTokenFilter = new JwtTokenFilter();
        //Security6.x关闭默认登录页
        httpSecurity.removeConfigurers(DefaultLoginPageConfigurer.class);
        logger.info("注册JWT认证SecurityFilterChain");
        var chain = httpSecurity
                // 自定义权限拦截规则
                .authorizeHttpRequests((requests) -> {
                    //允许匿名访问
                    requests
                            //自定可匿名访问地址,放到permitAllUrl中即可
                            .requestMatchers(permitUrlArr).permitAll()
                            //除上面声明的可匿名访问地址,其它所有请求全部需要进行认证
                            .anyRequest()
                            .authenticated();
                }).build();
        return chain;
    }

八、完成JWT认证的主要代码

目前是对已有jwt的认证,下发的jwt是基于RSA加密的内容,需要使用公钥进行解密,公钥一般配置在yml文件里。关键逻辑设计3部分,SecuritConfig、JwtTokenFilter、JwtUtil。

1. JwtUtil

公钥是统一认证中心下发的,目前写在yml中,格式如下:

jwt.keyValue: |
          -----BEGIN PUBLIC KEY-----
          xxxxxxxx
          -----END PUBLIC KEY-----

JwtUtil类提供了验证方法,出于性能考虑使用了单例模式,验证器只需要实例化一次。

public class JwtUtil {
    private static JwtUtil instance = new JwtUtil();
    private static JWTVerifier jwtVerifier;
    //配置文件中公钥的key值
    private static final String jwtPublicKeyConfig="jwt.keyValue";

    private JwtUtil()  {}

    /**
     * 基于固定配置文件的公钥初始化JWT验证器
     * @return
     */
    public static JwtUtil getInstance(){
        if (jwtVerifier == null){
            String publicKey = SpringUtil.getConfig(jwtPublicKeyConfig);
            return getInstance(publicKey);
        }
        return instance;
    }
    /**
     * 基于自定义公钥初始化JWT验证器
     * @return
     */
    public static JwtUtil getInstance(String publicKey) {
        if (jwtVerifier == null){
            initVerifier(publicKey);
        }
        return instance;
    }

    // 静态的初始化函数
    private static synchronized void initVerifier(String publicKey) {
        if (jwtVerifier != null)
            return;

        //替换为实际的Base64编码的RSA公钥字符串
        String publicKeyStr = publicKey.replaceAll("\\s", "") // 去除所有空白字符,包括换行符
                .replace("-----BEGINPUBLICKEY-----", "")
                .replace("-----ENDPUBLICKEY-----", "");
        // 将Base64编码的公钥字符串转换为PublicKey对象
        byte[] encodedPublicKey = Base64.getDecoder().decode(publicKeyStr);
        X509EncodedKeySpec keySpec = new X509EncodedKeySpec(encodedPublicKey);
        KeyFactory keyFactory = null;
        try {
            keyFactory = KeyFactory.getInstance("RSA");
            PublicKey pubKey = keyFactory.generatePublic(keySpec);
            // 使用公钥创建一个Algorithm对象,用于验证token的签名
            Algorithm algorithm = Algorithm.RSA256((RSAPublicKey) pubKey, null);
            // 解析和验证token
            jwtVerifier = JWT.require(algorithm).build();
        } catch (NoSuchAlgorithmException e) {
            throw new RuntimeException(e);
        } catch (InvalidKeySpecException e) {
            throw new RuntimeException(e);
        }catch (Exception e){
            throw new RuntimeException(e);
        }

    }

    /**
     * 解析和验证JWT token。
     *
     * @param token JWT token字符串
     * @return 解码后的JWT对象
     * @throws Exception 如果解析或验证失败,抛出异常
     */
    public DecodedJWT verifyToken(String token) {
        return jwtVerifier.verify(token);
    }
}

2. JwtTokenFilter

该类是校验的主要逻辑,完成了jwt校验、已认证的标注。

public class JwtTokenFilter extends OncePerRequestFilter {
    private static Logger logger = LoggerFactory.getLogger(JwtTokenFilter.class);
    private JwtUtil jwtUtil;
    //获取yml中的配置
    public String getConfig(String configKey) {
        var bean = applicationContext.getBean(Environment.class);
        var val = bean.getProperty(configKey);
        return val;
    }
    public JwtTokenFilter() throws ServletException {
        String jwtPubliKey = getConfig("jwt.keyValue");
        initTokenFilter(jwtPubliKey);
    }

    public JwtTokenFilter(String jwtPubliKey) throws ServletException {
        initTokenFilter(jwtPubliKey);
    }

    @Override
    protected void initFilterBean() throws ServletException {
    }
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        var pass = doTokenFilter(request,response,filterChain);
        if(!pass){
            return;
        }
        filterChain.doFilter(request,response);
    }

    /**
     * 初始化Token过滤器。
     * @throws ServletException 如果在初始化过程中发生错误,则抛出ServletException异常
     */
    public void  initTokenFilter(String publicKey) throws ServletException {
        logger.info("初始化TokenFilter");
        if(StringUtils.isBlank(publicKey)){
            throw new ServletException("jwtPublicKey is null");
        }
        logger.info("jwtPublicKey:{}",publicKey);
        jwtUtil = JwtUtil.getInstance(publicKey);
        logger.info("初始化JwtUtil完成");
    }

    protected Boolean doTokenFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws ServletException, IOException {
        HttpServletRequest request = (HttpServletRequest) servletRequest;
        HttpServletResponse response = (HttpServletResponse) servletResponse;
        // 从请求头中获取token
        String token = request.getHeader("Authorization");
        if(StringUtils.isBlank(token)){
            logger.info("jwt token为空,{} {}",request.getMethod(),request.getRequestURI());
            // 验证失败,返回401状态码
            response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid token");
            return false;
        }

        // 假设token是以"Bearer "开头的,需要去掉这个前缀
        if (token.startsWith("Bearer")) {
            token = token.replaceAll("Bearer\s+","");
        }
        logger.debug(request.getRequestURI());
        try {
            // 调用JwtUtils进行token验证
            DecodedJWT jwtDecode = jwtUtil.verifyToken(token);
            //接入Spring Security6.x上下文,标记为已认证状态
            JwtAuthenticationToken jwtToken = new JwtAuthenticationToken(null);
            jwtToken.setAuthenticated(true);
            SecurityContextHolder.getContext().setAuthentication(jwtToken);
            //将登录信息写入spring security上下文
        } catch (JWTVerificationException ex) {
            logger.info("jwt token 非法");
            response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "非法token:"+ex.getMessage());
            return false;
        } catch (Exception ex) {
            throw ex;
        }
        logger.debug("token验证通过");
        return true;
    }

    public static class JwtAuthenticationToken extends AbstractAuthenticationToken {
        private User userInfo;
        public JwtAuthenticationToken(User user) {
            super(null);
            this.userInfo =user;
        }
        @Override
        public User getPrincipal() {
            return userInfo;
        }
        @Override
        public Object getCredentials() {
            throw new UnsupportedOperationException();
        }

        @Override
        public boolean implies(Subject subject) {
            return super.implies(subject);
        }
    }

}

3. SecuritConfig

该类完成了对需要匿名访问的地址的配置,还有自定义filter的注入。

@Configuration
public class SecurityConfig {
    private static final Logger logger = LoggerFactory.getLogger(SecurityConfig.class);
    /** 其它不需要认证的地址 */
    private final String[] permitUrlArr = new String[]{
            "/login"
            ,"/error"
            //静态资源
            ,"/static/**.ico"
            ,"/static/**.js"
            ,"/static/**.css"
            //匹配springdoc
            ,"/doc.html"
            ,"/webjars/**"
            //匹配swagger路径(默认)
            , "/swagger-ui.html"
            , "/swagger-ui/index.html"
            , "/v3/api-docs/**"
            , "/swagger-ui/**"
            //监控检测
            , "/actuator/**"
    };
    @Bean
    public WebSecurityCustomizer ignoringCustomize(){
        return (web) -> web.ignoring()
                .requestMatchers(permitUrlArr);
    }
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
        //初始化jwt过滤器,并设置jwt公钥
        var jwtTokenFilter = new JwtTokenFilter();
        //Security6.x关闭默认登录页
        httpSecurity.removeConfigurers(DefaultLoginPageConfigurer.class);
        logger.info("注册JWT认证SecurityFilterChain");
        var chain = httpSecurity
                // 自定义权限拦截规则
                .authorizeHttpRequests((requests) -> {
                    //requests.anyRequest().permitAll(); //放行所有请求!!!
                    //允许匿名访问
                    requests
                            //自定可匿名访问地址,放到permitAllUrl中即可
                            .requestMatchers(permitUrlArr).permitAll()
                            //除上面声明的可匿名访问地址,其它所有请求全部需要进行认证
                            .anyRequest()
                            .authenticated();
                })
                // 禁用HTTP响应标头
                .headers(headersCustomizer -> {headersCustomizer
                            .cacheControl(cache -> cache.disable())
                            .frameOptions(options -> options.sameOrigin());})
                //会话设为无状态,基于token,所以不需要session
                .sessionManagement(session -> session
                        .sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                //添加自定义的JWT认证筛选器,验证header中jwt有效性,将插入到UsernamePasswordAuthenticationFilter之前 
                .addFilterBefore(jwtTokenFilter, UsernamePasswordAuthenticationFilter.class)
                //禁用表单登录
                .formLogin(formLogin -> formLogin.disable())
                //禁用httpBasic登录
                .httpBasic(httpBasic -> httpBasic.disable())
                //禁用rememberMe
                .rememberMe(rememberMe -> rememberMe.disable())
                // 禁用CSRF,因为不使用session
                .csrf(csrf -> csrf.disable())
                //允许跨域请求
                .cors(Customizer.withDefaults())
                .build();
        return chain;
    }
    @Bean
    public FilterRegistrationBean disableSpringBootErrorFilter(ErrorPageFilter filter){
        FilterRegistrationBean filterRegistrationBean = new FilterRegistrationBean();
        filterRegistrationBean.setFilter(filter);
        filterRegistrationBean.setEnabled(false);
        return filterRegistrationBean;
    }
}

总结

以上支持介绍了对于已有JWT统一认证系统的接入(JWT解析和认证),不涉及JWT生成和管理相关内容。
目前的用户信息是基于JWT动态解析的,所以暂时没有基于AbstractAuthenticationToken在Security上下文中存放用户信息,JwtAuthenticationToken已经支持自定义用户信息的存储,只需要按需传入即可。基于Security上下文获取用户信息使用SecurityContextHolder.getContext().getAuthentication().getPrincipal();方法。