資源伺服器:Angular JS 和 Spring Security 第三部分

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

注意:此部落格的原始碼和測試會持續演進,但此處不會維護對文字的變更。 如需最新內容,請參閱教學課程版本

在本文中,我們將繼續討論如何在「單頁應用程式」中使用 Spring SecurityAngular JS。 在這裡,我們首先將「greeting」資源分離出來,該資源在我們的應用程式中用作動態內容,分離到一個單獨的伺服器中,首先作為一個不受保護的資源,然後再用不透明的令牌保護。 這是系列文章中的第三篇,您可以通過閱讀第一篇文章,了解應用程式的基本構建塊或從頭構建它,或者您可以直接進入 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。 例如,如果我們要在 localhost 上執行新資源,它可能如下所示

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

伺服器端變更

更改 UI 伺服器非常簡單:我們只需要刪除 greeting 資源的 @RequestMapping(它是「/resource」)。 然後我們需要創建一個新的資源伺服器,我們可以像在第一篇文章中使用 Spring Boot Initializr一樣來實現。 例如,在類似 UN*X 的系統上使用 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

並在瀏覽器中轉到 http://localhost:9000,您應該會看到帶有 greeting 的 JSON。 您可以在 application.properties(在「src/main/resources」中)中嵌入埠變更

server.port: 9000

如果您嘗試在瀏覽器中從 UI(在埠 8080 上)載入該資源,您會發現它不起作用,因為瀏覽器不允許 XHR 請求。

CORS 協商

瀏覽器嘗試與我們的資源伺服器協商,以了解是否允許根據跨來源資源共享協定存取它。 這不是 Angular JS 的責任,因此就像 cookie 合約一樣,它將與瀏覽器中的所有 JavaScript 一起使用。 兩個伺服器沒有聲明它們具有共同的來源,因此瀏覽器拒絕傳送請求,並且 UI 已損壞。

為了修復它,我們需要支援 CORS 協定,該協定涉及一個「pre-flight」OPTIONS 請求和一些標頭,以列出呼叫者的允許行為。 Spring 4.2 可能有一些很好的細粒度 CORS 支援,但在發布之前,我們可以通過使用 Filter 將相同的 CORS 響應傳送到所有請求,從而為此應用程式做足夠的工作。 我們可以在與資源伺服器應用程式相同的目錄中創建一個類,並確保它是 @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() {}

}

Filter 是使用 @Order 定義的,因此它肯定會在主 Spring Security 篩選器之前應用。 通過對資源伺服器的這種更改,我們應該能夠重新啟動它並在 UI 中獲取我們的 greeting。

注意:輕率地使用 Access-Control-Allow-Origin=* 快速而骯髒,並且它有效,但它不安全,並且不以任何方式推薦。

保護資源伺服器

太好了! 我們有一個使用新架構的工作應用程式。 唯一的問題是資源伺服器沒有安全性。

新增 Spring Security

我們還可以看看如何將安全性作為篩選器層添加到資源伺服器,就像在 UI 伺服器中一樣。 這可能更傳統,並且在大多數 PaaS 環境中肯定是一個最佳選擇(因為它們通常不會將私人網路提供給應用程式)。 第一步非常簡單:只需將 Spring Security 添加到 Maven POM 中的類路徑

<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: http://localhost: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 實現,可幫助您開始自己的驗證(例如,請參閱AbstractPreAuthenticatedProcessingFilterTokenService)。 但是,Spring Security 中沒有規範的實現,其中一個原因可能是因為有一種更簡單的方法。

回顧本系列第二部分,Spring Security 預設使用 HttpSession 來儲存身份驗證資料。但它並非直接與 Session 互動,而是透過一個抽象層(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 和憑證)。

完成上述一行程式碼,並在 localhost 上執行 Redis 伺服器後,您就可以執行 UI 應用程式,使用有效的使用者憑證登入,並且 Session 資料(身份驗證和 CSRF token)將會儲存在 Redis 中。

提示:如果您沒有在本機執行 Redis 伺服器,可以使用 Docker 輕鬆啟動一個(在 Windows 或 MacOS 上需要 VM)。在 Github 原始碼中有一個 docker-compose.yml 檔案,您可以使用命令列上的 docker-compose up 輕鬆執行它。

從 UI 傳送自訂 Token

唯一缺少的部分是儲存區中資料金鑰的傳輸機制。金鑰是 HttpSession ID,因此如果我們可以在 UI 用戶端中取得該金鑰,就可以將其作為自訂標頭傳送到資源伺服器。因此,「首頁」控制器需要進行變更,以便在問候資源的 HTTP 請求中傳送標頭。例如

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

(一個更優雅的解決方案可能是根據需要抓取 token,並使用 Angular 攔截器將標頭新增到資源伺服器的每個請求。然後可以抽象化攔截器定義,而不是在一個地方完成所有操作並使業務邏輯混亂。)

我們沒有直接連到「http://localhost: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 應用程式已準備就緒,並且會針對所有對後端的呼叫在名為「X-Auth-Token」的標頭中包含 Session ID。

資源伺服器中的身份驗證

資源伺服器需要進行一個小變更才能接受自訂標頭。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")
    ...
  }

  ...
}

剩下的就是擷取資源伺服器中的自訂 token,並使用它來驗證我們的使用者。這非常簡單,因為我們只需要告訴 Spring Security Session 儲存庫的位置,以及在傳入請求中尋找 token(Session ID)的位置即可。首先,我們需要新增 Spring Session 和 Redis 依賴項目,然後我們可以設定 Filter

@SpringBootApplication
@RestController
@EnableRedisHttpSession
class ResourceApplication {

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

}

建立的這個 Filter 是 UI 伺服器中鏡像版本,因此它會將 Redis 建立為 Session 儲存區。唯一的不同是它使用自訂 HttpSessionStrategy,該策略在標頭(預設為「X-Auth-Token」)中尋找,而不是在預設值(名為「JSESSIONID」的 Cookie)中尋找。我們也需要防止瀏覽器在未經過身份驗證的用戶端中彈出對話框 - 應用程式是安全的,但預設會傳送帶有 WWW-Authenticate: Basic 的 401,因此瀏覽器會回應一個使用者名稱和密碼的對話框。有多種方法可以實現這一點,但我們已經讓 Angular 傳送「X-Requested-With」標頭,因此 Spring Security 預設會為我們處理它。

資源伺服器需要進行最後一個變更才能使用我們的新身份驗證方案。Spring Boot 預設安全性是無狀態的,我們希望將身份驗證儲存在 Session 中,因此我們需要在 application.yml(或 application.properties)中明確說明

security:
  sessions: NEVER

這告訴 Spring Security「永遠不要建立 Session,但如果存在 Session,請使用它」(因為 UI 中的身份驗證,它已經存在)。

重新啟動資源伺服器,並在新瀏覽器視窗中開啟 UI。

為什麼不能完全使用 Cookie?

我們必須使用自訂標頭並在用戶端中編寫程式碼來填充標頭,這並非非常複雜,但它似乎與第二部分中盡可能使用 Cookie 和 Session 的建議相矛盾。那裡的論點是,如果不這樣做,會引入額外的不必要複雜性,並且可以肯定的是,我們現在的實作是迄今為止我們所見過的最複雜的:解決方案的技術部分遠遠超過業務邏輯(誠然非常小)。這絕對是一個公平的批評(我們計劃在本系列的下一篇文章中解決這個問題),但讓我們簡要地看看為什麼僅使用 Cookie 和 Session 並非那麼簡單。

至少我們仍然在使用 Session,這是有道理的,因為 Spring Security 和 Servlet 容器知道如何在不費吹灰之力的情况下做到這一點。但是,我們是否可以繼續使用 Cookie 來傳輸身份驗證 token?這會很好,但有一個原因它行不通,那就是瀏覽器不允許我們這樣做。您可以直接從 JavaScript 用戶端在瀏覽器的 Cookie 儲存區中四處尋找,但有一些限制,而且這是有充分理由的。特別是,您無法存取伺服器作為「HttpOnly」傳送的 Cookie(您會發現預設就是這種情況對於 Session Cookie)。您也不能在傳出的請求中設定 Cookie,因此我們無法設定「SESSION」Cookie(這是 Spring Session 預設的 Cookie 名稱),我們必須使用自訂的「X-Session」標頭。這些限制都是為了保護您自己,因此惡意指令碼無法在未經適當授權的情况下存取您的資源。

簡而言之,UI 和資源伺服器沒有共同的來源,因此它們無法共享 Cookie(即使我們可以透過使用 Spring Session 強制它們共享 Session)。

結論

我們複製了本系列的第二部分中應用程式的功能:一個帶有從遠端後端獲取的問候語的首頁,以及導覽列中的登入和登出連結。不同之處在於,問候語來自一個獨立的資源伺服器,而不是嵌入在 UI 伺服器中。這增加了實作的複雜性,但好消息是我們有一個主要基於組態的(並且幾乎 100% 宣告式的)解決方案。我們甚至可以透過將所有新程式碼提取到程式庫(Spring 組態和 Angular 自訂指令)中,使解決方案達到 100% 宣告式。我們將把這個有趣的任務推遲到接下來的幾期之後。在下一篇文章中,我們將研究另一種非常好的方法來減少目前實作中的所有複雜性:API 閘道模式(用戶端將所有請求傳送到一個地方,並在那裡處理身份驗證)。

注意:我們在這裡使用 Spring Session 在兩個邏輯上不相同的應用程式之間共享 Session。這是一個巧妙的技巧,並且使用「常規」JEE 分散式 Session 是不可能的。

取得 Spring 電子報

透過 Spring 電子報保持連線

訂閱

搶先一步

VMware 提供培訓和認證,以加速您的進展。

了解更多

取得支援

Tanzu Spring 在一個簡單的訂閱中提供對 OpenJDK™、Spring 和 Apache Tomcat® 的支援和二進位檔案。

了解更多

即將舉行的活動

查看 Spring 社群中所有即將舉行的活動。

查看全部