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" 的應用程式。

  • 除了 "ng-cloak" 之外的所有 CSS 類別都來自 Twitter Bootstrap。一旦我們設定了正確的樣式表,它們就會讓東西看起來很漂亮。

  • greeting 中的內容使用 handlebars 進行標記,例如 {{greeting.content}},稍後將由 Angular 填寫(根據周圍 <div> 上的 ng-controller 指令,使用名為 "home" 的「控制器」)。

  • Angular JS(和 Twitter Bootstrap)包含在 <body> 的底部,以便瀏覽器可以在處理所有 HTML 之前處理它。

  • 我們還包含一個單獨的 "hello.js",我們將在其中定義應用程式行為。

我們稍後會建立 script 和 stylesheet 資源,但現在我們可以忽略它們尚不存在的事實。

執行應用程式

一旦新增了首頁檔案,您的應用程式就可以在瀏覽器中載入(即使它還沒什麼功能)。您可以在命令列中執行以下操作:

$ mvn spring-boot:run

然後在瀏覽器中前往 https://127.0.0.1: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 可能不是首選工具 - 他們可能會使用基於節點的工具鏈,使用 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 的依賴性,因此我們也包含了它。如果 Angular JS 應用程式沒有使用 Bootstrap,則不需要它,因為 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" 的單一 "group" 資源,這最終會成為產生的靜態資源的基準名稱。它包含對我們新增的 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",您應該會在您的 JAR 檔案中看到 "bootstrap-angular.*" 資源。如果您現在執行應用程式,您應該會看到 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" 的空 "controller"。當我們載入 "index.html" 時,將會呼叫 "home" 控制器,因為我們已經用 ng-controller="home" 裝飾了內容 <div>

請注意,我們將一個神奇的 $scope 注入到控制器函數中(Angular 會依命名慣例進行依賴注入,並識別您的函數參數的名稱)。然後在函數內部使用 $scope 來設定此控制器負責的 UI 元素的內容和行為。

如果您在 "src/main/resources/static/js" 下新增該檔案,您的應用程式現在應該是安全且可運作的,並且它會說 "Hello World!"。greeting 由 Angular 在 HTML 中使用 handlebar 佔位符 {{greeting.id}}{{greeting.content}} 呈現。

## 新增動態內容

到目前為止,我們有一個具有硬式編碼問候語的應用程式。這對於了解事物如何組合在一起很有用,但實際上我們期望內容來自後端伺服器,因此讓我們建立一個 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 框架或非框架選項一起使用。

那有什麼問題?

從表面上看,我們似乎做得很好,它簡潔、易於實現,我們所有的資料都受到秘密密碼的保護,如果我們更改前端或後端技術,它仍然可以工作。 但還是有一些問題。

  • 基本身份驗證僅限於使用者名稱和密碼身份驗證。

  • 身份驗證 UI 無處不在,但很醜陋(瀏覽器對話方塊)。

  • 沒有針對 跨站請求偽造 (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 社群中所有即將舉行的活動。

查看所有