使用SAS遇到的坑。
背景
在原本系统已经有一套完整的JWT鉴权的时候,想使用SAS复用原来的JWT Filter进行鉴权。
SAS技术基础
SAS不同于原本的Spring Security过滤器链,原本的过滤器链是请求经过一系列Filter,再进入所有句柄进行了路由匹配。
因此,当我们要添加自定义Filter就只需要将Filter添加到 UsernamePasswordFilter 之前。
实际 Spring Security 也提供了部分验证用 Filter ,其逻辑就是经过其 doFilter 接口,将认证对象置入 SecurityContextHolder ,后续的验证Filter都会经过检查上下文来判断是否已经认证完毕,如果已经认证就会跳过这个步骤。
如同下面这个自定义Filter:
/**
* JwtAuthFilter 是一个基于 JWT(JSON Web Token)的认证过滤器,继承自 Spring Security 的 OncePerRequestFilter。
* 该过滤器会在每次请求时执行,用于验证请求中的 JWT 令牌,确保请求的合法性。
* 具体流程包括从请求头中提取 JWT 令牌,检查令牌是否在黑名单中,解析并验证令牌的有效性,
* 构建认证信息并将其设置到 Spring Security 的安全上下文中,最后放行请求。
* 如果令牌无效、过期或在黑名单中,过滤器会返回相应的错误信息。
*/
public class JwtAuthFilter extends OncePerRequestFilter {
private final JwtCoreUtil jwtCoreUtil;
private final AuthService authService;
private final LocalTokenBlacklistService tokenBlacklistService;
public JwtAuthFilter(JwtCoreUtil jwtCoreUtil, AuthService authService,
LocalTokenBlacklistService tokenBlacklistService) {
this.jwtCoreUtil = jwtCoreUtil;
this.authService = authService;
this.tokenBlacklistService = tokenBlacklistService;
}
/**
* 此方法是过滤器的核心执行逻辑,在每次请求时会被调用,用于验证请求中的 JWT 令牌。
* 它会依次执行令牌提取、黑名单检查、令牌解析、声明验证等操作,
* 若不携带令牌,则将未认证信息设置到 Spring Security 安全上下文中,然后继续过滤器链;
* 若携带令牌验证通过则将认证信息设置到 Spring Security 安全上下文中,然后继续过滤器链;
* 若携带令牌验证失败或黑名单匹配,则返回相应的错误信息。
*
* @param request 当前的 HTTP 请求对象,包含请求的相关信息,如请求头、请求参数等。
* @param response 当前的 HTTP 响应对象,用于向客户端返回响应信息。
* @param chain 过滤器链对象,用于将请求传递给下一个过滤器或目标资源。
* @throws IOException 处理输入输出操作时可能抛出的异常。
*/
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws IOException {
String accessToken = HttpUtils.getSpecifyCookieValue(request, "access-token");
try {
if (!StringUtils.hasText(accessToken)) {
// 如果没有认证,设置一个未认证的对象置入上下文
Authentication anonymousAuth = new AnonymousAuthenticationToken("guestKey",
User.guestUser(),
List.of(new SimpleGrantedAuthority("ROLE_GUEST")));
anonymousAuth.setAuthenticated(false);
SecurityContextHolder.getContext().setAuthentication(anonymousAuth);
chain.doFilter(request, response);
return;
}
if (tokenBlacklistService.isBlacklisted(accessToken)) {
sendError(response, "Token revoked");
return;
}
// 解析 jwt
Claims claims = jwtCoreUtil.parseToken(accessToken);
validateClaims(claims);
// 根据 token 获取登录用户
User user = authService.getUser(accessToken);
Authentication authentication = createAuthentication(user);
// 设置登录用户,置入上下文
SecurityContextHolder.getContext().setAuthentication(authentication);
chain.doFilter(request, response);
} catch (ExpiredJwtException ex) {
sendError(response, "Token expired");
} catch (JwtInvalidException ex) {
sendError(response, "Invalid token");
} catch (Exception ex) {
sendError(response, ex.getMessage());
}
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
String path = request.getServletPath();
// 排除 /oauth2/token 和 /oauth2/jwks,不走 JWT 校验
return "/oauth2/token".equals(path) || "/oauth2/jwks".equals(path);
}
/**
* 根据 用户 User 的信息创建 Spring Security 的认证对象。
* 最终构建一个包含用户信息、权限信息的认证对象。
*
* @param user 从 JWT 令牌中解析出的声明信息对象查表后的用户对象
* @return 一个 Spring Security 的认证对象,包含用户的认证信息。
*/
protected Authentication createAuthentication(User user) {
return new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
}
/**
* 验证 JWT 令牌中的必要声明信息。
* 此方法会检查 JWT 声明中的必要字段,确保令牌包含有效的信息。
* 目前主要验证 subject 字段是否存在,后续可根据需求添加更多声明的验证逻辑。
*
* @param claims 从 JWT 令牌中解析出的声明信息对象。
* @throws JwtInvalidException 当必要的声明信息缺失时抛出此异常。
*/
protected void validateClaims(Claims claims) throws JwtInvalidException {
if (claims.getSubject() == null) {
throw new JwtInvalidException("Missing subject", null);
}
// 可添加其他必要声明验证
}
// 辅助方法:统一错误响应
private void sendError(HttpServletResponse response, String message) throws IOException {
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
HttpUtils.setCookie(response, "access-token", "", 0);
response.getWriter().write(String.format("{\\"code\\":401,\\"message\\":\\"%s\\"}", message));
}
}
所以在正常的Spring Security情况下,只要配置:
.addFilterBefore(new JwtAuthFilter(jwtCoreUtil, authService, tokenBlacklistService),
UsernamePasswordAuthenticationFilter.class);
即可。
SAS的不同点
SAS的使用方法很简单,只要提供文件的配置
@Bean
@Order(1)
public SecurityFilterChain authServerSecurityFilterChain(HttpSecurity http) throws Exception {
OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
http.formLogin(AbstractHttpConfigurer::disable)
.csrf(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable);
JwtAuthFilter jwtAuthFilter = new JwtAuthFilter(jwtCoreUtil, authService, tokenBlacklistService);
// 只有在 AbstractPreAuthenticatedProcessingFilter 之前才能保证在 EndPointFilter 之前
// 参考:<https://stackoverflow.com/questions/46801064/add-filter-before-oauth2authenticationprocessingfilter>
http.addFilterBefore(jwtAuthFilter, AbstractPreAuthenticatedProcessingFilter.class);
return http.build();
}
@Bean
public RegisteredClientRepository registeredClientRepository() {
List<RegisteredClient> clients = properties.getClients()
.stream()
.map(clientProps -> RegisteredClient.withId(UUID.randomUUID().toString())
.clientId(clientProps.getId())
.clientSecret(clientProps.getSecret())
.redirectUri(clientProps.getRedirectUri())
.scopes(scopes -> scopes.addAll(clientProps.getScopes()))
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
.tokenSettings(TokenSettings.builder()
.accessTokenTimeToLive(Duration.ofSeconds(properties.getToken().getAccessTokenTtl()))
.refreshTokenTimeToLive(Duration.ofSeconds(properties.getToken().getRefreshTokenTtl()))
.build())
.build())
.toList();
return new InMemoryRegisteredClientRepository(clients);
}
你的服务器后端就会直接支持 oauth2/token oauth2/jwks oauth2/authorize 等OAuth2标准接口。
SAS有一套自己独立的Filter链,但是将自己的Filter加载到 UsernamePasswordAuthenticationFilter 之前并不能生效。
这是因为其执行逻辑是如下的:
请求GET /oauth2/authorize
- 进入过滤器链,但不会触发 UsernamePasswordAuthenticationFilter
- 你的自定义 Filter 如果放在 UsernamePasswordAuthenticationFilter 之前,也不会执行
- 该请求被 OAuth2AuthorizationEndpointFilter 发现 “未认证”
- 触发重定向到 /login
跳转请求POST /login
- 通过 UsernamePasswordAuthenticationFilter
- 此时才会执行你放在其前后的自定义 Filter
跳转回GET /oauth2/authorize
- 再次进入 OAuth2AuthorizationEndpointFilter
- 完成授权逻辑(生成 code)
作为前后端分离的项目,我们此处不会使用到/login端口,因此添加的Filter是无效的。想要自己的Filter可以依旧生效,我们就应该将Jwt认证放在所有的EndpointFilter之前。
可以参考:
JwtAuthFilter jwtAuthFilter = new JwtAuthFilter(jwtCoreUtil, authService, tokenBlacklistService);
// 只有在 AbstractPreAuthenticatedProcessingFilter 之前才能保证在 EndPointFilter 之前
// 参考:<https://stackoverflow.com/questions/46801064/add-filter-before-oauth2authenticationprocessingfilter>
http.addFilterBefore(jwtAuthFilter, AbstractPreAuthenticatedProcessingFilter.class);
return http.build();
如上即可实现