资源服务器:Angular JS 和 Spring Security 第三部分

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

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

在本文中,我们将继续 讨论 如何在“单页应用程序”中使用 Spring SecurityAngular JS。在这里,我们首先将应用程序中用作动态内容的“问候”资源分解为一个单独的服务器,先作为未受保护的资源,然后用不透明令牌进行保护。这是系列文章的第三篇,您可以通过阅读 第一篇文章 来了解应用程序的基本构建块或从头开始构建它,或者直接查看 Github 上的源代码,它分为两部分:一部分是 未受保护的资源,另一部分是 受令牌保护的资源

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

独立的资源服务器

客户端更改

在客户端,将资源移到不同的后端并没有太多工作要做。这是 上一篇文章 中“home”控制器的内容。

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

我们所需要做的就是更改 URL。例如,如果我们要在本地运行新的资源,它可能看起来像这样:

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

后端更改

UI 服务器 的更改非常简单:我们只需要删除问候资源(之前是 "/resource")的 `@RequestMapping`。然后我们需要创建一个新的资源服务器,我们可以像在 第一篇文章 中使用 Spring Boot Initializr 一样创建它。例如,在类 Unix 系统上使用 curl:

$ mkdir resource && cd resource
$ curl https://start.spring.io/starter.tgz -d style=web \
-d name=resource -d language=groovy | tar -xzvf - 

然后,您可以将该项目(默认情况下是正常的 Maven Java 项目)导入到您喜欢的 IDE 中,或者直接在命令行中使用文件和“mvn”进行操作。我们使用 Groovy 是因为我们可以,但如果您愿意,也可以使用 Java。无论如何,代码不会太多。

只需在 主应用程序类 中添加 `@RequestMapping`,并复制 旧 UI 的实现。

@SpringBootApplication
@RestController
class ResourceApplication {
	
	@RequestMapping('/')
	def home() {
		[id: UUID.randomUUID().toString(), content: 'Hello World']
	}

    static void main(String[] args) {
        SpringApplication.run ResourceApplication, args
    }

}

完成后,您的应用程序就可以在浏览器中加载了。在命令行中,您可以执行此操作:

$ mvn spring-boot:run --server.port=9000

然后在浏览器中访问 https://:9000,您应该会看到一个包含问候信息的 JSON。您可以在 `application.properties`(在“src/main/resources”中)中设置端口更改。

server.port: 9000

如果您尝试从浏览器中的 UI(端口 8080)加载该资源,您会发现它不起作用,因为浏览器不允许 XHR 请求。

CORS 协商

浏览器会尝试与我们的资源服务器进行协商,以根据 跨域资源共享 协议确定是否允许访问。这不是 Angular JS 的职责,因此就像 cookie 合约一样,它会与所有浏览器中的 JavaScript 以这种方式工作。两个服务器没有声明它们具有共同的源,因此浏览器会拒绝发送请求,UI 也会中断。

为了解决这个问题,我们需要支持 CORS 协议,这涉及到“预检” OPTIONS 请求以及一些标头来列出允许的调用者行为。Spring 4.2 可能有一些 细粒度的 CORS 支持,但在发布之前,我们可以通过发送相同的 CORS 响应给所有请求来充分满足此应用程序的需求,使用一个 `Filter`。我们可以创建一个类,将其放在资源服务器应用程序的同一目录下,并确保它是一个 `@Component`(以便它被扫描到 Spring 应用程序上下文中),例如:

@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
class CorsFilter implements Filter {

  void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) {
    HttpServletResponse response = (HttpServletResponse) res
    response.setHeader("Access-Control-Allow-Origin", "*")
    response.setHeader("Access-Control-Allow-Methods", "POST, PUT, GET, OPTIONS, DELETE")
    response.setHeader("Access-Control-Allow-Headers", "x-requested-with")
    response.setHeader("Access-Control-Max-Age", "3600")
    if (request.getMethod()!='OPTIONS') {
      chain.doFilter(req, res)
    } else {
    }
  }

  void init(FilterConfig filterConfig) {}

  void destroy() {}

}

通过 `@Order` 定义 `Filter`,以确保它在主 Spring Security 过滤器之前被应用。进行此更改后,我们应该能够重新启动资源服务器,并在 UI 中获取问候语。

注意:随意使用 `Access-Control-Allow-Origin=*` 是快速而粗糙的方法,虽然有效,但并不安全,并且不推荐使用。

保护资源服务器

太棒了!我们现在拥有了一个具有新架构的有效应用程序。唯一的问题是资源服务器没有安全性。

添加 Spring Security

我们还可以查看如何像在 UI 服务器中一样,将安全性作为过滤器层添加到资源服务器。这也许更传统,而且在大多数 PaaS 环境中无疑是最佳选择(因为它们通常不向应用程序提供专用网络)。第一步非常简单:只需在 Maven POM 中将 Spring Security 添加到类路径中:

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

重新启动资源服务器,瞧!它已受到保护。

$ curl -v localhost:9000
< HTTP/1.1 302 Found
< Location: https://:9000/login
...

我们看到重定向到一个(白标)登录页面,因为 curl 发送的标头与我们的 Angular 客户端发送的标头不同。修改命令以发送更相似的标头:

$ curl -v -H "Accept: application/json" \
    -H "X-Requested-With: XMLHttpRequest" localhost:9000
< HTTP/1.1 401 Unauthorized
...

所以,我们只需要教会客户端在每次请求时发送凭据。

令牌认证

互联网以及人们的 Spring 后端项目都充斥着自定义的基于令牌的认证解决方案。Spring Security 提供了一个基础的 `Filter` 实现供您开始(例如,请参阅 `AbstractPreAuthenticatedProcessingFilter``TokenService`)。尽管如此,Spring Security 中没有规范化的实现,其中一个原因很可能是有一个更简单的方法。

从本系列 第二部分 回忆一下,Spring Security 默认使用 `HttpSession` 来存储认证数据。但它并不直接与会话交互:之间有一个抽象层(`SecurityContextRepository`),您可以用来更改存储后端。如果我们可以将这个存储库在我们的资源服务器中指向一个经过我们 UI 验证的存储,那么我们就有了共享两个服务器之间认证的一种方法。UI 服务器已经有了这样一个存储(`HttpSession`),所以如果我们能够分发这个存储并对其开放给资源服务器,我们就接近解决方案了。

Spring Session

通过 Spring Session,解决方案的这一部分非常容易实现。我们只需要一个共享数据存储(Redis 开箱即用),以及服务器中几行配置来设置一个 `Filter`。

在 UI 应用程序中,我们需要在我们的 POM 中添加一些依赖项:

<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>

然后,将 `@EnableRedisHttpSession` 添加到您的主应用程序中:

@SpringBootApplication
@RestController
@EnableRedisHttpSession
public class UiApplication {

  public static void main(String[] args) {
    SpringApplication.run(UiApplication.class, args);
  }

  ...

}

`@EnableRedisHttpSession` 注解来自 Spring Session,而 Spring Boot 提供 Redis 连接(可以通过环境变量或配置文件配置 URL 和凭据)。

有了这 1 行代码,并且在本地运行一个 Redis 服务器,您就可以运行 UI 应用程序,使用有效的用户名和密码登录,然后会话数据(认证和 CSRF 令牌)将存储在 Redis 中。

提示:如果您本地没有运行 Redis 服务器,可以使用 Docker 轻松启动一个(在 Windows 或 MacOS 上这需要一个 VM)。在 Github 的源代码 中有一个 `docker-compose.yml` 文件,您可以在命令行中使用 `docker-compose up` 非常轻松地运行它。

从 UI 发送自定义令牌

唯一缺失的部分是数据存储中密钥的传输机制。密钥是 `HttpSession` ID,所以如果我们能在 UI 客户端获取到这个密钥,我们就可以把它作为自定义标头发送给资源服务器。因此,“home”控制器需要进行更改,以便在 HTTP 请求问候资源时发送标头。例如:

angular.module('hello', [ 'ngRoute' ])
...
.controller('home', function($scope, $http) {
  $http.get('token').success(function(token) {
    $http({
      url : 'https://:9000',
      method : 'GET',
      headers : {
        'X-Auth-Token' : token.token
      }
    }).success(function(data) {
      $scope.greeting = data;
    });
  })
});

(一个更优雅的解决方案可能是根据需要获取令牌,并使用 Angular 拦截器 将标头添加到对资源服务器的每个请求中。然后可以抽象拦截器的定义,而不是在一个地方完成所有工作并弄乱业务逻辑。)

我们没有直接转到“https://:9000”,而是将该调用包装在对 UI 服务器上新自定义端点 "/token" 的调用的成功回调中。它的实现非常简单:

@SpringBootApplication
@RestController
@EnableRedisHttpSession
public class UiApplication {

  public static void main(String[] args) {
    SpringApplication.run(UiApplication.class, args);
  }

  ...

  @RequestMapping("/token")
  @ResponseBody
  public Map<String,String> token(HttpSession session) {
    return Collections.singletonMap("token", session.getId());
  }

}

因此,UI 应用程序已准备就绪,并将把会话 ID 作为名为“X-Auth-Token”的标头包含在所有发往后端的调用中。

资源服务器中的认证

资源服务器只有一个微小的更改才能接受自定义标头。CORS 过滤器必须将该标头指定为允许的远程客户端标头,例如:

@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class CorsFilter implements Filter {

  void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
    ...
    response.setHeader("Access-Control-Allow-Headers", "x-auth-token, x-requested-with")
    ...
  }

  ...
}

剩下要做的就是在资源服务器中获取自定义令牌并用它来认证我们的用户。这出奇地简单,因为我们只需要告诉 Spring Security 会话存储在哪里,以及在传入请求中在哪里查找令牌(会话 ID)。首先,我们需要添加 Spring Session 和 Redis 依赖项,然后才能设置 `Filter`:

@SpringBootApplication
@RestController
@EnableRedisHttpSession
class ResourceApplication {

  ...
  
  @Bean
  HeaderHttpSessionStrategy sessionStrategy() {
    new HeaderHttpSessionStrategy();
  }

}

创建的这个 `Filter` 是 UI 服务器中 `Filter` 的镜像,因此它将 Redis 设置为会话存储。唯一的区别是它使用自定义的 `HttpSessionStrategy`,它在标头(默认为“X-Auth-Token”)而不是默认值(名为“JSESSIONID”的 cookie)中查找。我们还需要防止浏览器在未经验证的客户端中弹出对话框 - 应用程序是安全的,但默认情况下会发送一个带有 `WWW-Authenticate: Basic` 的 401,因此浏览器会响应一个用户名和密码对话框。实现这一点有多种方法,但我们已经让 Angular 发送了一个“X-Requested-With”标头,所以 Spring Security 默认会为我们处理它。

为了使资源服务器与我们新的认证方案一起工作,还需要进行最后一次更改。Spring Boot 默认安全是无状态的,我们希望它将认证存储在会话中,因此我们需要在 `application.yml`(或 `application.properties`)中明确说明:

security:
  sessions: NEVER

这告诉 Spring Security:“永远不要创建会话,但如果存在,则使用它”(由于 UI 中的认证,它已经存在)。

重新启动资源服务器并在新的浏览器窗口中打开 UI。

为什么所有内容都不能与 Cookie 一起工作?

我们不得不使用自定义标头并在客户端编写代码来填充标头,这并不算复杂,但似乎与 第二部分 中尽可能使用 Cookie 和会话的建议相悖。那里的论点是,不这样做会引入额外的、不必要的复杂性,并且我们现在的实现无疑是我们迄今为止看到的 সবচেয়ে 复杂的一个:解决方案的技术部分远远超过了业务逻辑(尽管它确实非常小)。这绝对是一个公平的批评(也是我们计划在下一篇文章中解决的问题),但让我们简要地看一下为什么仅仅使用 Cookie 和会话并不那么简单。

至少我们仍然在使用会话,这很有意义,因为 Spring Security 和 Servlet 容器知道如何轻松处理它。但是我们不能继续使用 Cookie 来传输认证令牌吗?这本可以很好,但它之所以不起作用,是因为浏览器不允许我们这样做。您可以通过 JavaScript 客户端随意查看浏览器 Cookie 存储,但有一些限制,并且有充分的理由。特别是,您无法访问服务器作为“HttpOnly”(您会看到这是会话 Cookie 的默认设置)发送的 Cookie。您也不能在传出请求中设置 Cookie,所以我们无法设置“SESSION”Cookie(这是 Spring Session 的默认 Cookie 名称),而必须使用自定义的“X-Session”标头。这些限制都是为了保护您,以免恶意脚本在未经适当授权的情况下访问您的资源。

TL;DR:UI 和资源服务器没有共同的源,因此它们无法共享 Cookie(即使我们可以使用 Spring Session 来强制它们共享会话)。

结论

我们已经复制了本系列 第二部分 中应用程序的功能:一个主页,其中包含从远程后端获取的问候语,并在导航栏中带有登录和注销链接。不同之处在于,问候语来自一个独立的资源服务器,而不是嵌入在 UI 服务器中。这为实现增加了相当大的复杂性,但好消息是我们有一个大部分基于配置(并且几乎 100% 声明式)的解决方案。我们甚至可以通过将所有新代码提取到库中(Spring 配置和 Angular 自定义指令)使解决方案 100% 声明式。我们将在接下来的几期之后再处理这项有趣的任务。在 下一篇文章 中,我们将研究另一种非常好的方法来减少当前实现中的所有复杂性:API 网关模式(客户端将其所有请求发送到一个地方,并在那里处理认证)。

注意:我们在这里使用了 Spring Session 来共享两个逻辑上不是同一应用程序的服务器之间的会话。这是一个巧妙的技巧,并且无法与“常规”JEE 分布式会话一起使用。

获取 Spring 新闻通讯

通过 Spring 新闻通讯保持联系

订阅

领先一步

VMware 提供培训和认证,助您加速进步。

了解更多

获得支持

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

了解更多

即将举行的活动

查看 Spring 社区所有即将举行的活动。

查看所有