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();方法。