API 网关模式:Angular JS 和 Spring Security 第四部分

工程 | Dave Syer | 2015 年 1 月 28 日 | ...

注意:本博客的源代码和测试仍在不断演进,但文本的更改不再在此处维护。请参阅教程版本以获取最新内容。

在本文中,我们继续讨论如何在“单页应用程序”中使用 Spring SecurityAngular JS我们在这里展示了如何使用 Spring Cloud 构建 API 网关来控制对后端资源的认证和访问。这是系列文章中的第四篇,您可以通过阅读第一篇文章来了解应用程序的基本构建块或从头开始构建,或者您也可以直接前往Github 上的源代码。在上一篇文章中,我们构建了一个使用 Spring Session 对后端资源进行认证的简单分布式应用程序。在本文中,我们将 UI 服务器转变为后端资源服务器的反向代理,解决了上次实现中的问题(自定义令牌认证引入的技术复杂性),并为我们提供了许多控制浏览器客户端访问的新选项。

提醒:如果您正在使用示例应用程序阅读本文,请务必清除浏览器缓存中的 cookie 和 HTTP Basic 凭据。在 Chrome 中,针对单个服务器执行此操作的最佳方法是打开新的无痕窗口。

创建 API 网关

API 网关是前端客户端(可以是基于浏览器的,如本文中的示例,也可以是移动端的)的单一入口点(和控制点)。客户端只需知道一个服务器的 URL,后端可以随意重构而无需更改,这是一个显著优势。在集中化和控制方面还有其他优势:速率限制、认证、审计和日志记录。使用 Spring Cloud 实现一个简单的反向代理非常简单。

如果您跟着代码一起操作,您会知道上一篇文章末尾的应用实现有些复杂,所以不太适合在此基础上继续迭代。然而,有一个更简单的中间点可以作为起点,那就是后端资源还没有用 Spring Security 进行保护。这部分的源代码是一个单独的项目在 Github 上,所以我们将从那里开始。它包含一个 UI 服务器和一个资源服务器,它们相互通信。资源服务器还没有 Spring Security,所以我们可以先让系统运行起来,然后再添加安全层。

一行代码实现声明式反向代理

为了将其转变为 API 网关,UI 服务器需要进行一个小小的调整。在 Spring 配置的某个地方,我们需要添加一个 @EnableZuulProxy 注解,例如在主要的(唯一的)应用程序类

@SpringBootApplication
@RestController
@EnableZuulProxy
public class UiApplication {
  ...
}

并在外部配置文件中,我们需要将 UI 服务器中的本地资源映射到外部配置(“application.yml”)中的远程资源

security:
  ...
zuul:
  routes:
    resource:
      path: /resource/**
      url: http://localhost:9000

这句话的意思是“将此服务器中路径模式为 /resource/** 的路径映射到远程服务器 localhost:9000 上的相同路径”。简单而有效(好吧,包括 YAML 在内是 6 行,但你并不总是需要那么多)!

要想让它工作,我们只需要类路径中有正确的东西。为此,我们在 Maven POM 中添加了几行新代码

<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>org.springframework.cloud</groupId>
      <artifactId>spring-cloud-starter-parent</artifactId>
      <version>1.0.0.BUILD-SNAPSHOT</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>
  </dependencies>
</dependencyManagement>

<dependencies>
  <dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-zuul</artifactId>
  </dependency>
  ...
</dependencies>

注意使用了 "spring-cloud-starter-zuul"——它是一个 starter POM,就像 Spring Boot 的一样,但它管理着我们这个 Zuul 代理所需的依赖项。我们还使用了 <dependencyManagement>,因为我们希望能够依赖所有传递性依赖项的正确版本。

在客户端中使用代理

完成这些更改后,我们的应用程序仍然可以工作,但在修改客户端之前,我们实际上还没有使用新的代理。幸运的是,这微不足道。我们只需要从“home”控制器的当前实现进行修改

angular.module('hello', [ 'ngRoute' ])
...
.controller('home', function($scope, $http) {
	$http.get('http://localhost:9000/').success(function(data) {
		$scope.greeting = data;
	})
});

到一个本地资源

angular.module('hello', [ 'ngRoute' ])
...
.controller('home', function($scope, $http) {
	$http.get('resource/').success(function(data) {
		$scope.greeting = data;
	})
});

现在,当我们启动服务器后,一切都正常工作,请求通过 UI(API 网关)代理到资源服务器。

进一步简化

更好的是:我们不再需要在资源服务器中使用 CORS 过滤器了。我们本来就很快地写了它,但它应该是一个警告信号,提醒我们不得不手动处理任何技术性问题(尤其是在安全方面)。幸运的是,它现在是多余的,所以我们可以直接把它扔掉,安心睡觉了!

保护资源服务器

您可能还记得,在我们开始时的中间状态下,资源服务器没有任何安全保护。

旁注:如果您的网络架构与应用程序架构相匹配(您可以使资源服务器物理上除了 UI 服务器之外任何人都无法访问),那么缺乏软件安全甚至可能不是问题。作为一个简单的演示,我们可以让资源服务器只能在 localhost 上访问。只需将此内容添加到资源服务器的 application.properties

    server.address: 127.0.0.1

哇,这太简单了!用一个仅在您的数据中心可见的网络地址来实现这一点,您就拥有了一个适用于所有资源服务器和所有用户桌面的安全解决方案。

假设我们决定确实需要在软件层面实施安全措施(出于多种原因,这很可能)。这也不会是个问题,因为我们所需要做的就是将 Spring Security 添加为依赖项(在资源服务器的 POM 中)

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-security</artifactId>
</dependency>

这足以让我们拥有一个安全的资源服务器,但它还不足以让我们拥有一个可工作的应用程序,原因与第三部分中的情况相同:两个服务器之间没有共享的认证状态。

共享认证状态

我们可以使用与上次相同的机制来共享认证(和 CSRF)状态,即 Spring Session。我们像之前一样将依赖项添加到两个服务器中

<dependency>
  <groupId>org.springframework.session</groupId>
  <artifactId>spring-session</artifactId>
  <version>1.0.0.RELEASE</version>
</dependency>
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-redis</artifactId>
</dependency>

但这次配置要简单得多,因为我们可以直接在两者中添加相同的 Filter 声明。首先是 UI 服务器(添加 @EnableRedisHttpSession

@SpringBootApplication
@RestController
@EnableZuulProxy
@EnableRedisHttpSession
public class UiApplication {

  ...

}

然后是资源服务器。需要做三个小改动:一个是向 ResourceApplication 添加 @EnableRedisHttpSession

@SpringBootApplication
@RestController
@EnableRedisHttpSession
class ResourceApplication {
  ...
}

另一个是在资源服务器中显式禁用 HTTP Basic(以防止浏览器弹出认证对话框)

@SpringBootApplication
@RestController
@EnableRedisHttpSession
class ResourceApplication extends WebSecurityConfigurerAdapter {

  ...

  @Override
  protected void configure(HttpSecurity http) throws Exception {
    http.httpBasic().disable()
    http.authorizeRequests().anyRequest().authenticated()
  }

}

旁注:另一种也能阻止认证对话框的方法是保留 HTTP Basic,但将 401 挑战改为“Basic”之外的其他内容。您可以通过在 HttpSecurity 配置回调中实现一个单行 AuthenticationEntryPoint 来做到这一点。

最后一点是显式要求在 application.properties 中使用非无状态的会话创建策略

security.sessions: NEVER

只要 redis 在后台运行(如果您愿意,可以使用 fig.yml 启动它),系统就会工作。在 http://localhost:8080 加载 UI 的主页并登录,您将看到后端消息呈现在主页上。

它是如何工作的?

现在幕后发生了什么?首先我们可以看看 UI 服务器(和 API 网关)中的 HTTP 请求

动词 路径 状态 响应
GET / 200 index.html
GET /css/angular-bootstrap.css 200 Twitter bootstrap CSS
GET /js/angular-bootstrap.js 200 Bootstrap 和 Angular JS
GET /js/hello.js 200 应用程序逻辑
GET /user 302 重定向到登录页面
GET /login 200 白标登录页(忽略)
GET /resource 302 重定向到登录页面
GET /login 200 白标登录页(忽略)
GET /login.html 200 Angular 登录表单部分
POST /login 302 重定向到主页(忽略)
GET /user 200 JSON 认证用户
GET /resource 200 (代理)JSON 问候

这与第二部分末尾的序列完全相同,只是 cookie 名称略有不同(“SESSION”代替“JSESSIONID”),因为我们使用了 Spring Session。但架构是不同的,最后一个对“/resource”的请求很特殊,因为它被代理到了资源服务器。

我们可以通过查看 UI 服务器中的“/trace”端点(来自 Spring Boot Actuator,我们与 Spring Cloud 依赖项一起添加了它)来查看反向代理的工作情况。在新的浏览器中转到http://localhost:8080/trace 并滚动到底部(如果您还没有 JSON 插件,请为您的浏览器获取一个,以便更美观易读)。您需要使用 HTTP Basic 进行认证(浏览器弹出窗口),但凭据与您的登录表单相同。在末尾或靠近末尾处,您应该看到一对类似这样的请求

注意:尝试使用不同的浏览器,这样就不会出现认证交叉(例如,如果您使用 Chrome 测试 UI,则使用 Firefox)——这不会阻止应用程序工作,但如果跟踪包含来自同一浏览器的混合认证信息,则会使跟踪更难阅读。

{
  "timestamp": 1420558194546,
  "info": {
    "method": "GET",
    "path": "/",
    "query": ""
    "remote": true,
    "proxy": "resource",
    "headers": {
      "request": {
        "accept": "application/json, text/plain, */*",
        "x-xsrf-token": "542c7005-309c-4f50-8a1d-d6c74afe8260",
        "cookie": "SESSION=c18846b5-f805-4679-9820-cd13bd83be67; XSRF-TOKEN=542c7005-309c-4f50-8a1d-d6c74afe8260",
        "x-forwarded-prefix": "/resource",
        "x-forwarded-host": "localhost:8080"
      },
      "response": {
        "Content-Type": "application/json;charset=UTF-8",
        "status": "200"
      }
    },
  }
},
{
  "timestamp": 1420558200232,
  "info": {
    "method": "GET",
    "path": "/resource/",
    "headers": {
      "request": {
        "host": "localhost:8080",
        "accept": "application/json, text/plain, */*",
        "x-xsrf-token": "542c7005-309c-4f50-8a1d-d6c74afe8260",
        "cookie": "SESSION=c18846b5-f805-4679-9820-cd13bd83be67; XSRF-TOKEN=542c7005-309c-4f50-8a1d-d6c74afe8260"
      },
      "response": {
        "Content-Type": "application/json;charset=UTF-8",
        "status": "200"
      }
    }
  }
},

第二个条目是客户端向网关发出的对“/resource”的请求,您可以看到 cookie(由浏览器添加)和 CSRF 头部(由 Angular 添加,如第二部分所述)。第一个条目有 remote: true,这意味着它正在跟踪对资源服务器的调用。您可以看到它发送到了 uri 路径“/”,并且您可以看到(至关重要的是)cookie 和 CSRF 头部也已发送。如果没有 Spring Session,这些头部对于资源服务器来说将毫无意义,但通过我们的设置方式,它现在可以使用这些头部来重建包含认证和 CSRF 令牌数据的会话。因此,请求被允许,我们就可以正常工作了!

结论

本文涵盖的内容 काफी 多,但我们达到了一个非常好的状态:我们的两个服务器的样板代码最少,它们都得到了很好的保护,并且用户体验没有受到影响。仅凭这一点就足以成为使用 API 网关模式的理由,但实际上我们才刚刚触及它可以用来做什么的表面(Netflix 将其用于很多事情)。阅读有关 Spring Cloud 的更多信息,了解如何轻松为网关添加更多功能。本系列的下一篇文章将通过将认证职责提取到单独的服务器(单点登录模式)来稍微扩展应用程序架构。

获取 Spring 新闻邮件

订阅 Spring 新闻邮件,保持联系

订阅

先人一步

VMware 提供培训和认证,助力您加速前行。

了解更多

获取支持

Tanzu Spring 提供 OpenJDK™、Spring 和 Apache Tomcat® 的支持和二进制文件,只需一个简单订阅。

了解更多

即将到来的活动

查看 Spring 社区中所有即将到来的活动。

查看全部