資源伺服器: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 社群所有即將舉行的活動。

檢視所有