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

並在瀏覽器中訪問 https://: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 社群中所有即將到來的活動。

檢視全部