SpringBoot3.x接入Security6.x实现JWT认证的完整步骤
作者:草青工作室
一、引言
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();方法。
到此这篇关于SpringBoot3.x接入Security6.x实现JWT认证的文章就介绍到这了,更多相关SpringBoot3接入Security6.x实现JWT认证内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!