Spring 和 Angular JS:一个安全的单页应用

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

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

在本文中,我们将展示 Spring Security、Spring Boot 和 Angular JS 协同工作的一些出色特性,以提供愉快且安全的用户体验。本文对于 Spring 和 Angular JS 的初学者应该很容易理解,但对于其中任何一个的专家来说,也有很多有用的细节。这实际上是关于 Spring Security 和 Angular JS 系列文章中的第一篇,每篇都会陆续介绍新功能。我们将在第二篇及后续文章中改进此应用,但在此之后的主要更改是架构上的,而非功能上的。

Spring 和单页应用

HTML5、丰富的浏览器端特性以及“单页应用”是现代开发者极其宝贵的工具,但任何有意义的交互都会涉及后端服务器。因此,除了静态内容(HTML、CSS 和 JavaScript)之外,我们还需要一个后端服务器。后端服务器可以扮演多种角色中的任何一种或全部:提供静态内容、有时(但现在不那么频繁了)渲染动态 HTML、认证用户、保护对受保护资源的访问,以及(最后但同样重要)通过 HTTP 和 JSON 与浏览器中的 JavaScript 交互(有时被称为 REST API)。

Spring 一直以来都是构建后端特性(尤其是在企业领域)的流行技术,随着Spring Boot的出现,事情变得前所未有的简单。让我们看看如何使用 Spring Boot、Angular JS 和 Twitter Bootstrap 从零开始构建一个新的单页应用。选择这个特定的技术栈并没有特别的原因,但它相当流行,尤其是在企业 Java 开发团队中的核心 Spring 受众中,因此这是一个值得入手的起点。

创建新项目

我们将详细讲解如何创建这个应用,以便不完全熟悉 Spring 和 Angular 的任何人都能理解正在发生的事情。如果你想快速看到结果,可以跳到最后,那里有正在运行的应用,并了解它们是如何协同工作的。创建新项目有各种选项:

我们将要构建的完整项目的源代码位于此处的 Github 上,因此如果你愿意,可以直接克隆该项目并直接开始工作。然后跳到下一节

使用 Curl

创建新项目以快速入门的最简单方法是使用Spring Boot Initializr。例如,在类 UN*X 系统上使用 curl:

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

然后你可以将该项目(默认是一个普通的 Maven Java 项目)导入到你喜欢的 IDE 中,或者只在命令行中使用文件和 "mvn" 命令。然后跳到下一节

使用 Spring Boot CLI

你可以使用Spring Boot CLI 创建相同的项目,如下所示:

$ spring init --dependencies web,security ui/ && cd ui

然后跳到下一节

使用 Initializr 网站

如果你愿意,也可以直接从Spring Boot Initializr 网站获取 .zip 文件形式的相同代码。只需在浏览器中打开它,选择依赖项“Web”和“Security”,然后点击“Generate Project”。该 .zip 文件在根目录中包含一个标准的 Maven 或 Gradle 项目,因此在解压之前,你可能需要创建一个空目录。然后跳到下一节

使用 Spring Tool Suite

Spring Tool Suite(一套 Eclipse 插件)中,你也可以使用向导 File->New->Spring Starter Project 创建和导入项目。然后跳到下一节

添加首页

单页应用的核心是一个静态的 "index.html" 文件,所以让我们直接创建一个(在 "src/main/resources/static" 或 "src/main/resources/public" 目录下)。

<!doctype html>
<html>
<head>
<title>Hello AngularJS</title>
<link href="css/angular-bootstrap.css" rel="stylesheet">
<style type="text/css">
[ng\:cloak], [ng-cloak], .ng-cloak {
  display: none !important;
}
</style>
</head>

<body ng-app="hello">
  <div class="container">
    <h1>Greeting</h1>
    <div ng-controller="home" ng-cloak class="ng-cloak">
      <p>The ID is {{greeting.id}}</p>
      <p>The content is {{greeting.content}}</p>
    </div>
  </div>
  <script src="js/angular-bootstrap.js" type="text/javascript"></script>
  <script src="js/hello.js"></script>
</body>
</html>

它相当简洁,因为它只会显示 "Hello World"。

首页特性

显著特性包括:

  • <head> 中导入了一些 CSS,其中一个占位符文件尚不存在,但命名很有提示性("angular-bootstrap.css"),另一个内联样式表定义了 "ng-cloak" 类。

  • "ng-cloak" 类应用于内容 <div>,以便在 Angular JS 处理动态内容之前将其隐藏(这可以防止初次页面加载时的“闪烁”)。

  • <body> 被标记为 ng-app="hello",这意味着我们需要定义一个 JavaScript 模块,Angular 会将其识别为一个名为 "hello" 的应用。

  • 所有 CSS 类(除了 "ng-cloak")都来自 Twitter Bootstrap。一旦我们正确设置了样式表,它们会使页面看起来很美观。

  • 问候语中的内容使用 handlebar 语法标记,例如 {{greeting.content}},这将在稍后由 Angular 填充(根据外层 <div> 上的 ng-controller 指令,使用一个名为 "home" 的“控制器”)。

  • Angular JS(和 Twitter Bootstrap)包含在 <body> 的底部,这样浏览器可以在它们被处理之前先处理所有 HTML。

  • 我们还包含一个独立的 "hello.js" 文件,我们将在其中定义应用的行为。

我们稍后会创建脚本和样式表资源文件,但现在可以忽略它们尚不存在的事实。

运行应用

添加首页文件后,你的应用就可以在浏览器中加载了(尽管它还没有太多功能)。在命令行中,你可以这样做:

$ mvn spring-boot:run

并在浏览器中访问 http://localhost:8080。加载首页时,应该会弹出一个浏览器对话框,要求输入用户名和密码(用户名是 "user",密码会在启动时的控制台日志中打印)。实际上还没有内容,所以成功认证后,你应该会看到一个空白页面,上面有“Greeting”标题。

提示:如果你不喜欢从控制台日志中抓取密码,只需将以下内容添加到 "application.properties" 文件(在 "src/main/resources" 目录下):security.user.password=password(并选择你自己的密码)。我们在示例代码中使用了 "application.yml" 来完成此操作。

在 IDE 中,只需运行应用类中的 main() 方法即可(如果上面你使用了 "curl" 命令,那么只有一个类,它被称为 UiApplication)。

要打包并作为独立 JAR 运行,你可以这样做:

$ mvn package
$ java -jar target/*.jar

前端资源

关于 Angular 和其他前端技术的入门教程通常直接从互联网引入库资源(例如,Angular JS 官网本身推荐从 Google CDN 下载)。但我们不会这样做,而是通过连接这些库中的多个文件来生成 "angular-bootstrap.js" 资源。这对于让应用工作起来并不是严格必需的,但对于生产应用来说,合并脚本以避免浏览器和服务器(或内容分发网络)之间的频繁通信是最佳实践。由于我们不会修改或定制 CSS 样式表,因此也无需生成 "angular-bootstrap.css",我们也可以直接使用 Google CDN 上的静态资源。然而,在实际应用中,我们几乎肯定会想要修改样式表,并且我们不会想手动编辑 CSS 源文件,所以我们会使用更高级别的工具(例如 LessSass),因此我们也将使用其中一个。

有很多不同的方法可以做到这一点,但出于本文的目的,我们将使用 wro4j,这是一个基于 Java 的工具链,用于预处理和打包前端资源。它可以在任何 Servlet 应用中用作 JIT(Just in Time,即时)Filter,但也对 Maven 和 Eclipse 等构建工具有很好的支持,这就是我们打算使用它的方式。因此,我们将构建静态资源文件并将它们打包到我们的应用 JAR 中。

旁注:对于硬核前端开发者来说,wro4j 可能不是首选工具——他们可能会使用基于 Node 的工具链,配合 bower 和/或 grunt。这些工具无疑非常优秀,并且在互联网上有很多详细的介绍,所以如果你更喜欢它们,请随意使用。如果你只是将这些工具链的输出文件放在 "src/main/resources/static" 目录下,一切都会正常工作。我发现 wro4j 更方便,因为我不是硬核前端开发者,而且我知道如何使用基于 Java 的工具。

为了在构建时创建静态资源,我们向 Maven pom.xml 文件添加一些“魔法”配置(它相当冗长,但只是样板代码,因此可以提取到 Maven 中的父 pom 或 Gradle 中的共享任务或插件中)。

<build>
  <resources>
    <resource>
      <directory>${project.basedir}/src/main/resources</directory>
    </resource>
    <resource>
      <directory>${project.build.directory}/generated-resources</directory>
    </resource>
  </resources>
  <plugins>
    <plugin>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-maven-plugin</artifactId>
    </plugin>
    <plugin>
      <artifactId>maven-resources-plugin</artifactId>
      <executions>
        <execution>
          <!-- Serves *only* to filter the wro.xml so it can get an absolute 
            path for the project -->
          <id>copy-resources</id>
          <phase>validate</phase>
          <goals>
            <goal>copy-resources</goal>
          </goals>
          <configuration>
            <outputDirectory>${basedir}/target/wro</outputDirectory>
            <resources>
              <resource>
                <directory>src/main/wro</directory>
                <filtering>true</filtering>
              </resource>
            </resources>
          </configuration>
        </execution>
      </executions>
    </plugin>
    <plugin>
      <groupId>ro.isdc.wro4j</groupId>
      <artifactId>wro4j-maven-plugin</artifactId>
      <version>1.7.6</version>
      <executions>
        <execution>
          <phase>generate-resources</phase>
          <goals>
            <goal>run</goal>
          </goals>
        </execution>
      </executions>
      <configuration>
        <wroManagerFactory>ro.isdc.wro.maven.plugin.manager.factory.ConfigurableWroManagerFactory</wroManagerFactory>
        <cssDestinationFolder>${project.build.directory}/generated-resources/static/css</cssDestinationFolder>
        <jsDestinationFolder>${project.build.directory}/generated-resources/static/js</jsDestinationFolder>
        <wroFile>${project.build.directory}/wro/wro.xml</wroFile>
        <extraConfigFile>${basedir}/src/main/wro/wro.properties</extraConfigFile>
        <contextFolder>${basedir}/src/main/wro</contextFolder>
      </configuration>
      <dependencies>
        <dependency>
          <groupId>org.webjars</groupId>
          <artifactId>jquery</artifactId>
          <version>2.1.1</version>
        </dependency>
        <dependency>
          <groupId>org.webjars</groupId>
          <artifactId>angularjs</artifactId>
          <version>1.3.8</version>
        </dependency>
        <dependency>
          <groupId>org.webjars</groupId>
          <artifactId>bootstrap</artifactId>
          <version>3.2.0</version>
        </dependency>
      </dependencies>
    </plugin>
  </plugins>
</build>

你可以将其原封不动地复制到你的 POM 文件中,或者如果你正对照着Github 中的源码阅读,可以只浏览一下。主要要点是:

  • 我们包含了一些 webjars 库作为依赖项(jquery 和 bootstrap 用于 CSS 和样式设计,Angular JS 用于业务逻辑)。这些 jar 文件中的一些静态资源将包含在我们生成的 "angular-bootstrap.*" 文件中,但 jar 文件本身不需要与应用打包在一起。

  • Twitter Bootstrap 依赖于 jQuery,所以我们也包含了它。不使用 Bootstrap 的 Angular JS 应用则不需要 jQuery,因为 Angular 有自己版本的从 jQuery 需要的功能。

  • 生成的资源将位于 "target/generated-resources" 目录下,并且由于它在 <resources/> 部分中声明,它们将被打包到项目的输出 JAR 文件中,并在 IDE 的类路径中可用(只要我们使用 Maven 工具,例如 Eclipse 中的 m2e)。

  • wro4j-maven-plugin 插件具有一些 Eclipse 集成功能,你可以从 Eclipse Marketplace 安装(如果这是你第一次尝试,可以稍后再试——它不是完成应用所必需的)。如果你这样做,那么 Eclipse 将监视源文件并在它们更改时重新生成输出文件。如果在调试模式下运行,则更改可以立即在浏览器中重新加载。

  • wro4j 由一个 XML 配置文件控制,该文件不了解你的构建类路径,只理解绝对文件路径,因此我们必须创建一个绝对文件位置并将其插入到 wro.xml 文件中。为此,我们使用 Maven 资源过滤,这就是为什么有一个明确的 "maven-resources-plugin" 声明的原因。

这就是我们需要对 POM 文件进行的所有更改。剩下的就是添加 wro4j 构建文件了,我们指定它们将位于 "src/main/wro" 目录下。

Wro4j 源文件

如果你查看Github 中的源代码,会看到只有 3 个文件(其中一个为空,留待后续定制)。

  • wro.properties 是 wro4j 预处理和渲染引擎的配置文件。你可以用它来开启或关闭工具链的各个部分。在本例中,我们用它来从 Less 编译 CSS 并压缩 JavaScript,最终将所有所需库的源代码合并到两个文件中。

      preProcessors=lessCssImport
      postProcessors=less4j,jsMin
    
  • wro.xml 声明了一个名为 "angular-bootstrap" 的单一资源“组”,这将成为生成的静态资源的基础名称。它包含对我们添加的 webjars 中 <css><js> 元素的引用,以及对本地源文件 main.less 的引用。

      <groups xmlns="http://www.isdc.ro/wro">
        <group name="angular-bootstrap">
        <css>webjar:bootstrap/3.2.0/less/bootstrap.less</css>   
          <css>file:${project.basedir}/src/main/wro/main.less</css>
          <js>webjar:jquery/2.1.1/jquery.min.js</js>
          <js>webjar:bootstrap/3.2.0/bootstrap.js</js>
          <js>webjar:angularjs/1.3.8/angular.min.js</js>
        </group>
      </groups>
    
  • main.less 是空的,但可用于定制外观,更改 Twitter Bootstrap 中的默认设置。例如,要将颜色从默认蓝色更改为浅粉色,你可以添加一行代码:@brand-primary: #de8579;

将这些文件复制到你的项目中并运行 "mvn package",你应该会看到 "bootstrap-angular.*" 资源出现在你的 JAR 文件中。如果现在运行应用,应该会看到 CSS 生效,但业务逻辑和导航仍然缺失。

创建 Angular 应用

让我们创建 "hello" 应用(在 "src/main/resources/static/js/hello.js" 目录下),这样我们 "index.html" 底部的 <script/> 标签就能在正确的位置找到它。

一个最简单的 Angular JS 应用看起来像这样:

angular.module('hello', [])
  .controller('home', function($scope) {
    $scope.greeting = {id: 'xxx', content: 'Hello World!'}
})

应用的名称是 "hello",它有一个空的(且冗余的)"config" 和一个空的“控制器”,名为 "home"。当我们加载 "index.html" 时,"home" 控制器将被调用,因为我们在内容 <div> 上使用了 ng-controller="home" 指令。

注意,我们向控制器函数中注入了一个神奇的 $scope(Angular 通过命名约定进行依赖注入,并识别你的函数参数名称)。然后,在函数内部使用 $scope 为该控制器负责的 UI 元素设置内容和行为。

如果你将该文件添加到 "src/main/resources/static/js" 目录下,你的应用现在应该是安全且功能齐全的,并且会显示 "Hello World!"。greeting 对象的内容由 Angular 使用 handlebar 占位符 {{greeting.id}}{{greeting.content}} 在 HTML 中渲染。

## 添加动态内容

到目前为止,我们有一个应用,其中的问候语是硬编码的。这对于学习各部分如何协同工作很有用,但实际上我们期望内容来自后端服务器,所以让我们创建一个 HTTP 端点,用于获取问候语。在你的应用类中(位于 "src/main/java/demo" 目录下),添加 @RestController 注解并定义一个新的 @RequestMapping

@SpringBootApplication
@RestController
public class UiApplication {

  @RequestMapping("/resource")
  public Map<String,Object> home() {
    Map<String,Object> model = new HashMap<String,Object>();
    model.put("id", UUID.randomUUID().toString());
    model.put("content", "Hello World");
    return model;
  }

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

}

注意:根据你创建新项目的方式,它可能不叫 UiApplication,并且可能包含 @EnableAutoConfiguration @ComponentScan @Configuration 注解而不是 @SpringBootApplication

运行该应用并尝试使用 curl 访问 "/resource" 端点,你会发现它默认是安全的。

$ curl localhost:8080/resource
{"timestamp":1420442772928,"status":401,"error":"Unauthorized","message":"Full authentication is required to access this resource","path":"/resource"}

从 Angular 加载动态资源

那么,让我们在浏览器中获取该消息。修改 "home" 控制器,使用 XHR 加载受保护的资源:

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

我们注入了一个 $http 服务,这是 Angular 作为核心功能提供的,我们用它来 GET 我们的资源。成功时,Angular 将响应体中的 JSON 传递回一个回调函数。

再次运行应用(或者只需在浏览器中重新加载首页),你将看到带有唯一 ID 的动态消息。所以,即使资源受保护且你不能直接使用 curl 访问它,浏览器仍然能够访问内容。我们用不到一百行代码就构建了一个安全的单页应用!

注意:更改静态资源后,你可能需要强制浏览器重新加载。在 Chrome(以及安装插件的 Firefox)中,你可以使用“开发者工具”(F12),这可能就足够了。或者你可能需要使用 CTRL+F5。

## 工作原理

如果你使用一些开发者工具(通常按 F12 打开,Chrome 默认支持,Firefox 需要插件),可以在浏览器中看到浏览器和后端之间的交互。以下是一个总结:

动词 路径 状态 响应
GET / 401 浏览器提示认证
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 /resource 200 JSON 格式问候语

你可能不会看到 401 状态码,因为浏览器将首页加载视为一次单独的交互;你可能会看到 "/resource" 的 2 次请求,因为存在 CORS 协商。

仔细查看请求,你会发现所有请求都有一个 "Authorization" 请求头,类似这样:

Authorization: Basic dXNlcjpwYXNzd29yZA==

浏览器在每次请求时都会发送用户名和密码(所以记住在生产环境中只使用 HTTPS)。这与 "Angular" 没有关系,因此它适用于你选择的 JavaScript 框架或非框架。

有什么问题?

表面上看,我们似乎做得相当不错,它简洁、易于实现,所有数据都受到秘密密码的保护,而且即使我们改变前端或后端技术,它也能继续工作。但存在一些问题。

  • 基本认证仅限于用户名和密码认证。

  • 认证界面无处不在,但很丑陋(浏览器弹窗)。

  • 没有针对跨站请求伪造(CSRF)的保护措施。

CSRF 暂时对我们当前的应用来说不是问题,因为它只需要 GET 后端资源(即服务器端没有状态改变)。一旦你的应用中有 POST、PUT 或 DELETE 操作,按照任何合理的现代标准衡量,它就不再安全了。

在本系列的下一篇文章中,我们将扩展应用以使用基于表单的认证,这比 HTTP Basic 灵活得多。一旦有了表单,我们将需要 CSRF 防护,Spring Security 和 Angular 都提供了一些不错的开箱即用功能来帮助解决这个问题。剧透:我们将需要使用 HttpSession

致谢:我要感谢所有帮助我开发本系列文章的人,特别是 Rob WinchThorsten Spaeth,感谢他们仔细审阅了文章和源代码,并教给我一些我以前不知道的技巧,甚至包括我认为自己最熟悉的部分。

订阅 Spring 资讯

保持与 Spring 资讯的联系

订阅

抢先一步

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

了解更多

获取支持

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

了解更多

即将到来的活动

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

查看全部