0
点赞
收藏
分享

微信扫一扫

Spring Security 详解与实操第三节 安全访问/跨域/csrf

管道过滤:如何基于 Spring Security 过滤器扩展安全性?

在 06 讲“权限管理:如何剖析 Spring Security 的授权原理?”中,我们介绍 Spring Security 授权流程时提到了过滤器的概念。今天,我们就直面这个主题,详细分析 Spring Security 中的过滤器架构,进一步学习实现自定义过滤器的系统方法。

Spring Security 过滤器架构

过滤器是一种通用机制,在处理 Web 请求的过程中发挥了重要作用。可以说,目前市面上所有的 Web 开发框架都或多或少使用了过滤器完成对请求的处理,Spring Security 也不例外。Spring Security 中的过滤器架构是基于 Servlet构建的,所以我们先从 Servlet 中的过滤器开始说起。

Servlet 与管道-过滤器模式

和业界大多数处理 Web 请求的框架一样,Servlet 中采用的最基本的架构就是管道-过滤器(Pipe-Filter)架构模式。管道-过滤器架构模式的示意图如下所示:

Drawing 0.png

管道-过滤器架构模式示意图

结合上图我们可以看到,处理业务逻辑的组件被称为过滤器,而处理结果通过相邻过滤器之间的管道进行传输,这样就构成了一个过滤器链。

在 Servlet 中,代表过滤器的 Filter 接口定义如下:

public interface Filter {
 
    public void init(FilterConfig filterConfig) throws ServletException;

    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException;
 
    public void destroy();
}

当应用程序启动时,Servlet 容器就会调用 init() 方法。这个方法只会在容器启动时调用一次,因此包含了初始化过滤器的相关代码。对应的,destroy() 方法用于释放该过滤器占有的资源。

一个过滤器组件所包含的业务逻辑应该位于 doFilter() 方法中,该方法带有三个参数,分别是ServletRequest、ServletResponse 和 FilterChain。这三个参数都很重要,我们一一说明。

  • ServletRequest:表示 HTTP 请求,我们使用该对象获取有关请求的详细信息。

  • ServletResponse:表示 HTTP 响应,我们使用该对象构建响应结果,然后将其发送回客户端或沿着过滤器链向后传递。

  • FilterChain:表示过滤器链,我们使用该对象将请求转发到链中的下一个过滤器。

请注意,过滤器链中的过滤器是有顺序的,这点非常重要,我们在本讲后续内容中会针对这点展开讲解。

Spring Security 中的过滤器链

在 Spring Security 中,其核心流程的执行也是依赖于一组过滤器,这些过滤器在框架启动后会自动进行初始化,如图所示:

Drawing 1.png

Spring Security 中的过滤器链示意图

在上图中,我们看到了几个常见的 Filter,比如 BasicAuthenticationFilter、UsernamePasswordAuthenticationFilter 等,这些类都直接或间接实现了 Servlet 中的 Filter 接口,并完成某一项具体的认证机制。例如,上图中的 BasicAuthenticationFilter 用来验证用户的身份凭证;而 UsernamePasswordAuthenticationFilter 会检查输入的用户名和密码,并根据认证结果决定是否将这一结果传递给下一个过滤器。

请注意,整个 Spring Security 过滤器链的末端是一个 FilterSecurityInterceptor,它本质上也是一个 Filter。但与其他用于完成认证操作的 Filter 不同,它的核心功能是实现权限控制,也就是用来判定该请求是否能够访问目标 HTTP 端点。FilterSecurityInterceptor 对于权限控制的粒度可以到方法级别,能够满足前面提到的精细化访问控制。我们在 06 讲“权限管理:如何剖析 Spring Security 的授权原理?”中已经对这个拦截器做了详细的介绍,这里就不再展开了。

通过上述分析,我们明确了在 Spring Security 中,认证和授权这两个安全性需求是通过一系列的过滤器来实现的。而过滤器的真正价值不仅在于实现了认证和授权,更为开发人员提供了一个扩展 Spring Security 框架的有效手段。

实现自定义过滤器

在 Spring Security 中创建一个新的过滤器并不复杂,只需要遵循 Servlet 所提供的 Filter 接口约定即可。

开发过滤器

讲到开发自定义的过滤器,最经典的应用场景就是记录 HTTP 请求的访问日志。如下所示就是一种常见的实现方式:

public class LoggingFilter implements Filter {
 
    private final Logger logger =
            Logger.getLogger(AuthenticationLoggingFilter.class.getName());
 
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain) throws IOException, ServletException {
        HttpServletRequest httpRequest = (HttpServletRequest) request;
 
        //从 ServletRequest 获取请求数据并记录
        String uniqueRequestId = httpRequest.getHeader("UniqueRequestId");
        logger.info("成功对请求进行了认证: " +  uniqueRequestId);
 
       //将请求继续在过滤器链上进行传递
        filterChain.doFilter(request, response);
    }
}

这里我们定义了一个 LoggingFilter,用来记录已经通过用户认证的请求中包含的一个特定的消息头“UniqueRequestId”,通过这个唯一的请求 Id,我们可以对请求进行跟踪、监控和分析。在实现一个自定义的过滤器组件时,我们通常会从 ServletRequest 中获取请求数据,并在 ServletResponse 中设置响应数据,然后通过 filterChain 的 doFilter() 方法将请求继续在过滤器链上进行传递。

接下来,我们想象这样一种场景,业务上我们需要根据客户端请求头中是否包含某一个特定的标志位,来决定请求是否有效。如图所示:

Drawing 2.png

根据标志位设计过滤器示意图

这在现实开发过程中也是一种常见的应用场景,可以实现定制化的安全性控制。针对这种应用场景,我们可以实现如下所示的 RequestValidationFilter 过滤器:

public class RequestValidationFilter implements Filter {
 
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain) throws IOException, ServletException {
        HttpServletRequest httpRequest = (HttpServletRequest) request;
        HttpServletResponse httpResponse = (HttpServletResponse) response;
        String requestId = httpRequest.getHeader("SecurityFlag");
        if (requestId == null || requestId.isBlank()) {
            httpResponse.setStatus(HttpServletResponse.SC_BAD_REQUEST);
            return;
        }
 
        filterChain.doFilter(request, response);
    }
}

这里我们从 HttpServletRequest 对象的请求头中获取了“SecurityFlag”标志位,否则将直接抛出一个 400 Bad Request 响应结果。根据需要,我们也可以实现各种自定义的异常处理逻辑。

配置过滤器

现在,我们已经实现了几个有价值的过滤器了,下一步就是将这些过滤器整合到 Spring Security 的整个过滤器链中。这里,我想特别强调一点,和 Servlet 中的过滤器一样,Spring Security 中的过滤器也是有顺序的。也就是说,将过滤器放置在过滤器链的具体位置需要符合每个过滤器本身的功能特性,不能将这些过滤器随意排列组合。

我们来举例说明合理设置过滤器顺序的重要性。在“用户认证:如何使用 Spring Security 构建用户认证体系?”一讲中我们提到了 HTTP 基础认证机制,而在 Spring Security 中,实现这一认证机制的就是 BasicAuthenticationFilter。

如果我们想要实现定制化的安全性控制策略,就可以实现类似前面介绍的 RequestValidationFilter 这样的过滤器,并放置在 BasicAuthenticationFilter 前。这样,在执行用户认证之前,我们就可以排除掉一批无效请求,效果如下所示:

Drawing 3.png

RequestValidationFilter 的位置示意图

上图中的 RequestValidationFilter 确保那些没有携带有效请求头信息的请求不会执行不必要的用户认证。基于这种场景,把 RequestValidationFilter 放在 BasicAuthenticationFilter 之后就不是很合适了,因为用户已经完成了认证操作。

同样,针对前面已经构建的 LoggingFilter,原则上我们可以把它放在过滤器链的任何位置,因为它只记录了日志。但有没有更合适的位置呢?结合 RequestValidationFilter 来看,同样对于一个无效的请求而言,记录日志是没有什么意义的。所以 LoggingFilter 应该放置在 RequestValidationFilter 之后。另一方面,对于日志操作而言,通常只需要记录那些已经通过认证的请求,所以也推荐将 LoggingFilter 放在 BasicAuthenticationFilter 之后。最终,这三个过滤器之间的关系如下图所示:

Drawing 4.png

三个过滤器的位置示意图

在 Spring Security 中,提供了一组可以往过滤器链中添加过滤器的工具方法,包括 addFilterBefore()、addFilterAfter()、addFilterAt() 以及 addFilter() 等,它们都定义在 HttpSecurity 类中。这些方法的含义都很明确,使用起来也很简单,例如,想要实现如上图所示的效果,我们可以编写这样的代码:

@Override
protected void configure(HttpSecurity http) throws Exception {
        http.addFilterBefore(
                new RequestValidationFilter(),
                BasicAuthenticationFilter.class)
            .addFilterAfter(
                new LoggingFilter(),
                BasicAuthenticationFilter.class)
            .authorizeRequests()
                .anyRequest()
                .permitAll();
}

这里,我们使用了 addFilterBefore() 和 addFilterAfter() 方法在 BasicAuthenticationFilter 之前和之后分别添加了 RequestValidationFilter 和 LoggingFilter。

Spring Security 中的过滤器

下表列举了 Spring Security 中常用的过滤器名称、功能以及它们的顺序关系:

image.png

Spring Security 中的常见过滤器一览表

这里以最基础的 UsernamePasswordAuthenticationFilter 为例,该类的定义及核心方法 attemptAuthentication 如下所示:

public class UsernamePasswordAuthenticationFilter extends
        AbstractAuthenticationProcessingFilter {
 
    public Authentication attemptAuthentication(HttpServletRequest request,
           HttpServletResponse response) throws AuthenticationException {
        if (postOnly && !request.getMethod().equals("POST")) {
           throw new AuthenticationServiceException(
                   "Authentication method not supported: " + request.getMethod());
        }
 
        String username = obtainUsername(request);
        String password = obtainPassword(request);
 
        if (username == null) {
           username = "";
        }
 
        if (password == null) {
           password = "";
        }
 
        username = username.trim();
 
        UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(
               username, password);
 
        setDetails(request, authRequest);
 
        return this.getAuthenticationManager().authenticate(authRequest);
    }
    …
}

围绕上述方法,我们结合前面已经介绍的认证和授权相关实现原理,可以引出该框架中一系列核心类并梳理它们之间的交互结构,如下图所示:

image (1).png

UsernamePasswordAuthenticationFilter 相关核心类图

上图中的很多类,我们通过名称就能明白它的含义和作用。以位于左下角的 SecurityContextHolder 为例,它是一个典型的 Holder 类,存储了应用的安全上下文对象 SecurityContext,而这个上下文对象中就包含了用户的认证信息。

我们也可以大胆猜想,它的内部应该使用 ThreadLocal 确保线程访问的安全性。更具体的,我们已经在“权限管理:如何剖析 Spring Security 的授权原理?”中讲解过 SecurityContext 的使用方法。

正如 UsernamePasswordAuthenticationFilter 中的代码所示,一个 HTTP 请求到达之后,会通过一系列的 Filter 完成用户认证,而具体的工作交由 AuthenticationManager 完成,这个过程又会涉及 AuthenticationProvider 以及 UserDetailsService 等多个核心组件之间的交互。关于 Spring Security 中认证流程的详细描述,你可以参考“认证体系:如何深入理解 Spring Security 的用户认证机制?”做一些回顾。

小结与预告

这一讲我们关注于 Spring Security 中的一个核心组件——过滤器。在请求-响应式处理框架中,过滤器发挥着重要的作用,它用来实现对请求的拦截,并定义认证和授权逻辑。同时,我们也可以根据需要实现各种自定义的过滤器组件,从而实现对 Spring Security 的动态扩展。本讲对 Spring Security 中的过滤器架构和开发方式都做了详细的介绍,你可以反复学习。

本讲内容总结如下:

Drawing 6.png

最后,给你留一道思考题:在 Spring Security 中,你能简单描述使用过滤器实现用户认证的操作过程吗?欢迎你在留言区和我分享自己的观点。

介绍完过滤器机制,下一讲,让我们把目光转向攻击应对部分,讨论 Spring Security 框架如何应对跨站请求伪造攻击,以及如何实现跨域资源共享。


攻击应对:如何实现 CSRF 保护和跨域 CORS?

现在我们已经掌握了 Spring Security 提供的多项核心功能,但正如在开篇词中提到的,我们面临的系统安全性问题不止如此。今天我们就来讨论在日常开发过程中常见的两个安全性话题,即 CSRF 和 CORS。这两个缩写名称看似陌生,但和应用程序的每次请求都有关联,Spring Security 对它们也提供了良好的开发支持。

使用 Spring Security 提供 CSRF 保护

我们先来看 CSRF。CSRF 的全称是 Cross-Site Request Forgery,翻译成中文就是跨站请求伪造。那么,究竟什么是跨站请求伪造,面对这个问题我们又该如何应对呢?请继续往下看。

什么是 CSRF?

从安全的角度来讲,你可以将 CSRF 理解为一种攻击手段,即攻击者盗用了你的身份,然后以你的名义向第三方网站发送恶意请求。我们可以使用如下所示的流程图来描述 CSRF:

Drawing 0.png

CSRF 运行流程图

具体流程如下:

  • 用户浏览并登录信任的网站 A,通过用户认证后,会在浏览器中生成针对 A 网站的 Cookie;

  • 用户在没有退出网站 A 的情况下访问网站 B,然后网站 B 向网站 A 发起一个请求;

  • 用户浏览器根据网站 B 的请求,携带 Cookie 访问网站 A;

  • 由于浏览器会自动带上用户的 Cookie,所以网站 A 接收到请求之后会根据用户具备的权限进行访问控制,这样相当于用户本身在访问网站 A,从而网站 B 就达到了模拟用户访问网站 A 的操作过程。

显然,从应用程序开发的角度来讲,CSRF 就是系统的一个安全漏洞,这种安全漏洞也在 Web 开发中广泛存在。

基于 CSRF 的工作流程,进行 CSRF 保护的基本思想就是为系统中的每一个连接请求加上一个随机值,我们称之为 csrf_token。这样,当用户向网站 A 发送请求时,网站 A 在生成的 Cookie 中就会设置一个 csrf_token 值。而在浏览器发送请求时,提交的表单数据中也有一个隐藏的 csrf_token 值,这样网站 A 接收到请求后,一方面从 Cookie 中提取出 csrf_token,另一方面也从表单提交的数据中获取隐藏的 csrf_token,将两者进行比对,如果不一致就代表这就是一个伪造的请求。

使用 CsrfFilter

在 Spring Security 中,专门提供了一个 CsrfFilter 来实现对 CSRF 的保护。CsrfFilter 拦截请求,并允许使用 GET、HEAD、TRACE 和 OPTIONS 等 HTTP 方法的请求。而针对 PUT、POST、DELETE 等可能会修改数据的其他请求,CsrfFilter 则希望接收包含 csrf_token 的消息头。如果这个消息头不存在或包含不正确的 csrf_token 值,应用程序将拒绝该请求并将响应的状态设置为 403。

看到这里,你可能会问,这个 csrf_token 到底长什么样子呢?其实它本质上就是一个字符串。在 Spring Security 中,专门定义了一个 CsrfToken 接口来约定它的格式:

public interface CsrfToken extends Serializable {
 
    //获取消息头名称
    String getHeaderName();
 
    //获取应该包含 Token 的参数名称
    String getParameterName();
	 
	//获取具体的 Token 值
    String getToken();
}

而在 CsrfFilter 类中,我们也找到了如下所示的针对 CsrfToken 的处理过程:

@Override
protected void doFilterInternal(HttpServletRequest request,
             HttpServletResponse response, FilterChain filterChain)
                     throws ServletException, IOException {
        request.setAttribute(HttpServletResponse.class.getName(), response);
 
        //从 CsrfTokenRepository 中获取 CsrfToken
        CsrfToken csrfToken = this.tokenRepository.loadToken(request);
        final boolean missingToken = csrfToken == null;
 
        //如果找不到 CsrfToken 就生成一个并保存到 CsrfTokenRepository 中
        if (missingToken) {
             csrfToken = this.tokenRepository.generateToken(request);
             this.tokenRepository.saveToken(csrfToken, request, response);
        }
 
        //在请求中添加 CsrfToken
        request.setAttribute(CsrfToken.class.getName(), csrfToken);
        request.setAttribute(csrfToken.getParameterName(), csrfToken);
 
        if (!this.requireCsrfProtectionMatcher.matches(request)) {
             filterChain.doFilter(request, response);
             return;
        }
 
        //从请求中获取 CsrfToken
        String actualToken = request.getHeader(csrfToken.getHeaderName());
        if (actualToken == null) {
             actualToken = request.getParameter(csrfToken.getParameterName());
        }
 
        //如果请求所携带的 CsrfToken 与从 Repository 中获取的不同,则抛出异常
        if (!csrfToken.getToken().equals(actualToken)) {
             if (this.logger.isDebugEnabled()) {
                 this.logger.debug("Invalid CSRF token found for "
                         + UrlUtils.buildFullRequestUrl(request));
             }
             if (missingToken) {
                 this.accessDeniedHandler.handle(request, response,
                         new MissingCsrfTokenException(actualToken));
             }
             else {
                 this.accessDeniedHandler.handle(request, response,
                         new InvalidCsrfTokenException(csrfToken, actualToken));
             }
             return;
        }
        
        //正常情况下继续执行过滤器链的后续流程
        filterChain.doFilter(request, response);
}

整个过滤器执行流程还是比较清晰的,基本就是围绕 CsrfToken 的校验工作。我们注意到这里引入了一个 CsrfTokenRepository,这个 Repository 组件实现了对 CsrfToken 的存储管理,其中就包含前面提到的专门针对 Cookie 的 CookieCsrfTokenRepository。从 CookieCsrfTokenRepository 中,首先我们能看到一组常量定义,包括针对 CSRF 的 Cookie 名称、参数名称以及消息头名称,如下所示:

static final String DEFAULT_CSRF_COOKIE_NAME = "XSRF-TOKEN";
static final String DEFAULT_CSRF_PARAMETER_NAME = "_csrf";
static final String DEFAULT_CSRF_HEADER_NAME = "X-XSRF-TOKEN";

CookieCsrfTokenRepository 的 saveToken() 方法也比较简单,就是基于 Cookie 对象进行了 CsrfToken 的设置工作,如下所示:

@Override
public void saveToken(CsrfToken token, HttpServletRequest request,
             HttpServletResponse response) {
        String tokenValue = token == null ? "" : token.getToken();
        Cookie cookie = new Cookie(this.cookieName, tokenValue);
        cookie.setSecure(request.isSecure());
        if (this.cookiePath != null && !this.cookiePath.isEmpty()) {
                 cookie.setPath(this.cookiePath);
        } else {
                 cookie.setPath(this.getRequestContext(request));
        }
        if (token == null) {
             cookie.setMaxAge(0);
        }
        else {
             cookie.setMaxAge(-1);
        }
        cookie.setHttpOnly(cookieHttpOnly);
        if (this.cookieDomain != null && !this.cookieDomain.isEmpty()) {
             cookie.setDomain(this.cookieDomain);
        }
 
        response.addCookie(cookie);
}

在 Spring Security 中,CsrfTokenRepository 接口具有一批实现类,除了 CookieCsrfTokenRepository,还有 HttpSessionCsrfTokenRepository 等,这里不再一一展开。

了解了 CsrfFilter 的基本实现流程,下面我们继续讨论如何使用它来实现 CSRF 保护。从 Spring Security 4.0 开始,默认启用 CSRF 保护,以防止 CSRF 攻击应用程序。Spring Security CSRF 会针对 POST、PUT 和 DELETE 方法进行防护。因此,对于开发人员而言,实际上你并不需要做什么额外工作就能使用这个功能了。当然,如果你不想使用这个功能,也可以通过如下配置方法进行关闭:

http.csrf().disable();

定制化 CSRF 保护

根据前面的讨论,如果你想获取 HTTP 请求中的 CsrfToken,只需要使用如下所示的代码:

CsrfToken token = (CsrfToken)request.getAttribute("_csrf");

如果你不想使用 Spring Security 内置的存储方式,而是想基于自身需求把 CsrfToken 存储起来,要做的事情就是实现 CsrfTokenRepository 接口。这里我们尝试把 CsrfToken 保存到关系型数据库中,所以可以通过扩展 Spring Data 中的 JpaRepository 来定义一个 JpaTokenRepository,如下所示:

public interface JpaTokenRepository extends JpaRepository<Token, Integer> {
 
    Optional<Token> findTokenByIdentifier(String identifier);
}

JpaTokenRepository 很简单,只有一个根据 identifier 获取 Token 的查询方法,而新增接口则是 JpaRepository 默认提供的,我们可以直接使用。

然后,我们基于 JpaTokenRepository 来构建一个 DatabaseCsrfTokenRepository,如下所示:

public class DatabaseCsrfTokenRepository
        implements CsrfTokenRepository {
 
    @Autowired
    private JpaTokenRepository jpaTokenRepository;
 
    @Override
    public CsrfToken generateToken(HttpServletRequest httpServletRequest) {
        String uuid = UUID.randomUUID().toString();
        return new DefaultCsrfToken("X-CSRF-TOKEN", "_csrf", uuid);
    }
 
    @Override
    public void saveToken(CsrfToken csrfToken, HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse) {
        String identifier = httpServletRequest.getHeader("X-IDENTIFIER");
        Optional<Token> existingToken = jpaTokenRepository.findTokenByIdentifier(identifier);
 
        if (existingToken.isPresent()) {
            Token token = existingToken.get();
            token.setToken(csrfToken.getToken());
        } else {
            Token token = new Token();
            token.setToken(csrfToken.getToken());
            token.setIdentifier(identifier);
            jpaTokenRepository.save(token);
        }
    }
 
    @Override
    public CsrfToken loadToken(HttpServletRequest httpServletRequest) {
        String identifier = httpServletRequest.getHeader("X-IDENTIFIER");
        Optional<Token> existingToken = jpaTokenRepository.findTokenByIdentifier(identifier);
 
        if (existingToken.isPresent()) {
            Token token = existingToken.get();
            return new DefaultCsrfToken("X-CSRF-TOKEN", "_csrf", token.getToken());
        }
 
        return null;
    }
}

DatabaseCsrfTokenRepository 类的代码基本都是自解释的,这里借助了 HTTP 请求中的“X-IDENTIFIER”请求头来确定请求的唯一标识,从而将这一唯一标识与特定的 CsrfToken 关联起来。然后我们使用 JpaTokenRepository 完成了针对关系型数据库的持久化工作。

最后,想要上述代码生效,我们需要通过配置方法完成对 CSRF 的设置,如下所示,这里直接通过 csrfTokenRepository 方法集成了自定义的 DatabaseCsrfTokenRepository:


@Override
protected void configure(HttpSecurity http) throws Exception {
        http.csrf(c -> {
            c.csrfTokenRepository(databaseCsrfTokenRepository());
        });
        …
}

作为总结,我们可以用如下所示的示意图来梳理整个定制化 CSRF 所包含的各个组件以及它们之间的关联关系:

Drawing 1.png

定制化 CSRF 的相关组件示意图

使用 Spring Security 实现 CORS

介绍完 CSRF,我们继续来看 Web 应用程序开发过程中另一个常见的需求——CORS,即跨域资源共享(Cross-Origin Resource Sharing)。那么问题来了,什么叫跨域?

什么是 CORS?

当下的 Web 应用程序开发基本都采用了前后端分离的开发模式,数据的获取并非同源,所以跨域问题在我们日常开发中特别常见。例如,当我们从“test.com”这个域名发起请求时,浏览器为了一定的安全因素考虑,并不会允许请求去访问“api.test.com”这个域名,因为请求已经跨越了两个域名。

请注意,跨域是浏览器的一种同源安全策略,是浏览器单方面限制的,所以仅在客户端运行在浏览器中才需要考虑这个问题。从原理上讲,实际就是浏览器在 HTTP 请求的消息头部分新增一些字段,如下所示:

//浏览器自己设置的请求域名
Origin     
//浏览器告诉服务器请求需要用到哪些 HTTP 方法
Access-Control-Request-Method
//浏览器告诉服务器请求需要用到哪些 HTTP 消息头
Access-Control-Request-Headers

当浏览器进行跨域请求时会和服务器端进行一次的握手协议,从响应结果中可以获取如下信息:

//指定哪些客户端的域名允许访问这个资源
Access-Control-Allow-Origin 
//服务器支持的 HTTP 方法
Access-Control-Allow-Methods 
//需要在正式请求中加入的 HTTP 消息头
Access-Control-Allow-Headers 

因此,实现 CORS 的关键是服务器。只要服务器合理设置这些响应结果中的消息头,就相当于实现了对 CORS 的支持,从而支持跨源通信。

使用 CorsFilter

和 CsrfFilter 注解一样,在 Spring 中也存在一个 CorsFilter 过滤器,不过这个过滤器并不是 Spring Security 提供的,而是来自Spring Web MVC。在 CorsFilter 这个过滤器中,首先应该判断来自客户端的请求是不是一个跨域请求,然后根据 CORS 配置来判断该请求是否合法,如下所示:

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
             FilterChain filterChain) throws ServletException, IOException {
 
        if (CorsUtils.isCorsRequest(request)) {
             CorsConfiguration corsConfiguration = this.configSource.getCorsConfiguration(request);
             if (corsConfiguration != null) {
                 boolean isValid = this.processor.processRequest(corsConfiguration, request, response);
                 if (!isValid || CorsUtils.isPreFlightRequest(request)) {
                     return;
                 }
             }
        }
 
        filterChain.doFilter(request, response);
}

上述操作的内容是创建合适的配置类 CorsConfiguration。根据 CorsFilter,Spring Security 也在 HttpSecurity 工具类通过提供了 cors() 方法来创建 CorsConfiguration,使用方式如下所示:

@Override
protected void configure(HttpSecurity http) throws Exception {
        http.cors(c -> {
            CorsConfigurationSource source = request -> {
                CorsConfiguration config = new CorsConfiguration();
                config.setAllowedOrigins(Arrays.asList("*"));
                config.setAllowedMethods(Arrays.asList("*"));
                return config;
            };
            c.configurationSource(source);
        });
        …
}

我们可以通过 setAllowedOrigins() 和 setAllowedMethods() 方法实现对 HTTP 响应消息头的设置。这里将它们都设置成“*”,意味着所有请求都可以进行跨域访问。你也可以根据需要设置特定的域名和 HTTP 方法。

使用 @CrossOrigin 注解

通过 CorsFilter,我们实现了全局级别的跨域设置。但有时候,我们可能只需要针对某些请求实现这一功能,通过 Spring Security 也是可以做到这一点的,我们可以在特定的 HTTP 端点上使用如下所示的 @CrossOrigin 注解:

@Controller
public class TestController {
        
    @PostMapping("/hello")
	@CrossOrigin("http://api.test.com:8080")
    public String hello() {
        return "hello";
    }
}

默认情况下,@CrossOrigin 注解允许使用所有的域和消息头,同时会将 Controller 中的方法映射到所有的 HTTP 方法。

小结与预告

这一讲关注的是对 Web 请求安全性的讨论,我们讨论了日常开发过程中常见的两个概念,即 CSRF 和 CORS。这两个概念有时候容易混淆,但应对的是完全不同的两种场景。

CSRF 是一种攻击行为,所以我们需要对系统进行保护,而 CORS 更多的是一种前后端开发模式上的约定。在 Spring Security 中,针对这两个场景都提供了对应的过滤器,我们只需要通过简单的配置方法就能在系统中自动集成想要的功能。

本讲主要内容如下:

Drawing 2.png

最后我想给你留一道思考题:在 Spring Security 中,如何定制化一套对 CsrfToken 的处理机制?欢迎你在留言区和我分享观点。

介绍完只有在 Web 应用程序中才会遇到的 CSRF 和 CORS,下一讲,我们将面对非 Web 类的应用程序,看看如何在这类应用程序中开展全局方法级别的安全访问控制。


全局方法:如何确保方法级别的安全访问?

到目前为止,我们已经系统介绍了 Spring Security 中的认证和授权过程。但是请注意,我们讨论的对象是 Web 应用程序,也就是说认证和授权的资源是一系列的 HTTP 端点。那么如果我们开发的不是一个 Web 应用程序呢?认证和授权还能否发挥作用呢?答案是肯定的。今天我们就来讨论针对方法级别的安全访问策略,确保一个普通应用程序中的每个组件都能具备安全性保障。

全局方法安全机制

明确方法级别的安全机制之前,我们先来剖析一个典型的应用程序具备的各层组件。以 Spring Boot 应用程序为例,我们可以采用经典的分层架构,即将应用程序分成 Web 层、Service 层和 Repository 层。请注意,三层架构中的 Service 层组件可能还会调用其他的第三方组件

在各层组件中,围绕某个业务链路提供了对应的实现方法,我们可以针对这些方法开展安全控制。因此,你可以认为这种安全控制不仅面向 Web 层组件,而且是全局方法级别的,所以也被称为全局方法安全(Global Method Security)机制。

那么,全局方法安全机制能为我们带来什么价值呢?通常包括两个方面,即方法调用授权和方法调用过滤

方法调用授权的含义很明确,与端点级别的授权机制一样,我们可以用它来确定某个请求是否具有调用方法的权限。如果是在方法调用之前进行授权管理,就是预授权(PreAuthorization);如果是在方法执行完成后来确定是否可以访问方法返回的结果,一般称之为后授权(PostAuthorization)。

方法调用过滤本质上类似于过滤器机制,也可以分为 PreFilter 和 PostFilter 两大类。其中预过滤(PreFilter)用来对该方法的参数进行过滤,从而获取其参数接收的内容,而后过滤(PostFilter)则用来判断调用者可以在方法执行后从方法返回结果中接收的内容。

请注意,默认情况下 Spring Security 并没有启用全局方法安全机制。因此,想要启用这个功能,我们需要使用**@EnableGlobalMethodSecurity 注解**。正如本专栏前面案例所展示的,一般的做法是创建一个独立的配置类,并把这个注解添加在配置类上,如下所示:

@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig 

请注意,在使用 @EnableGlobalMethodSecurity 注解时,我们设置了“prePostEnabled”为 true,意味着我们启用了 Pre/PostAuthorization 注解,而默认情况下这些注解也是不生效的。同时,我们也需要知道,在 Spring Security 中为实现全局方法安全机制提供了三种实现方法,除了 Pre/PostAuthorization 注解之外,还可以使用基于 JSR 250 规范的 @RolesAllowed 注解和 @Secured 注解。在本专栏中,我们只讨论最常用的 Pre/PostAuthorization 注解,下面我们来看具体的使用方法。

使用注解实现方法级别授权

针对方法级别授权,Spring Security 提供了 @PreAuthorize 和 @PostAuthorize 这两个注解,分别用于预授权和后授权。

@PreAuthorize 注解

先来看 @PreAuthorize 注解的使用场景。假设在一个基于 Spring Boot 的 Web 应用程序中,存在一个 Web 层组件 OrderController,该 Controller 会调用 Service 层的组件 OrderService。我们希望对访问 OrderService 层中方法的请求添加权限控制能力,即只有具备“DELETE”权限的请求才能执行 OrderService 中的 deleteOrder() 方法,而没有该权限的请求将直接抛出一个异常,如下图所示:

Drawing 0.png

Service 层组件预授权示意图

显然,上述流程针对的是预授权的应用场景,因此我们可以使用 @PreAuthorize 注解,

该注解定义如下:

@Target({ ElementType.METHOD, ElementType.TYPE })
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
public @interface PreAuthorize {
 
    //通过SpEL表达式设置访问控制
    String value();
}

可以发现,@PreAuthorize 注解与 05 讲“访问授权:如何对请求的安全访问过程进行有效配置?”中介绍的 access() 方法的原理是一样的,都是通过传入一个 SpEL 表达式来设置访问控制规则

要想在应用程序中集成 @PreAuthorize 注解,我们可以创建如下所示的安全配置类,在这个配置类上我们添加了 @EnableGlobalMethodSecurity 注解:

@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig {
 
    @Bean
    public UserDetailsService userDetailsService() {
        UserDetailsService service = new InMemoryUserDetailsManager();
 
        UserDetails u1 = User.withUsername("jianxiang1")
                .password("12345")
                .authorities("WRITE")
                .build();
 
        UserDetails u2 = User.withUsername("jianxiang2")
                .password("12345")
                .authorities("DELETE")
                .build();
 
        service.createUser(u1);
        service.createUser(u2);
 
        return service;
    }
 
    @Bean
    public PasswordEncoder passwordEncoder() {
        return NoOpPasswordEncoder.getInstance();
    }
}

这里,我们创建了两个用户“jianxiang1”和“jianxiang2”,分别具备“WRITE”和“DELETE”权限。然后,我们实现 OrderService 的 deleteOrder() 方法,如下所示:

@Service
public class OrderService {
 
    @PreAuthorize("hasAuthority('DELETE')")
    public void deleteOrder(String orderId) {
        …
    }
}

可以看到,这里使用了 @PreAuthorize 注解来实现预授权。在该注解中,我们通过熟悉的 hasAuthority('DELETE') 方法来判断请求是否具有“DELETE”权限。

上面介绍的这种情况比较简单,我们再来看一个比较复杂的场景,该场景与用户认证过程进行整合。

假设在 OrderService 中存在一个 getOrderByUser(String user) 方法,而出于系统安全性的考虑,我们希望用户只能获取自己创建的订单信息,也就是说我们需要校验通过该方法传入的“user”参数是否为当前认证的合法用户。这种场景下,我们就可以使用 @PreAuthorize 注解:

@PreAuthorize("#name == authentication.principal.username")
public List<Order> getOrderByUser(String user) {
        …
}

这里我们将输入的“user”参数与通过 SpEL 表达式从安全上下文中获取的“authentication.principal.username”进行比对,如果相同就执行正确的方法逻辑,反之将直接抛出异常。

@PostAuthorize 注解

相较 @PreAuthorize 注解,@PostAuthorize 注解的应用场景可能少见一些。有时我们允许调用者正确调用方法,但希望该调用者不接受返回的响应结果。这听起来似乎有点奇怪,但在那些访问第三方外部系统的应用中,我们并不能完全相信返回数据的正确性,也有对调用的响应结果进行限制的需求,@PostAuthorize 注解为我们实现这类需求提供了很好的解决方案,如下所示:

Drawing 1.png

Service 层组件后授权示意图

为了演示 @PostAuthorize 注解,我们先来设定特定的返回值。假设我们存在如下所示的一个 Author 对象,保存着该作者的姓名和创作的图书作品:

public class Author {
    private String name;
    private List<String> books;
}

进一步,我们假设系统中保存着如下所示的两个 Author 对象:

Map<String, Author> authors =
    Map.of("AuthorA", new Author("AuthorA ",List.of("BookA1", “BookA2)),"AuthorB", new Author("AuthorB", List.of("BookB1")
	)
);

现在,我们有这样一个根据姓名获取 Author 对象的查询方法:

@PostAuthorize("returnObject.books.contains('BookA2')")
public Author getAuthorByNames(String name) {
    return authors.get(name);
}

可以看到,通过使用 @PostAuthorize 注解,我们就能根据返回值来决定授权的结果。在这个示例中,借助于代表返回值的“returnObject”对象,如果我们使用创作了“BookA2”的“AuthorA”来调用这个方法,就能正常返回数据;如果使用“AuthorB”,就会报 403 异常。

使用注解实现方法级别过滤

针对方法级别过滤,Spring Security 同样提供了一对注解,即分别用于预过滤和后过滤的 @PreFilter 和 @PostFilter。

@PreFilter 注解

在介绍使用 @PreFilter 注解实现方法级别过滤之前,我们先要明确它与 @PreAuthorize 注解之间的区别。通过预授权,如果方法调用的参数不符合权限规则,那么这个方法就不会被调用。而使用预过滤,方法调用是一定会执行的,但只有那些符合过滤规则的数据才会正常传递到调用链路的下一层组件。

接下来我们看 @PreFilter 注解的使用方法。我们设计一个新的数据模型,并构建如下所示的 Controller 层方法:

@Autowired
private ProductService productService;
 
@GetMapping("/sell")
public List<Product> sellProduct() {
        List<Product> products = new ArrayList<>();
 
        products.add(new Product("p1", "jianxiang1"));
        products.add(new Product("p2", "jianxiang2"));
        products.add(new Product("p3", "jianxiang3"));
 
        return productService.sellProducts(products);
}

上面代码中的 Product 对象包含了商品的编号和用户名。然后,我们来到 Service 层组件,实现如下所示的方法:

@PreFilter("filterObject.name == authentication.name")
public List<Product> sellProducts(List<Product> products) {
        return products;
}

这里我们使用了 @PreFilter 注解对输入数据进行了过滤。通过使用“filterObject”对象,我们可以获取输入的 Product 数据,然后将“filterObject.name”字段与从安全上下文中获取的“authentication.name”进行比对,就能将那些不属于当前认证用户的数据进行过滤。

@PostFilter 注解

同样,为了更好地理解 @PostFilter 注解的含义,我们也将它与 @PostAuthorize 注解进行对比。类似的,通过后授权,如果方法调用的参数不符合权限规则,那么这个方法就不会被调用。如果使用后过滤,方法调用也是一定会执行的,但只有那些符合过滤规则的数据才会正常返回。

@PostFilter 注解的使用方法也很简单,示例如下:

@PostFilter("filterObject.name == authentication.principal.username")
public List<Product> findProducts() {
        List<Product> products = new ArrayList<>();
 
        products.add(new Product("p1", "jianxiang1"));
        products.add(new Product("p2", "jianxiang2"));
        products.add(new Product("p3", "jianxiang3"));
 
        return products;
}

通过 @PostFilter,我们指定了过滤的规则为"filterObject.name == authentication.principal.username",也就是说该方法只会返回那些属于当前认证用户的数据,其他用户的数据会被自动过滤。

通过上述案例,你可能已经认识到了各个注解之间的微妙关系。比方说,@PreFilter 注解的效果实际上和 @PostAuthorize 注解的效果有点类似,但两者针对数据的处理方向是相反的,即 @PreFilter 注解控制从 Controller 层到 Service 层的数据输入,而 @PostAuthorize 反过来限制了从 Service 层到 Controller 层的数据返回。在日常开发过程中,你需要关注业务场景下数据的流转方向,才能正确选择合适的授权或过滤注解。

小结与预告

这一讲我们关注的重点从 HTTP 端点级别的安全控制转换到了普通方法级别的安全控制。Spring Security 内置了一组非常实用的注解,方便开发人员实现全局方法安全机制,包括用于实现方法级别授权的 @PreAuthorize 和 @PostAuthorize 注解,以及用于实现方法级别过滤的 @PreFilter 注解和 @PostFilter 注解。我们针对这些注解的使用方法也给出了相应的描述和示例代码。

本讲内容总结如下:

Drawing 2.png

这里给你留一道思考题:针对 Spring Security 提供的全局方法安全机制,你能描述方法级别授权和方法级别过滤的区别以及它们各自的应用场景吗?欢迎在留言区写下你的想法。

介绍完今天的内容,我们针对 Spring Security 的高级主题篇也告一段落了。下一讲,我们就来结合这些高级主题设计并真正实现一个案例系统。


举报

相关推荐

0 条评论