搶先一步
VMware 提供培訓和認證,以加速您的進展。
了解更多注意:此部落格的原始碼和測試會持續演進,但此處不會維護對文字的變更。 如需最新內容,請參閱教學課程版本。
在本文中,我們將繼續討論如何在「單頁應用程式」中使用 Spring Security 和 Angular 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 請求。
瀏覽器嘗試與我們的資源伺服器協商,以了解是否允許根據跨來源資源共享協定存取它。 這不是 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=*
快速而骯髒,並且它有效,但它不安全,並且不以任何方式推薦。
太好了! 我們有一個使用新架構的工作應用程式。 唯一的問題是資源伺服器沒有安全性。
我們還可以看看如何將安全性作為篩選器層添加到資源伺服器,就像在 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
實現,可幫助您開始自己的驗證(例如,請參閱AbstractPreAuthenticatedProcessingFilter
和 TokenService
)。 但是,Spring Security 中沒有規範的實現,其中一個原因可能是因為有一種更簡單的方法。
回顧本系列第二部分,Spring Security 預設使用 HttpSession
來儲存身份驗證資料。但它並非直接與 Session 互動,而是透過一個抽象層(SecurityContextRepository
)來間接操作,您可以使用它來變更儲存後端。如果我們可以在資源伺服器中將該儲存庫指向一個經過 UI 驗證的身份驗證儲存區,那麼我們就能夠在兩個伺服器之間共享身份驗證。UI 伺服器已經有這樣的儲存區 (HttpSession
),因此如果我們可以將該儲存區分發並開放給資源伺服器,我們就已經完成了大部分的解決方案。
使用 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
輕鬆執行它。
唯一缺少的部分是儲存區中資料金鑰的傳輸機制。金鑰是 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 和 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 是不可能的。