使用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();
        

如上即可实现