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

工程 | 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('https://: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

然後前往瀏覽器地址https://:9000,你應該會看到包含 greeting 的 JSON。你可以在 application.properties(在 "src/main/resources" 中)中固化埠更改

server.port: 9000

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

CORS 協商

瀏覽器會嘗試與我們的資源伺服器協商,以瞭解是否允許其根據跨域資源共享協議訪問。這不是 Angular JS 的責任,因此就像 cookie 契約一樣,它將與瀏覽器中的所有 JavaScript 以這種方式工作。這兩個伺服器並未宣告它們具有共同的源,因此瀏覽器拒絕傳送請求,UI 也就損壞了。

為了解決這個問題,我們需要支援 CORS 協議,這涉及到“預檢”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 環境中無疑是最佳選擇(因為它們通常不向應用程式提供私有網路)。第一步非常簡單:只需在 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 實現,可以幫助你開始自己的工作(例如,參見AbstractPreAuthenticatedProcessingFilterTokenService)。但 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 行程式碼,並且在 localhost 上執行著 Redis 伺服器,你就可以執行 UI 應用程式,使用一些有效的使用者憑據登入,會話資料(認證和 CSRF 令牌)將被儲存在 Redis 中。

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

從 UI 傳送自定義令牌

唯一缺少的部分是儲存中資料鍵的傳輸機制。該鍵是 HttpSession ID,因此如果我們在 UI 客戶端中獲取到該鍵,就可以將其作為自定義頭部發送給資源伺服器。因此,“home”控制器需要更改,以便在 greeting 資源的 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 應用程式已經準備就緒,並將在所有對後端請求的頭部中包含一個名為“X-Auth-Token”的會話 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")
    ...
  }

  ...
}

剩下的就是資源伺服器如何獲取自定義令牌並用它來認證我們的使用者。這相當直接,因為我們只需要告訴 Spring Security 會話儲存庫在哪裡,以及在傳入請求中在哪裡查詢令牌(會話 ID)。首先我們需要新增 Spring Session 和 Redis 的依賴項,然後就可以設定 Filter

@SpringBootApplication
@RestController
@EnableRedisHttpSession
class ResourceApplication {

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

}

這個建立的 Filter 是 UI 伺服器中過濾器的映象,它將 Redis 設定為會話儲存。唯一的區別在於它使用一個自定義的 HttpSessionStrategy,該策略預設在頭部(“X-Auth-Token”)而不是預設的 cookie(名為“JSESSIONID”)中查詢令牌。我們還需要阻止瀏覽器在未經認證的客戶端中彈出對話方塊——應用程式是安全的,但預設會發送帶有 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 強制它們共享會話)。

結論

我們已經複製了本系列第二部分中的應用程式功能:一個主頁,其中包含從遠端後端獲取的 greeting,並在導航欄中有登入和退出連結。不同之處在於,greeting 來自一個獨立的資源伺服器,而不是嵌入在 UI 伺服器中。這給實現帶來了顯著的複雜性,但好訊息是,我們有一個主要基於配置(幾乎是 100% 宣告式)的解決方案。我們甚至可以透過將所有新程式碼提取到庫中(Spring 配置和 Angular 自定義指令)來使解決方案完全宣告式。我們將把這項有趣的任務推遲到接下來的幾篇文章之後。在下一篇文章中,我們將探討另一種非常好的方法來減少當前實現中的所有複雜性:API 閘道器模式(客戶端將所有請求傳送到一個地方,並在那裡處理認證)。

注意:我們在這裡使用 Spring Session 在兩個邏輯上並非同一應用程式的伺服器之間共享會話。這是一個巧妙的技巧,傳統的 JEE 分散式會話無法實現。

獲取 Spring 通訊

訂閱 Spring 通訊以保持聯絡

訂閱

領先一步

VMware 提供培訓和認證,助力你加速進步。

瞭解更多

獲得支援

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

瞭解更多

近期活動

檢視 Spring 社群的所有近期活動。

檢視全部