$ mkdir ui && cd ui
$ curl https://start.spring.io/starter.tgz -d dependencies=web,security -d name=ui | tar -xzvf -
Spring Security 和 Angular
安全的單頁應用
在本教程中,我們將展示 Spring Security、Spring Boot 和 Angular 協同工作的一些出色功能,以提供愉悅且安全的使用者體驗。它應該對 Spring 和 Angular 的初學者來說是易於理解的,但也有大量細節對任一領域的專家都有用。這實際上是 Spring Security 和 Angular 系列文章中的第一篇,每篇都會依次展示新功能。我們將在第二篇及後續文章中改進應用程式,但此後的主要變化是架構而非功能性的。
Spring 和單頁應用
HTML5、豐富的基於瀏覽器的功能和“單頁應用”對於現代開發者來說是非常有價值的工具,但任何有意義的互動都將涉及後端伺服器。因此,除了靜態內容(HTML、CSS 和 JavaScript),我們還需要一個後端伺服器。後端伺服器可以扮演多種角色:提供靜態內容,有時(但如今不那麼常見)渲染動態 HTML,驗證使用者,保護對受保護資源的訪問,以及(最後但並非最不重要)透過 HTTP 和 JSON(有時稱為 REST API)與瀏覽器中的 JavaScript 互動。
Spring 一直是構建後端功能(尤其是在企業中)的流行技術,而隨著 Spring Boot 的出現,事情變得前所未有的簡單。讓我們看看如何使用 Spring Boot、Angular 和 Twitter Bootstrap 從零開始構建一個新的單頁應用。沒有特別的理由選擇這個特定的技術棧,但它非常流行,尤其是在企業 Java 商店的核心 Spring 使用者群中,因此它是一個有價值的起點。
建立新專案
我們將詳細介紹建立此應用程式的步驟,以便任何不完全熟悉 Spring 和 Angular 的人都能理解正在發生的事情。如果您想直接瞭解結果,可以跳到結尾,在那裡應用程式正在執行,並瞭解它們是如何協同工作的。建立新專案有多種選擇:
我們將要構建的完整專案的原始碼可以在 Github 上找到,所以如果您願意,可以直接克隆專案並從那裡開始工作。然後跳到下一節。
使用 Curl
建立新專案最簡單的方法是透過 Spring Boot Initializr。例如,在類似 UN*X 的系統上使用 curl:
然後,您可以將該專案(預設情況下是一個普通的 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 嚮導建立和匯入專案。然後跳到下一節。IntelliJ IDEA 和 NetBeans 也有類似的功能。
新增 Angular 應用
如今,Angular(或任何現代前端框架)中單頁應用程式的核心將是 Node.js 構建。Angular 有一些工具可以快速設定,所以讓我們使用這些工具,並保留使用 Maven 構建的選項,就像任何其他 Spring Boot 應用程式一樣。如何設定 Angular 應用程式的詳細資訊在此處介紹,或者您可以直接從 Github 檢出本教程的程式碼。
執行應用程式
一旦 Angular 應用程式準備就緒,您的應用程式就可以在瀏覽器中載入(儘管它還沒有做太多事情)。在命令列上,您可以執行以下操作:
$ mvn spring-boot:run
然後開啟瀏覽器訪問 https://:8080。當您載入主頁時,應該會出現一個瀏覽器對話方塊,要求輸入使用者名稱和密碼(使用者名稱是“user”,密碼在啟動時的控制檯日誌中列印)。實際上還沒有內容(或者可能是來自 ng CLI 的預設“hero”教程內容),所以您應該會得到一個基本空白的頁面。
如果您不喜歡從控制檯日誌中提取密碼,只需將其新增到“application.properties”(位於“src/main/resources”中):security.user.password=password(並選擇您自己的密碼)。我們在示例程式碼中使用了“application.yml”來完成此操作。 |
在 IDE 中,只需執行應用程式類中的 main() 方法(只有一個類,如果您使用了上面的“curl”命令,它叫做 UiApplication)。
要打包並作為獨立 JAR 執行,您可以執行以下操作:
$ mvn package
$ java -jar target/*.jar
定製 Angular 應用程式
讓我們定製“app-root”元件(在“src/app/app.component.ts”中)。
一個最小的 Angular 應用程式看起來像這樣:
import { Component } from '@angular/core';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent {
title = 'Demo';
greeting = {'id': 'XXX', 'content': 'Hello World'};
}
這個 TypeScript 中的大部分程式碼都是樣板。有趣的部分都在 AppComponent 中,我們透過 @Component 註解定義了“selector”(HTML 元素的名稱)和一段要渲染的 HTML 片段。我們還需要編輯 HTML 模板(“app.component.html”)
<div style="text-align:center"class="container">
<h1>
Welcome {{title}}!
</h1>
<div class="container">
<p>Id: <span>{{greeting.id}}</span></p>
<p>Message: <span>{{greeting.content}}!</span></p>
</div>
</div>
如果您在“src/app”下添加了這些檔案並重建了您的應用程式,它現在應該是安全且功能齊全的,並且會顯示“Hello World!”。greeting 透過 Angular 使用雙花括號佔位符 {{greeting.id}} 和 {{greeting.content}} 在 HTML 中渲染。
新增動態內容
到目前為止,我們有一個問候語硬編碼的應用程式。這對於學習如何協同工作很有用,但實際上我們期望內容來自後端伺服器,所以讓我們建立一個 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。 |
執行該應用程式並嘗試 curl "/resource" 端點,您會發現它預設是安全的:
$ curl localhost:8080/resource
{"timestamp":1420442772928,"status":401,"error":"Unauthorized","message":"Full authentication is required to access this resource","path":"/resource"}
從 Angular 載入動態資源
那麼讓我們在瀏覽器中獲取該訊息。修改 AppComponent 以使用 XHR 載入受保護的資源:
import { Component } from '@angular/core';
import { HttpClient } from '@angular/common/http';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent {
title = 'Demo';
greeting = {};
constructor(private http: HttpClient) {
http.get('resource').subscribe(data => this.greeting = data);
}
}
我們注入了一個由 Angular 透過 http 模組提供的 http 服務,並使用它來 GET 我們的資源。Angular 將響應傳遞給我們,我們提取 JSON 並將其分配給 greeting。
為了使 http 服務能夠注入到我們的自定義元件中,我們需要在包含該元件的 AppModule 中宣告它(與初稿相比,它只是 imports 中的一行):
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { AppComponent } from './app.component';
import { HttpClientModule } from '@angular/common/http';
@NgModule({
declarations: [
AppComponent
],
imports: [
BrowserModule,
HttpClientModule
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule { }
再次執行應用程式(或者只是在瀏覽器中重新載入主頁),您將看到帶有其唯一 ID 的動態訊息。所以,即使資源受到保護,您也無法直接 curl 它,但瀏覽器能夠訪問內容。我們在不到一百行程式碼中實現了一個安全的單頁應用程式!
| 您可能需要在更改靜態資源後強制瀏覽器重新載入它們。在 Chrome(以及帶有外掛的 Firefox)中,您可以使用“開發者工具”(F12),這可能就足夠了。或者您可能需要使用 CTRL+F5。 |
它是如何工作的?
如果您使用一些開發者工具(通常按 F12 即可開啟,在 Chrome 中預設工作,在 Firefox 中可能需要外掛),您可以在瀏覽器中看到瀏覽器和後端之間的互動。以下是摘要:
| 動詞 | 路徑 | 狀態 | 響應 |
|---|---|---|---|
|
GET |
/ |
401 |
瀏覽器提示進行身份驗證 |
|
GET |
/ |
200 |
index.html |
|
GET |
/*.js |
200 |
從 Angular 載入大量第三方資產 |
|
GET |
/main.bundle.js |
200 |
應用程式邏輯 |
|
GET |
/resource |
200 |
JSON 問候語 |
您可能看不到 401,因為瀏覽器將主頁載入視為單個互動,並且您可能會看到兩次請求 "/resource",因為存在 CORS 協商。
仔細檢視請求,您會發現所有請求都帶有一個“Authorization”頭,類似於:
Authorization: Basic dXNlcjpwYXNzd29yZA==
瀏覽器每次請求都發送使用者名稱和密碼(因此請記住在生產環境中 exclusively 使用 HTTPS)。這與“Angular”無關,因此它適用於您選擇的 JavaScript 框架或非框架。
有什麼問題?
表面上看,我們做得相當不錯,它簡潔,易於實現,所有資料都透過秘密密碼保護,並且即使我們更改了前端或後端技術,它仍然可以工作。但存在一些問題。
-
基本認證僅限於使用者名稱和密碼認證。
-
認證 UI 無處不在但很難看(瀏覽器對話方塊)。
-
沒有針對 跨站請求偽造 (CSRF) 的保護。
CSRF 在我們當前的應用程式中並不是真正的問題,因為它只需要 GET 後端資源(即伺服器中的狀態沒有改變)。一旦您的應用程式中存在 POST、PUT 或 DELETE,它就不再符合任何合理的現代安全標準。
在本系列的下一節中,我們將擴充套件應用程式以使用基於表單的身份驗證,這比 HTTP Basic 更靈活。一旦有了表單,我們將需要 CSRF 保護,Spring Security 和 Angular 都有一些開箱即用的出色功能來幫助解決這個問題。劇透:我們將需要使用 HttpSession。
登入頁面
在本節中,我們將繼續討論如何在“單頁應用程式”中使用 Spring Security 和 Angular。在這裡,我們將展示如何使用 Angular 透過表單驗證使用者並獲取安全資源以在 UI 中渲染。這是系列文章的第二部分,您可以閱讀第一部分來了解應用程式的基本構建模組或從頭開始構建,或者您可以直接訪問 Github 上的原始碼。在第一部分中,我們構建了一個使用 HTTP Basic 身份驗證保護後端資源的簡單應用程式。在本部分中,我們添加了一個登入表單,讓使用者對是否進行身份驗證有一些控制權,並修復了第一個迭代的問題(主要是缺乏 CSRF 保護)。
提醒:如果您正在使用示例應用程式學習本節,請務必清除瀏覽器快取中的 cookie 和 HTTP Basic 憑據。在 Chrome 中,對於單個伺服器,最好的方法是開啟一個新的無痕視窗。
向主頁新增導航
Angular 應用程式的核心是基本頁面佈局的 HTML 模板。我們已經有一個非常基本的模板,但對於這個應用程式,我們需要提供一些導航功能(登入、登出、主頁),所以讓我們修改它(在 src/app 中)
<div class="container">
<ul class="nav nav-pills">
<li><a routerLinkActive="active" routerLink="/home">Home</a></li>
<li><a routerLinkActive="active" routerLink="/login">Login</a></li>
<li><a (click)="logout()">Logout</a></li>
</ul>
</div>
<div class="container">
<router-outlet></router-outlet>
</div>
主要內容是一個 <router-outlet/>,還有一個帶有登入和登出連結的導航欄。
<router-outlet/> 選擇器由 Angular 提供,需要將其連線到主模組中的一個元件。每個路由(每個菜單鏈接)將有一個元件,以及一個輔助服務來將它們粘合在一起並共享一些狀態 (AppService)。這是將所有部分整合在一起的模組的實現:
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { HttpClientModule } from '@angular/common/http';
import { RouterModule, Routes } from '@angular/router';
import { AppService } from './app.service';
import { HomeComponent } from './home.component';
import { LoginComponent } from './login.component';
import { AppComponent } from './app.component';
const routes: Routes = [
{ path: '', pathMatch: 'full', redirectTo: 'home'},
{ path: 'home', component: HomeComponent},
{ path: 'login', component: LoginComponent}
];
@NgModule({
declarations: [
AppComponent,
HomeComponent,
LoginComponent
],
imports: [
RouterModule.forRoot(routes),
BrowserModule,
HttpClientModule,
FormsModule
],
providers: [AppService]
bootstrap: [AppComponent]
})
export class AppModule { }
我們添加了對名為 "RouterModule" 的 Angular 模組的依賴,這使我們能夠將一個神奇的 router 注入到 AppComponent 的建構函式中。routes 用於 AppModule 的匯入內部,以設定到 "/"("home" 控制器)和 "/login"("login" 控制器)的連結。
我們還偷偷加入了 FormsModule,因為稍後將需要它來將資料繫結到我們希望在使用者登入時提交的表單。
UI 元件都是“宣告”,而服務膠合劑是“提供者”。AppComponent 實際上並沒有做太多事情。與應用程式根目錄一起的 TypeScript 元件在這裡:
import { Component } from '@angular/core';
import { AppService } from './app.service';
import { HttpClient } from '@angular/common/http';
import { Router } from '@angular/router';
import 'rxjs/add/operator/finally';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent {
constructor(private app: AppService, private http: HttpClient, private router: Router) {
this.app.authenticate(undefined, undefined);
}
logout() {
this.http.post('logout', {}).finally(() => {
this.app.authenticated = false;
this.router.navigateByUrl('/login');
}).subscribe();
}
}
顯著特徵
-
還有一些依賴注入,這次是
AppService的 -
有一個登出功能作為元件的屬性暴露,我們稍後可以使用它向後端傳送登出請求。它在
app服務中設定一個標誌,並將使用者送回登入螢幕(並透過finally()回撥無條件執行此操作)。 -
我們正在使用
templateUrl將模板 HTML 外部化到一個單獨的檔案中。 -
當控制器載入時會呼叫
authenticate()函式,以檢視使用者是否已經透過身份驗證(例如,如果他在會話中斷時重新整理了瀏覽器)。我們需要authenticate()函式進行遠端呼叫,因為實際的身份驗證是由伺服器完成的,我們不希望信任瀏覽器來跟蹤它。
我們上面注入的 app 服務需要一個布林標誌,以便我們能夠判斷使用者當前是否已透過身份驗證,以及一個 authenticate() 函式,該函式可用於與後端伺服器進行身份驗證,或僅查詢其使用者詳細資訊
import { Injectable } from '@angular/core';
import { HttpClient, HttpHeaders } from '@angular/common/http';
@Injectable()
export class AppService {
authenticated = false;
constructor(private http: HttpClient) {
}
authenticate(credentials, callback) {
const headers = new HttpHeaders(credentials ? {
authorization : 'Basic ' + btoa(credentials.username + ':' + credentials.password)
} : {});
this.http.get('user', {headers: headers}).subscribe(response => {
if (response['name']) {
this.authenticated = true;
} else {
this.authenticated = false;
}
return callback && callback();
});
}
}
authenticated 標誌很簡單。authenticate() 函式在提供 HTTP Basic 身份驗證憑據時傳送它們,否則不傳送。它還有一個可選的 callback 引數,我們可以用它在身份驗證成功時執行一些程式碼。
問候語
舊主頁的問候內容可以直接放在“src/app”中的“app.component.html”旁邊:
<h1>Greeting</h1>
<div [hidden]="!authenticated()">
<p>The ID is {{greeting.id}}</p>
<p>The content is {{greeting.content}}</p>
</div>
<div [hidden]="authenticated()">
<p>Login to see your greeting</p>
</div>
由於使用者現在可以選擇是否登入(以前由瀏覽器控制),我們需要在 UI 中區分安全內容和非安全內容。我們透過新增對(尚不存在的)authenticated() 函式的引用來預測這一點。
HomeComponent 必須獲取問候語,並提供 authenticated() 實用函式,該函式從 AppService 中提取標誌。
import { Component, OnInit } from '@angular/core';
import { AppService } from './app.service';
import { HttpClient } from '@angular/common/http';
@Component({
templateUrl: './home.component.html'
})
export class HomeComponent {
title = 'Demo';
greeting = {};
constructor(private app: AppService, private http: HttpClient) {
http.get('resource').subscribe(data => this.greeting = data);
}
authenticated() { return this.app.authenticated; }
}
登入表單
登入表單也有自己的元件:
<div class="alert alert-danger" [hidden]="!error">
There was a problem logging in. Please try again.
</div>
<form role="form" (submit)="login()">
<div class="form-group">
<label for="username">Username:</label> <input type="text"
class="form-control" id="username" name="username" [(ngModel)]="credentials.username"/>
</div>
<div class="form-group">
<label for="password">Password:</label> <input type="password"
class="form-control" id="password" name="password" [(ngModel)]="credentials.password"/>
</div>
<button type="submit" class="btn btn-primary">Submit</button>
</form>
這是一個非常標準的登入表單,帶有兩個用於使用者名稱和密碼的輸入框,以及一個透過 Angular 事件處理器 (submit) 提交表單的按鈕。您不需要在表單標籤上設定動作,所以最好根本不要設定。還有一個錯誤訊息,只有當 Angular 模型包含 error 時才顯示。表單控制元件使用 Angular Forms 中的 ngModel 在 HTML 和 Angular 控制器之間傳遞資料,在這種情況下,我們使用一個 credentials 物件來儲存使用者名稱和密碼。
認證過程
為了支援我們剛剛新增的登入表單,我們需要新增更多功能。在客戶端,這些將在 LoginComponent 中實現,在伺服器端,這將是 Spring Security 配置。
提交登入表單
要提交表單,我們需要定義我們在表單中透過 ng-submit 引用過的 login() 函式,以及我們透過 ng-model 引用過的 credentials 物件。讓我們充實一下“login”元件:
import { Component, OnInit } from '@angular/core';
import { AppService } from './app.service';
import { HttpClient } from '@angular/common/http';
import { Router } from '@angular/router';
@Component({
templateUrl: './login.component.html'
})
export class LoginComponent {
credentials = {username: '', password: ''};
constructor(private app: AppService, private http: HttpClient, private router: Router) {
}
login() {
this.app.authenticate(this.credentials, () => {
this.router.navigateByUrl('/');
});
return false;
}
}
除了初始化 credentials 物件之外,它還定義了表單中所需的 login()。
authenticate() 向相對資源(相對於應用程式部署根目錄)"/user" 傳送 GET 請求。當從 login() 函式呼叫時,它會在請求頭中新增 Base64 編碼的憑據,因此在伺服器上它會進行身份驗證並返回一個 cookie。當收到身份驗證結果時,login() 函式還會相應地設定本地 $scope.error 標誌,該標誌用於控制登入表單上方錯誤訊息的顯示。
當前已認證使用者
為了服務 authenticate() 函式,我們需要向後端新增一個新的端點:
@SpringBootApplication
@RestController
public class UiApplication {
@RequestMapping("/user")
public Principal user(Principal user) {
return user;
}
...
}
這在 Spring Security 應用程式中是一個有用的技巧。如果 "/user" 資源可訪問,它將返回當前已認證的使用者(一個 Authentication),否則 Spring Security 將攔截該請求並透過 AuthenticationEntryPoint 傳送 401 響應。
在伺服器上處理登入請求
Spring Security 使處理登入請求變得容易。我們只需要向我們的主應用程式類(例如,作為內部類)新增一些配置:
@SpringBootApplication
@RestController
public class UiApplication {
...
@Configuration
@Order(SecurityProperties.ACCESS_OVERRIDE_ORDER)
protected static class SecurityConfiguration extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.httpBasic()
.and()
.authorizeRequests()
.antMatchers("/index.html", "/", "/home", "/login").permitAll()
.anyRequest().authenticated();
}
}
}
這是一個標準的 Spring Boot 應用程式,帶有 Spring Security 定製,只允許匿名訪問靜態(HTML)資源。出於某些原因,HTML 資源需要對匿名使用者可用,而不僅僅是被 Spring Security 忽略。
我們最後需要記住的是,讓 Angular 提供的 JavaScript 元件匿名地可供應用程式使用。我們可以在上面的 HttpSecurity 配置中做到這一點,但由於它是靜態內容,所以最好簡單地忽略它:
security:
ignored:
- "*.bundle.*"
新增預設 HTTP 請求頭
如果您此時執行應用程式,您會發現瀏覽器會彈出一個基本身份驗證對話方塊(用於使用者名稱和密碼)。它之所以這樣做,是因為它看到來自 /user 和 /resource 的 XHR 請求返回 401 響應,並帶有一個“WWW-Authenticate”頭。抑制此彈出視窗的方法是抑制該頭,該頭來自 Spring Security。抑制響應頭的方法是傳送一個特殊的、傳統的請求頭“X-Requested-With=XMLHttpRequest”。它曾經是 Angular 中的預設設定,但他們在 1.3.0 版本中將其移除。因此,以下是如何在 Angular XHR 請求中設定預設頭的方法。
首先擴充套件 Angular HTTP 模組提供的預設 RequestOptions:
@Injectable()
export class XhrInterceptor implements HttpInterceptor {
intercept(req: HttpRequest<any>, next: HttpHandler) {
const xhr = req.clone({
headers: req.headers.set('X-Requested-With', 'XMLHttpRequest')
});
return next.handle(xhr);
}
}
這裡的語法是樣板。Class 的 implements 屬性是它的基類,除了建構函式之外,我們真正需要做的就是覆蓋 intercept() 函式,該函式總是由 Angular 呼叫,可用於新增額外的頭部。
要安裝這個新的 RequestOptions 工廠,我們需要在 AppModule 的 providers 中宣告它:
@NgModule({
...
providers: [AppService, { provide: HTTP_INTERCEPTORS, useClass: XhrInterceptor, multi: true }],
...
})
export class AppModule { }
登出
應用程式的功能幾乎完成了。我們需要做的最後一件事是實現我們在主頁上草擬的登出功能。如果使用者已透過身份驗證,我們就會顯示一個“登出”連結,並將其與 AppComponent 中的 logout() 函式掛鉤。請記住,它會向“/logout”傳送 HTTP POST 請求,我們現在需要在伺服器上實現該請求。這很簡單,因為 Spring Security 已經為我們添加了它(即,對於這個簡單的用例,我們無需做任何事情)。要更精細地控制登出行為,您可以使用 WebSecurityAdapter 中的 HttpSecurity 回撥,例如在登出後執行一些業務邏輯。
CSRF 保護
應用程式幾乎可以使用了,事實上,如果您執行它,您會發現我們目前構建的所有功能都正常工作,除了登出連結。嘗試使用它並在瀏覽器中檢視響應,您就會明白原因:
POST /logout HTTP/1.1
...
Content-Type: application/x-www-form-urlencoded
username=user&password=password
HTTP/1.1 403 Forbidden
Set-Cookie: JSESSIONID=3941352C51ABB941781E1DF312DA474E; Path=/; HttpOnly
Content-Type: application/json;charset=UTF-8
Transfer-Encoding: chunked
...
{"timestamp":1420467113764,"status":403,"error":"Forbidden","message":"Expected CSRF token not found. Has your session expired?","path":"/login"}
這很好,因為這意味著 Spring Security 內建的 CSRF 保護已經啟動,阻止我們搬起石頭砸自己的腳。它只要求在名為“X-CSRF”的頭部中傳送一個令牌。CSRF 令牌的值在載入主頁的初始請求中在 HttpRequest 屬性的伺服器端可用。要將其傳遞給客戶端,我們可以在伺服器上使用動態 HTML 頁面渲染它,或者透過自定義端點暴露它,或者我們可以將其作為 cookie 傳送。最後一種選擇是最好的,因為 Angular 內建了對 CSRF(它稱為“XSRF”)的支援,基於 cookie。
因此,在伺服器端,我們需要一個自定義過濾器來發送 cookie。Angular 要求 cookie 名稱為“XSRF-TOKEN”,而 Spring Security 預設將其作為請求屬性提供,因此我們只需將值從請求屬性傳輸到 cookie。幸運的是,Spring Security(自 4.1.0 起)提供了一個專門的 CsrfTokenRepository,它正好可以完成此操作:
@Configuration
@Order(SecurityProperties.ACCESS_OVERRIDE_ORDER)
protected static class SecurityConfiguration extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
...
.and().csrf()
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse());
}
}
進行了這些更改之後,我們不需要在客戶端做任何事情,登入表單現在可以正常工作了。
它是如何工作的?
如果您使用一些開發者工具(通常按 F12 即可開啟,在 Chrome 中預設工作,在 Firefox 中可能需要外掛),您可以在瀏覽器中看到瀏覽器和後端之間的互動。以下是摘要:
| 動詞 | 路徑 | 狀態 | 響應 |
|---|---|---|---|
|
GET |
/ |
200 |
index.html |
|
GET |
/*.js |
200 |
來自 angular 的資產 |
|
GET |
/使用者 |
401 |
未經授權 (被忽略) |
|
GET |
/home |
200 |
首頁 |
|
GET |
/使用者 |
401 |
未經授權 (被忽略) |
|
GET |
/resource |
401 |
未經授權 (被忽略) |
|
GET |
/使用者 |
200 |
傳送憑據並獲取 JSON |
|
GET |
/resource |
200 |
JSON 問候語 |
上面標記為“ignored”的響應是 Angular 在 XHR 呼叫中收到的 HTML 響應,由於我們沒有處理該資料,HTML 被丟棄。在對“/user”資源的首次呼叫中,我們確實尋找經過身份驗證的使用者,但由於它不存在,該響應被丟棄。
仔細檢視請求,您會發現它們都帶有 cookie。如果您從一個乾淨的瀏覽器(例如 Chrome 的無痕模式)開始,第一個請求不會向伺服器傳送任何 cookie,但伺服器會返回“Set-Cookie”,用於“JSESSIONID”(常規 HttpSession)和“X-XSRF-TOKEN”(我們上面設定的 CRSF cookie)。隨後的請求都帶有這些 cookie,它們很重要:沒有它們應用程式無法工作,並且它們提供了非常基本的安全功能(身份驗證和 CSRF 保護)。當用戶進行身份驗證(在 POST 之後)時,cookie 的值會發生變化,這是另一個重要的安全功能(防止 會話固定攻擊)。
| 僅僅依靠將 cookie 發回伺服器來提供 CSRF 保護是不夠的,因為即使您不在從應用程式載入的頁面中(跨站指令碼攻擊,也稱為 XSS),瀏覽器也會自動傳送它。請求頭不會自動傳送,因此來源受到控制。您可能會在我們的應用程式中看到 CSRF 令牌作為 cookie 傳送給客戶端,因此我們將看到瀏覽器自動將其傳送回,但提供保護的是請求頭。 |
救命,我的應用程式將如何擴充套件?
“但是等等……”您可能會說,“在單頁應用程式中使用會話狀態不是非常糟糕嗎?”這個問題的答案必須是“大部分情況下”,因為使用會話進行身份驗證和 CSRF 保護絕對是一件好事。這種狀態必須儲存在某個地方,如果您將其從會話中取出,您將不得不將其儲存在其他地方,並在伺服器和客戶端手動管理它。這只是更多的程式碼,可能需要更多的維護,並且通常是重複發明一個完美的輪子。
“但是,但是……”您會反駁道,“我現在如何水平擴充套件我的應用程式?”這是您上面提出的“真正”問題,但它往往被簡化為“會話狀態不好,我必須是無狀態的”。別慌。這裡要記住的重點是,安全性*是有狀態的*。您不能擁有一個安全的、無狀態的應用程式。那麼您將把狀態儲存在哪裡?這就是全部。 Rob Winch 在 2014 年 Spring Exchange 上發表了一個非常有益且富有洞察力的演講,解釋了狀態的必要性(以及它的普遍性——TCP 和 SSL 都是有狀態的,所以無論您是否知道,您的系統都是有狀態的),如果您想更深入地研究這個主題,可能值得一看。
好訊息是您有選擇。最簡單的選擇是將會話資料儲存在記憶體中,並依靠負載均衡器中的粘性會話將來自同一會話的請求路由回同一 JVM(它們都會以某種方式支援)。這足以讓您起步,並適用於*非常*多的用例。另一個選擇是在應用程式例項之間共享會話資料。只要您嚴格遵守規定,只儲存安全資料,它就很小且不經常更改(僅在使用者登入和登出或其會話超時時),因此不應出現任何重大的基礎設施問題。使用 Spring Session 也非常容易實現。我們將在本系列的下一節中使用 Spring Session,因此無需在此處詳細介紹如何設定它,但它實際上只需幾行程式碼和一個 Redis 伺服器,速度超快。
| 設定共享會話狀態的另一個簡單方法是將應用程式作為 WAR 檔案部署到 Cloud Foundry Pivotal Web Services 並將其繫結到 Redis 服務。 |
但是,我的自定義令牌實現(它是無狀態的,看)呢?
如果這是您對上一節的回應,那麼請再讀一遍,因為您可能第一次沒有理解。如果您將令牌儲存在某個地方,它可能就不是無狀態的,但即使您沒有(例如,您使用 JWT 編碼令牌),您將如何提供 CSRF 保護?這很重要。這裡有一個經驗法則(歸功於 Rob Winch):如果您的應用程式或 API 將透過瀏覽器訪問,您就需要 CSRF 保護。這不是說您不能在沒有會話的情況下做到這一點,只是您必須自己編寫所有這些程式碼,而且這樣做的意義何在呢?因為這些程式碼已經實現並且在 HttpSession(反過來又是您正在使用的容器的一部分,並且從一開始就內建在規範中)之上執行得非常完美?即使您決定不需要 CSRF,並且擁有一個完美的“無狀態”(非基於會話的)令牌實現,您仍然必須在客戶端編寫額外的程式碼來消費和使用它,而您本可以直接委託給瀏覽器和伺服器自己的內建功能:瀏覽器總是傳送 cookie,伺服器總是有一個會話(除非您將其關閉)。這些程式碼不是業務邏輯,它不會為您帶來任何收益,它只是一個開銷,所以更糟糕的是,它會花費您的錢。
結論
我們現在擁有的應用程式接近使用者在實際生產環境中可能期望的“真實”應用程式,它可能可以作為模板,以這種架構(帶有靜態內容和 JSON 資源的單一伺服器)構建更豐富功能的應用程式。我們正在使用 HttpSession 儲存安全資料,依賴我們的客戶端尊重並使用我們傳送給他們的 cookie,我們對此感到滿意,因為它讓我們能夠專注於自己的業務領域。在下一節中,我們將把架構擴充套件到獨立的身份驗證和 UI 伺服器,以及一個獨立的 JSON 資源伺服器。這顯然很容易推廣到多個資源伺服器。我們還將把 Spring Session 引入技術棧,並展示如何使用它來共享身份驗證資料。
資源伺服器
在本節中,我們將繼續討論如何在“單頁應用程式”中使用 Spring Security 和 Angular。在這裡,我們首先將應用程式中用作動態內容的“greeting”資源拆分到一個單獨的伺服器中,首先作為未受保護的資源,然後透過不透明令牌進行保護。這是系列文章的第三部分,您可以閱讀第一部分來了解應用程式的基本構建模組或從頭開始構建,或者您可以直接訪問 Github 上的原始碼,它分為兩部分:一部分是資源未受保護,另一部分是透過令牌保護。
| 如果您正在使用示例應用程式學習本節,請務必清除瀏覽器快取中的 cookie 和 HTTP Basic 憑據。在 Chrome 中,對於單個伺服器,最好的方法是開啟一個新的無痕視窗。 |
獨立的資源伺服器
客戶端更改
在客戶端,將資源移動到不同的後端幾乎不需要做什麼。這是上一節中的“home”元件:
@Component({
templateUrl: './home.component.html'
})
export class HomeComponent {
title = 'Demo';
greeting = {};
constructor(private app: AppService, private http: HttpClient) {
http.get('resource').subscribe(data => this.greeting = data);
}
authenticated() { return this.app.authenticated; }
}
我們只需要更改 URL。例如,如果我們要將新資源執行在 localhost 上,它可能看起來像這樣:
http.get('https://:9000').subscribe(data => this.greeting = data);
伺服器端更改
UI 伺服器的更改非常簡單:我們只需要移除 greeting 資源的 @RequestMapping(它是 "/resource")。然後我們需要建立一個新的資源伺服器,我們可以像第一節中那樣使用 Spring Boot Initializr 來完成。例如,在類似 UN*X 的系統上使用 curl:
$ mkdir resource && cd resource
$ curl https://start.spring.io/starter.tgz -d dependencies=web -d name=resource | tar -xzvf -
然後,您可以將該專案(預設情況下是一個普通的 Maven Java 專案)匯入到您喜歡的 IDE 中,或者只使用檔案並在命令列上執行 "mvn"。
@SpringBootApplication
@RestController
class ResourceApplication {
@RequestMapping("/")
public Message home() {
return new Message("Hello World");
}
public static void main(String[] args) {
SpringApplication.run(ResourceApplication.class, args);
}
}
class Message {
private String id = UUID.randomUUID().toString();
private String content;
public Message(String content) {
this.content = content;
}
// ... getters and setters and default constructor
}
一旦完成,您的應用程式就可以在瀏覽器中載入。在命令列上,您可以執行以下操作:
$ mvn spring-boot:run -Dserver.port=9000
然後開啟瀏覽器訪問 https://:9000,您應該會看到帶有問候語的 JSON。您可以在 application.properties(在“src/main/resources”中)中烘焙埠更改:
server.port: 9000
如果您嘗試從 UI(在埠 8080 上)在瀏覽器中載入該資源,您會發現它無法工作,因為瀏覽器不允許 XHR 請求。
CORS 協商
瀏覽器嘗試與我們的資源伺服器協商,以根據 跨域資源共享 協議確定是否允許訪問它。這不是 Angular 的責任,所以就像 cookie 契約一樣,它將與瀏覽器中的所有 JavaScript 這樣工作。這兩個伺服器沒有宣告它們具有共同的來源,因此瀏覽器拒絕傳送請求,導致 UI 損壞。
為了解決這個問題,我們需要支援 CORS 協議,這涉及一個“預檢”OPTIONS 請求和一些標頭,以列出呼叫者的允許行為。Spring 4.2 具有一些出色的細粒度 CORS 支援,因此我們只需向控制器對映新增一個註解,例如:
@RequestMapping("/")
@CrossOrigin(origins="*", maxAge=3600)
public Message home() {
return new Message("Hello World");
}
隨意使用 origins=* 是快速而粗暴的方法,它有效,但它不安全,也絕不推薦。 |
保護資源伺服器
太棒了!我們有了一個新的架構的工作應用程式。唯一的問題是資源伺服器沒有安全性。
新增 Spring Security
我們還可以看看如何像在 UI 伺服器中一樣,將安全性作為過濾層新增到資源伺服器中。第一步非常簡單:只需在 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 提供了一個 barebones 的 Filter 實現,讓您開始自己的工作(例如,請參閱 AbstractPreAuthenticatedProcessingFilter 和 TokenService)。不過,Spring Security 中沒有規範的實現,其中一個原因可能是存在一種更簡單的方法。
回想一下本系列第二部分,Spring Security 預設使用 HttpSession 儲存身份驗證資料。不過,它不直接與會話互動:在它們之間有一個抽象層(SecurityContextRepository),您可以使用它來更改儲存後端。如果我們將資源伺服器中的該儲存庫指向一個由我們的 UI 驗證的身份驗證儲存,那麼我們就有了在兩個伺服器之間共享身份驗證的方法。UI 伺服器已經有這樣一個儲存(HttpSession),所以如果我們可以分發該儲存並將其開放給資源伺服器,我們就有了大部分解決方案。
Spring Session
這部分解決方案使用 Spring Session 非常簡單。我們只需要一個共享資料儲存(Redis 和 JDBC 開箱即用支援),以及伺服器中幾行配置來設定 Filter。
在 UI 應用程式中,我們需要向我們的 POM 新增一些依賴項
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
Spring Boot 和 Spring Session 協同工作,連線到 Redis 並集中儲存會話資料。
有了那一行程式碼並執行一個 Redis 伺服器在本地主機上,您就可以執行 UI 應用程式,使用一些有效的使用者憑據登入,並且會話資料(身份驗證)將儲存在 redis 中。
如果您沒有在本地執行 redis 伺服器,您可以使用 Docker 輕鬆啟動一個(在 Windows 或 MacOS 上這需要一個 VM)。Github 上的原始碼中有一個 docker-compose.yml 檔案,您可以在命令列上使用 docker-compose up 輕鬆執行它。如果您在 VM 中執行此操作,Redis 伺服器將在與 localhost 不同的主機上執行,因此您要麼需要將其隧道到 localhost,要麼配置應用程式以指向 application.properties 中正確的 spring.redis.host。 |
從 UI 傳送自定義令牌
唯一缺少的部分是儲存中資料鍵的傳輸機制。鍵是 HttpSession ID,所以如果我們在 UI 客戶端中能夠獲取該鍵,我們可以將其作為自定義頭部發送到資源伺服器。因此,“home”控制器需要更改,以便它將頭部作為 greeting 資源的 HTTP 請求的一部分發送。例如:
constructor(private app: AppService, private http: HttpClient) {
http.get('token').subscribe(data => {
const token = data['token'];
http.get('https://:9000', {headers : new HttpHeaders().set('X-Auth-Token', token)})
.subscribe(response => this.greeting = response);
}, () => {});
}
(一個更優雅的解決方案可能是按需獲取令牌,並使用我們的 RequestOptionsService 將頭部新增到傳送到資源伺服器的每個請求中。)
我們沒有直接訪問 "https://:9000",而是將該呼叫封裝在對 UI 伺服器上新自定義端點 "/token" 的成功回撥中。該實現非常簡單:
@SpringBootApplication
@RestController
public class UiApplication {
public static void main(String[] args) {
SpringApplication.run(UiApplication.class, args);
}
...
@RequestMapping("/token")
public Map<String,String> token(HttpSession session) {
return Collections.singletonMap("token", session.getId());
}
}
因此,UI 應用程式已準備就緒,並將為所有後端呼叫在名為“X-Auth-Token”的頭部中包含會話 ID。
資源伺服器中的認證
資源伺服器有一個微小的更改,以便它能夠接受自定義頭部。CORS 配置必須將該頭部指定為遠端客戶端允許的頭部,例如:
@RequestMapping("/")
@CrossOrigin(origins = "*", maxAge = 3600,
allowedHeaders={"x-auth-token", "x-requested-with", "x-xsrf-token"})
public Message home() {
return new Message("Hello World");
}
現在,瀏覽器的預檢請求將由 Spring MVC 處理,但我們需要告訴 Spring Security 允許它透過:
public class ResourceApplication extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.cors().and().authorizeRequests()
.anyRequest().authenticated();
}
...
無需 permitAll() 訪問所有資源,並且可能存在處理程式因不知道請求是預檢而無意中傳送敏感資料的情況。cors() 配置實用程式透過在過濾層處理所有預檢請求來緩解此問題。 |
剩下的就是獲取資源伺服器中的自定義令牌並使用它來驗證我們的使用者。這變得相當簡單,因為我們所需要做的就是告訴 Spring Security 會話儲存庫在哪裡,以及在傳入請求中查詢令牌(會話 ID)的位置。首先我們需要新增 Spring Session 和 Redis 依賴項,然後我們就可以設定 Filter 了
@SpringBootApplication
@RestController
class ResourceApplication {
...
@Bean
HeaderHttpSessionStrategy sessionStrategy() {
return new HeaderHttpSessionStrategy();
}
}
建立的這個 Filter 是 UI 伺服器中過濾器的映象,因此它將 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。
為什麼它不能完全透過 Cookies 工作?
我們不得不使用自定義頭部並在客戶端編寫程式碼來填充頭部,這並不特別複雜,但這似乎與第二部分中儘可能使用 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 分散式會話無法實現。 |
API 閘道器
在本節中,我們將繼續討論如何在“單頁應用程式”中使用 Spring Security 和 Angular。在這裡,我們將展示如何構建一個 API 閘道器,使用 Spring Cloud 來控制對後端資源的身份驗證和訪問。這是系列文章的第四部分,您可以閱讀第一部分來了解應用程式的基本構建模組或從頭開始構建,或者您可以直接訪問 Github 上的原始碼。在上一節中,我們構建了一個小型分散式應用程式,它使用 Spring Session 來驗證後端資源,並使用 Spring Cloud 在 UI 伺服器中實現了一個嵌入式 API 閘道器。在本節中,我們將身份驗證職責提取到一個單獨的伺服器中,使我們的 UI 伺服器成為授權伺服器的眾多潛在單點登入應用程式中的第一個。這是當今許多應用程式中的常見模式,無論是在企業還是在社交初創公司中。我們將使用 OAuth2 伺服器作為身份驗證器,以便我們也可以使用它為後端資源伺服器授予令牌。Spring Cloud 將自動將訪問令牌中繼到我們的後端,並使我們能夠進一步簡化 UI 和資源伺服器的實現。
提醒:如果您正在使用示例應用程式學習本節,請務必清除瀏覽器快取中的 cookie 和 HTTP Basic 憑據。在 Chrome 中,對於單個伺服器,最好的方法是開啟一個新的無痕視窗。
建立 API 閘道器
API 閘道器是前端客戶端的單一入口點(和控制點),可以是基於瀏覽器的(如本節中的示例)或移動的。客戶端只需知道一個伺服器的 URL,後端可以隨意重構而無需更改,這是一個顯著的優勢。在集中化和控制方面還有其他優勢:速率限制、身份驗證、審計和日誌記錄。使用 Spring Cloud 實現一個簡單的反向代理非常簡單。
如果您一直在跟著程式碼,您會知道上一節末尾的應用程式實現有點複雜,所以它不是一個很好的迭代起點。但是,有一箇中間點我們可以更容易地從它開始,即後端資源尚未透過 Spring Security 保護。此原始碼是一個單獨的專案,位於 Github 中,所以我們將從那裡開始。它有一個 UI 伺服器和一個資源伺服器,它們正在相互通訊。資源伺服器還沒有 Spring Security,所以我們可以先讓系統工作,然後再新增該層。
一行程式碼的宣告式反向代理
為了將其轉換為 API 閘道器,UI 伺服器需要一個小的調整。在 Spring 配置的某個地方,我們需要新增一個 @EnableZuulProxy 註解,例如在主(唯一的)應用程式類中:
@SpringBootApplication
@RestController
@EnableZuulProxy
public class UiApplication {
...
}
在外部配置檔案中,我們需要將 UI 伺服器中的本地資源對映到 外部配置 ("application.yml") 中的遠端資源:
security:
...
zuul:
routes:
resource:
path: /resource/**
url: https://:9000
這意味著“將該伺服器中模式為 /resource/** 的路徑對映到 localhost:9000 遠端伺服器中的相同路徑”。簡單而有效(好吧,包括 YAML 在內是 6 行,但你並非總是需要那麼多)!
要使其正常工作,我們只需要類路徑上正確的內容。為此,我們的 Maven POM 中有幾行新程式碼:
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>Dalston.SR4</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-zuul</artifactId>
</dependency>
...
</dependencies>
注意使用“spring-cloud-starter-zuul”——它是一個啟動器 POM,就像 Spring Boot 的一樣,但它管理我們這個 Zuul 代理所需的依賴項。我們還使用了 <dependencyManagement>,因為我們希望能夠依賴所有傳遞依賴項的版本都是正確的。
客戶端消費代理
進行了這些更改後,我們的應用程式仍然可以工作,但我們尚未實際使用新代理,直到我們修改客戶端。幸運的是,這非常簡單。我們只需要還原從“single”到“vanilla”示例時在上一節中進行的更改:
constructor(private app: AppService, private http: HttpClient) {
http.get('resource').subscribe(data => this.greeting = data);
}
現在,當我們啟動伺服器時,一切都正常執行,請求透過 UI(API 閘道器)代理到資源伺服器。
進一步簡化
更好的是:我們不再需要資源伺服器中的 CORS 過濾器了。無論如何,我們很快就把它搞定了,而且我們不得不手動做任何技術性的事情(尤其是在安全方面)都應該是一個危險訊號。幸運的是,它現在已經多餘了,所以我們可以直接扔掉它,然後高枕無憂了!
保護資源伺服器
您可能還記得,我們開始的中間狀態下,資源伺服器並沒有任何安全措施。
附帶說明:如果您的網路架構與應用程式架構一致,缺乏軟體安全性可能甚至不是問題(您可以簡單地使資源伺服器物理上除了 UI 伺服器之外對任何人都是不可訪問的)。作為一個簡單的演示,我們可以讓資源伺服器只在 localhost 上可訪問。只需將此新增到資源伺服器的
application.properties中:
server.address: 127.0.0.1
哇,那太容易了!如果您的資料中心只有這個網路地址可見,那麼您就擁有了一個適用於所有資源伺服器和所有使用者桌面的安全解決方案。
假設我們決定確實需要在軟體層面提供安全性(出於多種原因很可能如此)。這不會成為問題,因為我們所需要做的就是將 Spring Security 作為依賴項新增(在資源伺服器 POM 中):
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
這足以讓我們獲得一個安全的資源伺服器,但它還不能讓我們獲得一個可用的應用程式,原因與第三部分相同:兩個伺服器之間沒有共享的身份驗證狀態。
共享認證狀態
我們可以使用與上次相同的機制來共享身份驗證(和 CSRF)狀態,即 Spring Session。我們像以前一樣將依賴項新增到兩個伺服器中:
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-redis</artifactId>
</dependency>
但這次配置要簡單得多,因為我們可以將相同的 Filter 宣告新增到兩者中。首先是 UI 伺服器,明確宣告我們希望轉發所有頭部(即沒有“敏感”頭部):
zuul:
routes:
resource:
sensitive-headers:
然後我們可以繼續討論資源伺服器。需要進行兩個小的更改:一個是顯式停用資源伺服器中的 HTTP Basic(以防止瀏覽器彈出身份驗證對話方塊):
@SpringBootApplication
@RestController
class ResourceApplication extends WebSecurityConfigurerAdapter {
...
@Override
protected void configure(HttpSecurity http) throws Exception {
http.httpBasic().disable();
http.authorizeRequests().anyRequest().authenticated();
}
}
旁註:另一種替代方法,也可以防止身份驗證對話方塊,是保留 HTTP Basic 但將 401 質詢更改為“Basic”以外的其他內容。您可以透過在
HttpSecurity配置回撥中實現一行程式碼的AuthenticationEntryPoint來實現。
另一個是在 application.properties 中明確要求非無狀態會話建立策略:
security.sessions: NEVER
只要 redis 仍在後臺執行(如果您喜歡,可以使用 docker-compose.yml 啟動它),系統就會正常工作。在新的瀏覽器中載入 UI 的主頁 https://:8080 並登入,您將在主頁上看到來自後端的渲染訊息。
它是如何工作的?
現在幕後發生了什麼?首先,我們可以檢視 UI 伺服器(和 API 閘道器)中的 HTTP 請求:
| 動詞 | 路徑 | 狀態 | 響應 |
|---|---|---|---|
|
GET |
/ |
200 |
index.html |
|
GET |
/*.js |
200 |
來自 Angular 的資產 |
|
GET |
/使用者 |
401 |
未經授權 (被忽略) |
|
GET |
/resource |
401 |
對資源的未經身份驗證的訪問 |
|
GET |
/使用者 |
200 |
JSON 認證使用者 |
|
GET |
/resource |
200 |
(代理)JSON 問候語 |
這與第二部分末尾的序列相同,只是 cookie 名稱略有不同(“SESSION”而不是“JSESSIONID”),因為我們使用的是 Spring Session。但架構不同,最後對“/resource”的請求是特殊的,因為它被代理到資源伺服器。
我們可以透過檢視 UI 伺服器中的“/trace”端點(來自 Spring Boot Actuator,我們隨 Spring Cloud 依賴項一起新增的)來觀察反向代理的實際執行情況。在新瀏覽器中訪問 https://:8080/trace(如果您還沒有,請為您的瀏覽器安裝一個 JSON 外掛,使其更易讀)。您需要使用 HTTP Basic(瀏覽器彈出視窗)進行身份驗證,但與登入表單相同的憑據有效。在開始時或附近,您應該會看到一對請求,類似於這樣:
| 嘗試使用不同的瀏覽器,以避免身份驗證交叉的可能性(例如,如果您在測試 UI 時使用了 Chrome,請使用 Firefox)——這不會阻止應用程式工作,但如果跟蹤中包含來自同一瀏覽器的混合身份驗證,則會使其更難閱讀。 |
{
"timestamp": 1420558194546,
"info": {
"method": "GET",
"path": "/",
"query": ""
"remote": true,
"proxy": "resource",
"headers": {
"request": {
"accept": "application/json, text/plain, */*",
"x-xsrf-token": "542c7005-309c-4f50-8a1d-d6c74afe8260",
"cookie": "SESSION=c18846b5-f805-4679-9820-cd13bd83be67; XSRF-TOKEN=542c7005-309c-4f50-8a1d-d6c74afe8260",
"x-forwarded-prefix": "/resource",
"x-forwarded-host": "localhost:8080"
},
"response": {
"Content-Type": "application/json;charset=UTF-8",
"status": "200"
}
},
}
},
{
"timestamp": 1420558200232,
"info": {
"method": "GET",
"path": "/resource/",
"headers": {
"request": {
"host": "localhost:8080",
"accept": "application/json, text/plain, */*",
"x-xsrf-token": "542c7005-309c-4f50-8a1d-d6c74afe8260",
"cookie": "SESSION=c18846b5-f805-4679-9820-cd13bd83be67; XSRF-TOKEN=542c7005-309c-4f50-8a1d-d6c74afe8260"
},
"response": {
"Content-Type": "application/json;charset=UTF-8",
"status": "200"
}
}
}
},
第二個條目是客戶端向閘道器發出的對“/resource”的請求,您可以看到 cookie(由瀏覽器新增)和 CSRF 頭部(由 Angular 新增,如第二部分所述)。第一個條目有 remote: true,這意味著它正在跟蹤對資源伺服器的呼叫。您可以看到它發往 URI 路徑“/”,並且您可以看到(至關重要的是)cookie 和 CSRF 頭部也已傳送。如果沒有 Spring Session,這些頭部對資源伺服器將毫無意義,但我們設定的方式使其現在可以使用這些頭部重新構建包含身份驗證和 CSRF 令牌資料的會話。因此,請求被允許,我們一切順利!
結論
我們在本節中涵蓋了很多內容,但我們達到了一個非常好的境界,即我們的兩個伺服器中樣板程式碼量極少,它們都得到了很好的保護,並且使用者體驗也沒有受到影響。僅此一點就足以成為使用 API 閘道器模式的理由,但實際上我們只是觸及了它可能用於什麼(Netflix 用它來做很多事情)的皮毛。閱讀 Spring Cloud 以瞭解如何輕鬆地向閘道器新增更多功能。本系列的下一節將透過將身份驗證職責提取到單獨的伺服器(單點登入模式)來擴充套件應用程式架構。
使用 OAuth2 進行單點登入
在本節中,我們將繼續討論如何在“單頁應用程式”中使用 Spring Security 和 Angular。在這裡,我們將展示如何使用 Spring Security OAuth 和 Spring Cloud 來擴充套件我們的 API 閘道器,以實現單點登入和 OAuth2 令牌認證到後端資源。這是系列文章的第五部分,您可以閱讀第一部分來了解應用程式的基本構建模組或從頭開始構建,或者您可以直接訪問 Github 上的原始碼。在上一節中,我們構建了一個小型分散式應用程式,它使用 Spring Session 來驗證後端資源,並使用 Spring Cloud 在 UI 伺服器中實現了一個嵌入式 API 閘道器。在本節中,我們將身份驗證職責提取到一個單獨的伺服器中,使我們的 UI 伺服器成為授權伺服器的眾多潛在單點登入應用程式中的第一個。這是當今許多應用程式中的常見模式,無論是在企業還是在社交初創公司中。我們將使用 OAuth2 伺服器作為身份驗證器,以便我們也可以使用它為後端資源伺服器授予令牌。Spring Cloud 將自動將訪問令牌中繼到我們的後端,並使我們能夠進一步簡化 UI 和資源伺服器的實現。
提醒:如果您正在使用示例應用程式學習本節,請務必清除瀏覽器快取中的 cookie 和 HTTP Basic 憑據。在 Chrome 中,對於單個伺服器,最好的方法是開啟一個新的無痕視窗。
建立 OAuth2 授權伺服器
我們的第一步是建立一個新伺服器來處理身份驗證和令牌管理。按照第一部分中的步驟,我們可以從 Spring Boot Initializr 開始。例如,在類似 UN*X 的系統上使用 curl:
$ curl https://start.spring.io/starter.tgz -d dependencies=web,security -d name=authserver | tar -xzvf -
然後,您可以將該專案(預設情況下是一個普通的 Maven Java 專案)匯入到您喜歡的 IDE 中,或者只使用檔案並在命令列上執行 "mvn"。
新增 OAuth2 依賴項
我們需要新增 Spring OAuth 依賴項,所以在我們的 POM 中新增:
<dependency>
<groupId>org.springframework.security.oauth</groupId>
<artifactId>spring-security-oauth2</artifactId>
</dependency>
授權伺服器的實現相當簡單。一個最小的版本看起來像這樣:
@SpringBootApplication
@EnableAuthorizationServer
public class AuthserverApplication extends WebMvcConfigurerAdapter {
public static void main(String[] args) {
SpringApplication.run(AuthserverApplication.class, args);
}
}
我們只需要再做一件事(新增 @EnableAuthorizationServer 之後):
---
...
security.oauth2.client.clientId: acme
security.oauth2.client.clientSecret: acmesecret
security.oauth2.client.authorized-grant-types: authorization_code,refresh_token,password
security.oauth2.client.scope: openid
---
這會註冊一個客戶端“acme”,帶有一個秘密和一些授權的授予型別,包括“authorization_code”。
現在讓我們讓它在埠 9999 上執行,並設定一個可預測的密碼用於測試:
server.port=9999
security.user.password=password
server.contextPath=/uaa
...
我們還設定了上下文路徑,使其不使用預設值(“/”),因為否則您可能會將 localhost 上的其他伺服器的 cookie 傳送到錯誤的伺服器。所以啟動伺服器,我們可以確保它正常工作:
$ mvn spring-boot:run
或者在您的 IDE 中啟動 main() 方法。
測試授權伺服器
我們的伺服器正在使用 Spring Boot 預設安全設定,所以像第一部分中的伺服器一樣,它將受到 HTTP Basic 身份驗證的保護。要啟動授權碼令牌授予,請訪問授權端點,例如 https://:9999/uaa/oauth/authorize?response_type=code&client_id=acme&redirect_uri=http://example.com ;一旦您透過身份驗證,您將獲得一個重定向到 example.com 的請求,並附帶一個授權碼,例如 http://example.com/?code=jYWioI。
| 為了本示例應用程式的目的,我們建立了一個沒有註冊重定向的客戶端“acme”,這使我們能夠重定向到 example.com。在生產應用程式中,您應該始終註冊重定向(並使用 HTTPS)。 |
該程式碼可以與令牌端點上的“acme”客戶端憑據交換,以獲取訪問令牌:
$ curl acme:acmesecret@localhost:9999/uaa/oauth/token \
-d grant_type=authorization_code -d client_id=acme \
-d redirect_uri=http://example.com -d code=jYWioI
{"access_token":"2219199c-966e-4466-8b7e-12bb9038c9bb","token_type":"bearer","refresh_token":"d193caf4-5643-4988-9a4a-1c03c9d657aa","expires_in":43199,"scope":"openid"}
訪問令牌是一個 UUID(“2219199c…),由伺服器中的記憶體令牌儲存支援。我們還獲得了重新整理令牌,噹噹前訪問令牌過期時,我們可以使用它來獲取新的訪問令牌。
| 由於我們允許“acme”客戶端使用“password”授予型別,我們還可以使用 curl 和使用者憑據而不是授權碼直接從令牌端點獲取令牌。這不適用於基於瀏覽器的客戶端,但對測試很有用。 |
如果您點選了上面的連結,您會看到 Spring OAuth 提供的白標 UI。一開始我們將使用這個,稍後我們可以像第二部分為自包含伺服器所做的那樣,對其進行增強。
更改資源伺服器
如果從第四部分繼續,我們的資源伺服器正在使用 Spring Session 進行身份驗證,因此我們可以將其移除並替換為 Spring OAuth。我們還需要移除 Spring Session 和 Redis 依賴項,所以替換:
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-redis</artifactId>
</dependency>
改為:
<dependency>
<groupId>org.springframework.security.oauth</groupId>
<artifactId>spring-security-oauth2</artifactId>
</dependency>
然後從主應用程式類中移除會話 Filter,將其替換為方便的 @EnableResourceServer 註解(來自 Spring Security OAuth2):
@SpringBootApplication
@RestController
@EnableResourceServer
class ResourceApplication {
@RequestMapping("/")
public Message home() {
return new Message("Hello World");
}
public static void main(String[] args) {
SpringApplication.run(ResourceApplication.class, args);
}
}
僅此一項更改,應用程式就可以挑戰訪問令牌而不是 HTTP Basic,但我們需要更改配置才能真正完成該過程。我們將新增少量外部配置(在“application.properties”中),以允許資源伺服器解碼它獲得的令牌並驗證使用者:
...
security.oauth2.resource.userInfoUri: https://:9999/uaa/user
這告訴伺服器可以使用令牌訪問“/user”端點,並使用該端點獲取身份驗證資訊(這有點像 Facebook API 中的 “/me”端點)。實際上,它為資源伺服器提供了一種解碼令牌的方式,正如 Spring OAuth2 中的 ResourceServerTokenServices 介面所表達的那樣。
執行應用程式並使用命令列客戶端訪問主頁:
$ curl -v localhost:9000
> GET / HTTP/1.1
> User-Agent: curl/7.35.0
> Host: localhost:9000
> Accept: */*
>
< HTTP/1.1 401 Unauthorized
...
< WWW-Authenticate: Bearer realm="null", error="unauthorized", error_description="An Authentication object was not found in the SecurityContext"
< Content-Type: application/json;charset=UTF-8
{"error":"unauthorized","error_description":"An Authentication object was not found in the SecurityContext"}
您會看到一個 401 響應,其中包含“WWW-Authenticate”頭部,表明它需要一個持有者令牌。
userInfoUri 絕不是將資源伺服器與令牌解碼方式連線起來的唯一方式。實際上,它有點像最低公分母(並且不屬於規範的一部分),但通常可以從 OAuth2 提供商(如 Facebook、Cloud Foundry、Github)獲得,並且還有其他選擇。例如,您可以將使用者身份驗證編碼到令牌本身中(例如使用 JWT),或者使用共享後端儲存。CloudFoundry 中還有一個 /token_info 端點,它提供比使用者資訊端點更詳細的資訊,但需要更徹底的身份驗證。不同的選項(自然地)提供不同的好處和權衡,但對這些的全面討論超出了本節的範圍。 |
實現使用者端點
在授權伺服器上,我們可以輕鬆新增該端點:
@SpringBootApplication
@RestController
@EnableAuthorizationServer
@EnableResourceServer
public class AuthserverApplication {
@RequestMapping("/user")
public Principal user(Principal user) {
return user;
}
...
}
我們添加了一個與第二部分中 UI 伺服器相同的 @RequestMapping,以及來自 Spring OAuth 的 @EnableResourceServer 註解,該註解預設保護授權伺服器中的所有內容,除了 "/oauth/*" 端點。
有了這個端點,我們就可以測試它和 greeting 資源了,因為它們現在都接受由授權伺服器建立的持有者令牌:
$ TOKEN=2219199c-966e-4466-8b7e-12bb9038c9bb
$ curl -H "Authorization: Bearer $TOKEN" localhost:9000
{"id":"03af8be3-2fc3-4d75-acf7-c484d9cf32b1","content":"Hello World"}
$ curl -H "Authorization: Bearer $TOKEN" localhost:9999/uaa/user
{"details":...,"principal":{"username":"user",...},"name":"user"}
(替換您從自己的授權伺服器獲取的訪問令牌值,以使其正常工作)。
UI 伺服器
我們需要完成此應用程式的最後一部分是 UI 伺服器,它提取認證部分並委託給授權伺服器。因此,與資源伺服器一樣,我們首先需要刪除 Spring Session 和 Redis 依賴項,並將其替換為 Spring OAuth2。由於我們在 UI 層使用 Zuul,我們實際上使用spring-cloud-starter-oauth2而不是直接使用spring-security-oauth2(這會設定一些自動配置,用於透過代理轉發令牌)。
完成此操作後,我們還可以刪除會話過濾器和“/user”端點,並設定應用程式以重定向到授權伺服器(使用@EnableOAuth2Sso註解)。
@SpringBootApplication
@EnableZuulProxy
@EnableOAuth2Sso
public class UiApplication {
public static void main(String[] args) {
SpringApplication.run(UiApplication.class, args);
}
...
}
回想一下第四部分,UI 伺服器憑藉@EnableZuulProxy充當 API 閘道器,我們可以在 YAML 中宣告路由對映。因此,“/user”端點可以代理到授權伺服器。
zuul:
routes:
resource:
path: /resource/**
url: https://:9000
user:
path: /user/**
url: https://:9999/uaa/user
最後,我們需要將應用程式更改為WebSecurityConfigurerAdapter,因為現在它將用於修改由@EnableOAuth2Sso設定的 SSO 過濾器鏈中的預設值。
@SpringBootApplication
@EnableZuulProxy
@EnableOAuth2Sso
public class UiApplication extends WebSecurityConfigurerAdapter {
@Override
public void configure(HttpSecurity http) throws Exception {
http
.logout().logoutSuccessUrl("/").and()
.authorizeRequests().antMatchers("/index.html", "/app.html", "/")
.permitAll().anyRequest().authenticated().and()
.csrf()
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse());
}
}
主要變化(除了基類名)是匹配器進入了自己的方法,並且不再需要formLogin()。顯式logout()配置明確添加了一個未受保護的成功 URL,這樣對/logout的 XHR 請求將成功返回。
還有一些強制性的外部配置屬性,用於@EnableOAuth2Sso註解,以便能夠聯絡正確的授權伺服器並進行認證。所以我們需要在application.yml中新增這些內容。
security:
...
oauth2:
client:
accessTokenUri: https://:9999/uaa/oauth/token
userAuthorizationUri: https://:9999/uaa/oauth/authorize
clientId: acme
clientSecret: acmesecret
resource:
userInfoUri: https://:9999/uaa/user
其中大部分是關於 OAuth2 客戶端(“acme”)和授權伺服器位置的。還有一個userInfoUri(就像在資源伺服器中一樣),以便使用者可以在 UI 應用程式本身中進行認證。
如果您希望 UI 應用程式能夠自動重新整理過期的訪問令牌,則必須將OAuth2RestOperations注入到執行轉發的 Zuul 過濾器中。您可以透過建立該型別的 bean 來實現(有關詳細資訊,請檢視OAuth2TokenRelayFilter)。 |
@Bean
protected OAuth2RestTemplate OAuth2RestTemplate(
OAuth2ProtectedResourceDetails resource, OAuth2ClientContext context) {
return new OAuth2RestTemplate(resource, context);
}
在客戶端中
我們仍然需要在前端對 UI 應用程式進行一些調整,以觸發重定向到授權伺服器。在這個簡單的演示中,我們可以將 Angular 應用程式精簡到最基本的程度,以便您可以更清楚地看到正在發生的事情。因此,我們暫時放棄使用表單或路由,回到單個 Angular 元件。
import { Component } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import 'rxjs/add/operator/finally';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent {
title = 'Demo';
authenticated = false;
greeting = {};
constructor(private http: HttpClient) {
this.authenticate();
}
authenticate() {
this.http.get('user').subscribe(response => {
if (response['name']) {
this.authenticated = true;
this.http.get('resource').subscribe(data => this.greeting = data);
} else {
this.authenticated = false;
}
}, () => { this.authenticated = false; });
}
logout() {
this.http.post('logout', {}).finally(() => {
this.authenticated = false;
}).subscribe();
}
}
AppComponent處理所有事情,獲取使用者詳細資訊,如果成功,則獲取問候語。它還提供了logout功能。
現在我們需要為這個新元件建立模板
app.component.html
<div class="container">
<ul class="nav nav-pills">
<li><a>Home</a></li>
<li><a href="login">Login</a></li>
<li><a (click)="logout()">Logout</a></li>
</ul>
</div>
<div class="container">
<h1>Greeting</h1>
<div [hidden]="!authenticated">
<p>The ID is {{greeting.id}}</p>
<p>The content is {{greeting.content}}</p>
</div>
<div [hidden]="authenticated">
<p>Login to see your greeting</p>
</div>
並將其作為<app-root/>包含在主頁中。
請注意,“登入”的導航連結是一個帶有href的常規連結(而不是 Angular 路由)。此連結指向的“/login”端點由 Spring Security 處理,如果使用者未認證,它將導致重定向到授權伺服器。
它是如何工作的?
現在同時執行所有伺服器,並在瀏覽器中訪問 https://:8080 處的 UI。單擊“登入”連結,您將被重定向到授權伺服器進行認證(HTTP Basic 彈出視窗)並批准令牌授予(白標籤 HTML),然後重定向到 UI 中的主頁,其中包含使用與認證 UI 相同的令牌從 OAuth2 資源伺服器獲取的問候語。
如果您使用一些開發者工具(通常按 F12 即可開啟,在 Chrome 中預設工作,在 Firefox 中可能需要外掛),您可以在瀏覽器中看到瀏覽器和後端之間的互動。以下是摘要:
| 動詞 | 路徑 | 狀態 | 響應 |
|---|---|---|---|
|
GET |
/ |
200 |
index.html |
|
GET |
/*.js |
200 |
來自 angular 的資產 |
|
GET |
/使用者 |
302 |
重定向到登入頁面 |
|
GET |
/login |
302 |
重定向到認證伺服器 |
|
GET |
(uaa)/oauth/authorize |
401 |
(忽略) |
|
GET |
/login |
302 |
重定向到認證伺服器 |
|
GET |
(uaa)/oauth/authorize |
200 |
HTTP Basic 認證發生在此處 |
|
POST |
(uaa)/oauth/authorize |
302 |
使用者批准授權,重定向到 /login |
|
GET |
/login |
302 |
重定向到主頁 |
|
GET |
/使用者 |
200 |
(代理) JSON 認證使用者 |
|
GET |
/app.html |
200 |
主頁的 HTML 片段 |
|
GET |
/resource |
200 |
(代理)JSON 問候語 |
以 (uaa) 為字首的請求是傳送到授權伺服器的。標記為“忽略”的響應是 Angular 在 XHR 呼叫中收到的響應,由於我們沒有處理這些資料,它們被丟棄了。我們確實在“/user”資源的情況下尋找已認證的使用者,但由於在第一次呼叫中不存在,該響應被丟棄。
在 UI 的“/trace”端點(向下滾動到底部),您將看到代理的後端請求“/user”和“/resource”,其中remote:true和 bearer 令牌而不是 cookie(就像在第四部分中一樣)用於認證。Spring Cloud Security 已經為我們處理了這個問題:透過識別我們有@EnableOAuth2Sso和@EnableZuulProxy,它已經確定(預設情況下)我們希望將令牌轉發到代理的後端。
| 與前幾節一樣,嘗試使用不同的瀏覽器訪問“/trace”,以避免認證交叉(例如,如果您使用 Chrome 測試 UI,則使用 Firefox)。 |
登出體驗
如果您單擊“登出”連結,您將看到主頁發生變化(問候語不再顯示),因此使用者不再使用 UI 伺服器進行認證。但是,如果再次單擊“登入”,您實際上不需要再次經歷授權伺服器中的認證和批准週期(因為您尚未從授權伺服器登出)。關於這是否是理想的使用者體驗,意見不一,這是一個眾所周知棘手的問題(單點登出:Science Direct 文章和Shibboleth 文件)。理想的使用者體驗可能在技術上不可行,而且您有時也必須懷疑使用者是否真的想要他們所說的。 “我希望‘登出’能讓我登出”聽起來很簡單,但顯而易見的回答是,“從哪裡登出?您是想從這個 SSO 伺服器控制的所有系統中登出,還是隻從您點選‘登出’連結的那個系統中登出?”如果您感興趣,本教程的稍後一節將更深入地討論。
結論
我們對 Spring Security 和 Angular 堆疊的淺層探索到此結束。我們現在擁有一個漂亮的架構,在三個獨立的元件中具有明確的職責:UI/API 閘道器、資源伺服器和授權伺服器/令牌授予器。所有層中的非業務程式碼量現在都最小化了,並且很容易看出在哪裡透過更多的業務邏輯來擴充套件和改進實現。接下來的步驟將是清理授權伺服器中的 UI,並可能新增更多的測試,包括對 JavaScript 客戶端的測試。另一個有趣的任務是提取所有樣板程式碼並將其放入一個庫中(例如“spring-security-angular”),其中包含 Spring Security 和 Spring Session 自動配置以及 Angular 部分中導航控制器的一些 webjars 資源。讀過本系列文章的任何人,如果希望瞭解 Angular 或 Spring Security 的內部工作原理,可能會感到失望,但如果您想了解它們如何很好地協同工作以及一點點配置如何發揮巨大作用,那麼希望您會有一個愉快的體驗。Spring Cloud是新出的,這些示例在編寫時需要快照,但現在已有釋出候選版本,並且 GA 版本即將推出,所以請檢視並透過 Github 或gitter.im傳送一些反饋。
本系列的下一節是關於訪問決策(超越認證)的,並在同一代理後面使用多個 UI 應用程式。
附錄:Bootstrap UI 和授權伺服器的 JWT 令牌
您可以在Github 原始碼中找到此應用程式的另一個版本,它有一個漂亮的登入頁面和使用者批准頁面,其實現方式類似於我們在第二部分中實現登入頁面的方式。它還使用JWT對令牌進行編碼,因此資源伺服器無需使用“/user”端點,而是可以從令牌本身提取足夠的資訊來進行簡單的認證。瀏覽器客戶端仍然使用它,透過 UI 伺服器代理,以便它可以確定使用者是否已認證(與實際應用程式中對資源伺服器的可能呼叫次數相比,它不需要經常這樣做)。
多個 UI 應用程式和閘道器
本節我們繼續討論如何將Spring Security與Angular一起用於“單頁應用程式”。在這裡,我們展示瞭如何將Spring Session與Spring Cloud結合使用,以結合我們在第二部分和第四部分中構建的系統的功能,並最終構建三個具有截然不同職責的單頁應用程式。目標是構建一個閘道器(如第四部分),不僅用於 API 資源,還用於從後端伺服器載入 UI。我們透過使用閘道器將認證傳遞給後端,從而簡化了第二部分中令牌處理的複雜性。然後,我們擴充套件系統,展示如何在後端進行本地、細粒度的訪問決策,同時仍在閘道器控制身份和認證。這是一種構建分散式系統的強大模型,並具有許多優點,我們將在引入程式碼中的功能時進行探索。
提醒:如果您正在使用示例應用程式完成本節,請務必清除瀏覽器快取中的 cookie 和 HTTP Basic 憑據。在 Chrome 中,最好的方法是開啟一個新的隱身視窗。
目標架構
這是我們將要構建的基本系統的圖片

與本系列中的其他示例應用程式一樣,它有一個 UI(HTML 和 JavaScript)和一個資源伺服器。與第四節中的示例一樣,它有一個閘道器,但這裡它是獨立的,不屬於 UI。UI 實際上成為了後端的一部分,這使我們有更多的選擇來重新配置和重新實現功能,並且還帶來了其他好處,我們將在後面看到。
瀏覽器對所有事物都訪問閘道器,它不需要知道後端的架構(從根本上說,它不知道有後端)。瀏覽器在此閘道器中執行的操作之一是認證,例如,它傳送使用者名稱和密碼,就像在第二節中那樣,並返回一個 cookie。在後續請求中,它會自動呈現 cookie,閘道器會將其傳遞給後端。客戶端無需編寫任何程式碼即可啟用 cookie 傳遞。後端使用 cookie 進行認證,並且由於所有元件共享一個會話,它們共享相同的使用者資訊。這與第五節形成對比,在第五節中,cookie 必須在閘道器中轉換為訪問令牌,然後訪問令牌必須由所有後端元件獨立解碼。
我們將要構建的完整專案的原始碼在 Github 這裡,所以如果您願意,可以直接克隆專案並從那裡開始工作。該系統最終狀態中有一個額外的元件("double-admin"),所以暫時忽略它。
構建後端
在此架構中,後端與我們在第三節中構建的 “spring-session” 示例非常相似,不同之處在於它實際上不需要登入頁面。要達到我們想要的效果,最簡單的方法可能是從第三節複製“resource”伺服器,並從第一節的 “basic” 示例中獲取 UI。要從“basic”UI 達到我們想要的效果,我們只需要新增幾個依賴項(就像我們第一次在第三節中使用 Spring Session 時一樣)。
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-redis</artifactId>
</dependency>
既然這是一個 UI,就不再需要“/resource”端點。完成這些操作後,您將擁有一個非常簡單的 Angular 應用程式(與“basic”示例中的相同),這極大地簡化了測試和對其行為的推理。
最後,我們希望此伺服器作為後端執行,因此我們將為其指定一個非預設埠進行偵聽(在application.properties中)。
server.port: 8081
security.sessions: NEVER
如果那是application.properties的全部內容,那麼應用程式將是安全的,並且可供名為“user”的使用者訪問,其密碼是隨機的,但在啟動時會在控制檯(INFO 日誌級別)上打印出來。“security.sessions”設定意味著 Spring Security 將接受 cookie 作為認證令牌,但除非它們已經存在,否則不會建立它們。
資源伺服器
資源伺服器很容易從我們現有的示例中生成。它與第三節中的“spring-session”資源伺服器相同:只是一個“/resource”端點,Spring Session 用於獲取分散式會話資料。我們希望這個伺服器監聽一個非預設埠,並且我們希望能夠在會話中查詢認證,所以我們需要這些(在application.properties中)。
server.port: 9000
security.sessions: NEVER
我們將對我們的訊息資源進行 POST 更改,這是本教程中的一個新功能。這意味著我們將需要在後端進行 CSRF 保護,並且我們需要使用通常的技巧讓 Spring Security 與 Angular 良好配合。
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf()
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse());
}
如果您想檢視,完整的示例在 github 這裡。
閘道器
對於閘道器的初始實現(最簡單的可行方案),我們只需採用一個空的 Spring Boot Web 應用程式並新增@EnableZuulProxy註解。正如我們在第一節中看到的,有幾種方法可以做到這一點,其中一種是使用Spring Initializr生成一個骨架專案。更簡單的方法是使用Spring Cloud Initializr,它與 Spring Initializr 相同,但用於Spring Cloud應用程式。使用與第一節中相同的命令列操作序列
$ mkdir gateway && cd gateway
$ curl https://cloud-start.spring.io/starter.tgz -d style=web \
-d style=security -d style=cloud-zuul -d name=gateway \
-d style=redis | tar -xzvf -
然後,您可以將該專案(預設情況下是普通的 Maven Java 專案)匯入您最喜歡的 IDE,或者只使用檔案並在命令列上執行“mvn”。Github 中有一個版本,如果您想從那裡開始,但它有一些我們暫時不需要的額外功能。
從空白的 Initializr 應用程式開始,我們新增 Spring Session 依賴項(如上面的 UI 中)。閘道器已準備好執行,但它尚不瞭解我們的後端服務,所以讓我們在它的application.yml中進行設定(如果您執行了上面的 curl 操作,則從application.properties重新命名)。
zuul:
sensitive-headers:
routes:
ui:
url: https://:8081
resource:
url: https://:9000
security:
user:
password:
password
sessions: ALWAYS
代理中有 2 條路由,它們都使用sensitive-headers屬性將 cookie 傳遞到下游,一條用於 UI,一條用於資源伺服器,我們還設定了預設密碼和會話持久化策略(告訴 Spring Security 在認證時總是建立會話)。最後一點很重要,因為我們希望在閘道器中管理認證和會話。
啟動並執行
我們現在有三個元件,執行在三個埠上。如果您將瀏覽器指向 https://:8080/ui/,您應該會收到 HTTP Basic 挑戰,您可以以“user/password”身份進行認證(您在閘道器中的憑據),一旦您完成認證,您應該會在 UI 中看到一個問候語,透過代理對資源伺服器的後端呼叫來實現。
如果您使用一些開發者工具(通常按 F12 即可開啟,在 Chrome 中預設工作,在 Firefox 中可能需要外掛),您可以在瀏覽器中看到瀏覽器和後端之間的互動。以下是摘要:
| 動詞 | 路徑 | 狀態 | 響應 |
|---|---|---|---|
|
GET |
/ui/ |
401 |
瀏覽器提示進行身份驗證 |
|
GET |
/ui/ |
200 |
index.html |
|
GET |
/ui/*.js |
200 |
Angular 資產 |
|
GET |
/ui/js/hello.js |
200 |
應用程式邏輯 |
|
GET |
/ui/user |
200 |
認證 |
|
GET |
/resource/ |
200 |
JSON 問候語 |
您可能看不到 401 錯誤,因為瀏覽器將主頁載入視為單個互動。所有請求都經過代理(閘道器中除了 Actuator 管理端點外,目前沒有任何內容)。
太棒了,它起作用了!您現在有兩個後端伺服器,其中一個作為 UI,每個都具有獨立的功能,並且能夠獨立測試,它們透過一個受您控制且已配置認證的安全閘道器連線在一起。如果後端無法被瀏覽器訪問,這無關緊要(事實上,這可能是一個優勢,因為它為您提供了對物理安全更大的控制權)。
新增登入表單
就像第一節中的“basic”示例一樣,我們現在可以向閘道器新增一個登入表單,例如透過複製第二節中的程式碼。當我們這樣做時,我們還可以在閘道器中新增一些基本的導航元素,這樣使用者就不必知道代理中 UI 後端的路徑。所以讓我們首先將“single”UI 中的靜態資產複製到閘道器中,刪除訊息渲染並在主頁中(在<app/>的某個位置)插入一個登入表單。
<div class="container" [hidden]="authenticated">
<form role="form" (submit)="login()">
<div class="form-group">
<label for="username">Username:</label> <input type="text"
class="form-control" id="username" name="username"
[(ngModel)]="credentials.username" />
</div>
<div class="form-group">
<label for="password">Password:</label> <input type="password"
class="form-control" id="password" name="password"
[(ngModel)]="credentials.password" />
</div>
<button type="submit" class="btn btn-primary">Submit</button>
</form>
</div>
我們將有一個漂亮的導航大按鈕,而不是訊息渲染
<div class="container" [hidden]="!authenticated">
<a class="btn btn-primary" href="/ui/">Go To User Interface</a>
</div>
如果您正在檢視 github 中的示例,它還有一個帶有“登出”按鈕的最小導航欄。這是登入表單的截圖

為了支援登入表單,我們需要一些 TypeScript 程式碼,其中包含一個實現我們在<form/>中宣告的login()函式的元件,並且我們需要設定authenticated標誌,以便主頁根據使用者是否已認證而以不同的方式呈現。例如
include::src/app/app.component.ts
其中login()函式的實現類似於第二節中的實現。
我們可以使用self來儲存authenticated標誌,因為在這個簡單的應用程式中只有一個元件。
如果我們執行這個增強的閘道器,我們不需要記住 UI 的 URL,只需載入主頁並點選連結。這是一個已認證使用者的主頁

後端中的細粒度訪問決策
到目前為止,我們的應用程式在功能上與第三節或第四節中的應用程式非常相似,但增加了一個專用的閘道器。額外層級的優勢可能尚未顯現,但我們可以透過稍微擴充套件系統來強調它。假設我們希望使用該閘道器暴露另一個後端 UI,供使用者“管理”主 UI 中的內容,並且我們希望將對此功能的訪問限制為具有特殊角色的使用者。因此,我們將在代理後面新增一個“Admin”應用程式,系統將如下所示

閘道器的application.yml中有一個新元件(Admin)和一條新路由
zuul:
sensitive-headers:
routes:
ui:
url: https://:8081
admin:
url: https://:8082
resource:
url: https://:9000
現有 UI 可供“USER”角色的使用者使用這一事實在上方的閘道器框(綠色字母)中指出,同樣,“ADMIN”角色需要訪問 Admin 應用程式這一事實也指出。 “ADMIN”角色的訪問決策可以在閘道器中應用,在這種情況下它將出現在WebSecurityConfigurerAdapter中,或者可以在 Admin 應用程式本身中應用(我們將在下面看到如何做到這一點)。
所以首先,建立一個新的 Spring Boot 應用程式,或者複製 UI 並進行編輯。您不需要對 UI 應用程式進行太多更改,除了名稱。完成的應用程式在Github 這裡。
假設在 Admin 應用程式中,我們想區分“READER”和“WRITER”角色,以便我們可以允許(比如說)審計員使用者檢視主要管理員使用者所做的更改。這是一個細粒度的訪問決策,規則只在後端應用程式中知道,並且應該只在後端應用程式中知道。在閘道器中,我們只需要確保我們的使用者帳戶具有所需的角色,並且此資訊可用,但閘道器不需要知道如何解釋它。在閘道器中,我們建立使用者帳戶以保持示例應用程式的自包含。
@Configuration
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
@Autowired
public void globalUserDetails(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication()
.withUser("user").password("password").roles("USER")
.and()
.withUser("admin").password("admin").roles("USER", "ADMIN", "READER", "WRITER")
.and()
.withUser("audit").password("audit").roles("USER", "ADMIN", "READER");
}
}
其中“admin”使用者已增強了 3 個新角色(“ADMIN”、“READER”和“WRITER”),我們還添加了一個具有“ADMIN”訪問許可權但沒有“WRITER”訪問許可權的“audit”使用者。
| 在生產系統中,使用者賬戶資料將儲存在後端資料庫中(最可能是目錄服務),而不是硬編碼在 Spring 配置中。連線到此類資料庫的示例應用程式很容易在網際網路上找到,例如在Spring Security Samples中。 |
訪問決策在 Admin 應用程式中。對於“ADMIN”角色(此後端全域性要求),我們在 Spring Security 中進行處理。
@Configuration
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
...
.authorizeRequests()
.antMatchers("/index.html", "/").permitAll()
.antMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
...
}
}
對於“READER”和“WRITER”角色,應用程式本身是分開的,由於應用程式是用 JavaScript 實現的,所以我們需要在那裡做出訪問決策。一種方法是有一個帶有透過路由器嵌入的計算檢視的主頁。
<div class="container">
<h1>Admin</h1>
<router-outlet></router-outlet>
</div>
當元件載入時計算路由
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent {
user: {};
constructor(private app: AppService, private http: HttpClient, private router: Router) {
app.authenticate(response => {
this.user = response;
this.message();
});
}
logout() {
this.http.post('logout', {}).subscribe(function() {
this.app.authenticated = false;
this.router.navigateByUrl('/login');
});
}
message() {
if (!this.app.authenticated) {
this.router.navigate(['/unauthenticated']);
} else {
if (this.app.writer) {
this.router.navigate(['/write']);
} else {
this.router.navigate(['/read']);
}
}
}
...
}
應用程式做的第一件事是檢查使用者是否已認證,並透過檢視使用者資料計算路由。路由在主模組中宣告。
const routes: Routes = [
{ path: '', pathMatch: 'full', redirectTo: 'read'},
{ path: 'read', component: ReadComponent},
{ path: 'write', component: WriteComponent},
{ path: 'unauthenticated', component: UnauthenticatedComponent},
{ path: 'changes', component: ChangesComponent}
];
這些元件(每個路由一個)都必須單獨實現。這裡以ReadComponent為例。
import { Component } from '@angular/core';
import { HttpClient } from '@angular/common/http';
@Component({
templateUrl: './read.component.html'
})
export class ReadComponent {
greeting = {};
constructor(private http: HttpClient) {
http.get('/resource').subscribe(data => this.greeting = data);
}
}
<h1>Greeting</h1>
<div>
<p>The ID is {{greeting.id}}</p>
<p>The content is {{greeting.content}}</p>
</div>
WriteComponent類似,但有一個表單可以在後端更改訊息。
import { Component } from '@angular/core';
import { HttpClient } from '@angular/common/http';
@Component({
templateUrl: './write.component.html'
})
export class WriteComponent {
greeting = {};
constructor(private http: HttpClient) {
this.http.get('/resource').subscribe(data => this.greeting = data);
}
update() {
this.http.post('/resource', {content: this.greeting['content']}).subscribe(response => {
this.greeting = response;
});
}
}
<form (submit)="update()">
<p>The ID is {{greeting.id}}</p>
<div class="form-group">
<label for="username">Content:</label> <input type="text"
class="form-control" id="content" name="content" [(ngModel)]="greeting.content"/>
</div>
<button type="submit" class="btn btn-primary">Submit</button>
</form>
AppService還需要提供資料來計算路由,所以在authenticate()函式中我們看到這一點。
http.get('/user').subscribe(function(response) {
var user = response.json();
if (user.name) {
self.authenticated = true;
self.writer = user.roles && user.roles.indexOf("ROLE_WRITER")>0;
} else {
self.authenticated = false;
self.writer = false;
}
callback && callback(response);
})
為了支援後端上的此功能,我們需要/user端點,例如在我們的主應用程式類中。
@SpringBootApplication
@RestController
public class AdminApplication {
@RequestMapping("/user")
public Map<String, Object> user(Principal user) {
Map<String, Object> map = new LinkedHashMap<String, Object>();
map.put("name", user.getName());
map.put("roles", AuthorityUtils.authorityListToSet(((Authentication) user)
.getAuthorities()));
return map;
}
public static void main(String[] args) {
SpringApplication.run(AdminApplication.class, args);
}
}
| 角色名稱從“/user”端點返回時帶有“ROLE_”字首,以便我們能夠將其與其他型別的許可權區分開來(這是 Spring Security 的一個特性)。因此,在 JavaScript 中需要“ROLE_”字首,但在 Spring Security 配置中不需要,在那裡從方法名稱可以清楚地看出“角色”是操作的重點。 |
支援 Admin UI 的閘道器更改
我們還將使用角色在閘道器中進行訪問決策(以便我們可以有條件地顯示到管理員 UI 的連結),所以我們也應該在閘道器中的“/user”端點中新增“roles”。一旦到位,我們可以新增一些 JavaScript 來設定一個標誌,指示當前使用者是“ADMIN”。在authenticated()函式中
this.http.get('user', {headers: headers}).subscribe(data => {
this.authenticated = data && data['name'];
this.user = this.authenticated ? data['name'] : '';
this.admin = this.authenticated && data['roles'] && data['roles'].indexOf('ROLE_ADMIN') > -1;
});
當用戶登出時,我們還需要將admin標誌重置為false
this.logout = function() {
http.post('logout', {}).subscribe(function() {
self.authenticated = false;
self.admin = false;
});
}
然後在 HTML 中我們可以有條件地顯示一個新連結
<div class="container" [hidden]="!authenticated">
<a class="btn btn-primary" href="/ui/">Go To User Interface</a>
</div>
<br />
<div class="container" [hidden]="!authenticated || !admin">
<a class="btn btn-primary" href="/admin/">Go To Admin Interface</a>
</div>
執行所有應用程式並訪問https://:8080檢視結果。一切都應該正常工作,並且 UI 應該根據當前認證的使用者而改變。
我們為什麼在這裡?
現在我們有了一個不錯的小系統,它有兩個獨立的 UI 和一個後端資源伺服器,所有這些都由閘道器中的相同認證保護。閘道器充當微代理的事實使得後端安全問題的實現極其簡單,並且它們可以自由地專注於自己的業務問題。Spring Session 的使用(再次)避免了大量的麻煩和潛在的錯誤。
一個強大的功能是後端可以獨立擁有任何型別的認證(例如,如果您知道其物理地址和一組本地憑據,則可以直接訪問 UI)。閘道器施加了一組完全不相關的約束,只要它能夠認證使用者併為他們分配滿足後端訪問規則的元資料。這是構建獨立開發和測試後端元件的優秀設計。如果需要,我們可以回到外部 OAuth2 伺服器(如第五節,甚至是完全不同的東西)進行閘道器處的認證,而無需觸及後端。
這種架構的一個額外特性(單個閘道器控制認證,以及所有元件共享會話令牌)是“單點登出”這一功能,我們在第五節中指出該功能難以實現,現在免費提供。更準確地說,我們完成的系統中自動提供了一種特定的單點登出使用者體驗方法:如果使用者從任何 UI(閘道器、UI 後端或管理後端)登出,他將從所有其他 UI 登出,前提是每個單獨的 UI 都以相同的方式實現了“登出”功能(使會話失效)。
測試 Angular 應用程式
在本節中,我們將繼續討論如何將Spring Security與Angular結合用於“單頁應用程式”。這裡我們將展示如何使用 Angular 測試框架編寫和執行客戶端程式碼的單元測試。您可以透過閱讀第一節來了解應用程式的基本構建塊或從頭開始構建它,或者您可以直接訪問Github 上的原始碼(與第一部分相同的原始碼,但現在添加了測試)。本節實際上很少使用 Spring 或 Spring Security 的程式碼,但它以一種在通常的 Angular 社群資源中可能不太容易找到的方式介紹了客戶端測試,我們認為這種方式對大多數 Spring 使用者來說會很舒服。
提醒:如果您正在使用示例應用程式學習本節,請務必清除瀏覽器快取中的 cookie 和 HTTP Basic 憑據。在 Chrome 中,對於單個伺服器,最好的方法是開啟一個新的無痕視窗。
編寫規範
“basic”應用程式中的“app”元件非常簡單,因此徹底測試它不會花費太多精力。這裡是程式碼的提醒。
include::basic/src/app/app.component.ts
我們面臨的主要挑戰是在測試中提供http物件,以便我們可以斷言它們在元件中的使用方式。實際上,甚至在我們面臨這個挑戰之前,我們需要能夠建立一個元件例項,這樣我們才能測試它載入時會發生什麼。以下是您可以做到這一點的方法。
透過ng new建立的應用程式中的 Angular 構建已經有一個 spec 和一些配置來執行它。生成的 spec 位於“src/app”中,它以這種方式開始。
import { TestBed, async } from '@angular/core/testing';
import { AppComponent } from './app.component';
describe('AppComponent', () => {
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [],
declarations: [
AppComponent
]
}).compileComponents();
}));
it('should create the app', async(() => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.debugElement.componentInstance;
expect(app).toBeTruthy();
}));
...
}
在這個非常基本的測試套件中,我們有這些重要的元素
-
我們使用一個函式
describe()被測試的事物(在這種情況下是“AppComponent”)。 -
在該函式內部,我們提供了一個
beforeEach()回撥,它載入 Angular 元件。 -
行為透過呼叫
it()來表達,我們在其中用語言說明期望是什麼,然後提供一個進行斷言的函式。 -
測試環境在其他任何事情發生之前被初始化。這是大多數 Angular 應用程式的樣板。
這裡的測試函式非常簡單,它實際上只斷言元件存在,所以如果失敗,測試就會失敗。
改進單元測試:模擬 HTTP 後端
為了將規範改進到生產級別,我們實際上需要斷言控制器載入時會發生什麼。由於它呼叫了http.get(),我們需要模擬該呼叫以避免僅為單元測試執行整個應用程式。為此,我們使用 Angular HttpClientTestingModule。
Unresolved directive in testing.adoc - include::basic/src/app/app.component.spec[indent=0]
這裡的新部分是
-
在
beforeEach()中,將HttpClientTestingModule宣告為TestBed中的匯入。 -
在測試函式中,我們在建立元件之前為後端設定期望,告訴它期望呼叫“resource/”,以及響應應該是什麼。
執行規格
要執行我們的測試程式碼,我們可以使用設定專案時建立的便利指令碼./ng test(或./ng build)。它也作為 Maven 生命週期的一部分執行,所以./mvnw install也是執行測試的好方法,這將在您的 CI 構建中發生。
端到端測試
Angular 還有一個標準的構建設定,用於使用瀏覽器和生成的 JavaScript 的“端到端測試”。這些測試以“specs”的形式寫入頂層e2e目錄。本教程中的所有示例都包含一個非常簡單的端到端測試,它在 Maven 生命週期中執行(因此,如果您在任何“ui”應用程式中執行mvn install,您將看到一個瀏覽器視窗彈出)。
結論
在現代 Web 應用程式中,能夠執行 JavaScript 單元測試非常重要,這是我們在這個系列中一直忽略(或迴避)的話題。透過這一期,我們介紹了編寫測試、在開發時執行測試以及更重要的是在持續整合環境中執行測試的基本要素。我們採取的方法不適合所有人,所以請不要因為以不同的方式進行而感到難過,但請確保您擁有所有這些要素。我們在這裡所做的方式可能會讓傳統的 Java 企業開發人員感到舒適,並且與他們現有的工具和流程很好地整合,所以如果您屬於這一類,我希望您會發現它是一個有用的起點。更多使用 Angular 和 Jasmine 進行測試的示例可以在網際網路上找到很多地方,但第一個參考點可能是本系列中的“single”示例,它現在有一些更新的測試程式碼,這些程式碼比本教程中“basic”示例所需的程式碼稍微不那麼簡單。
OAuth2 客戶端應用程式的登出
在本節中,我們將繼續討論如何將Spring Security與Angular結合用於“單頁應用程式”。在這裡,我們將展示如何獲取 OAuth2 示例並新增不同的登出體驗。許多實現 OAuth2 單點登入的人發現他們有一個難題需要解決,即如何“乾淨地”登出?之所以是個難題,是因為沒有單一的正確方法來完成,您選擇的解決方案將取決於您所尋求的使用者體驗,以及您願意承擔的複雜程度。複雜性的原因在於系統中可能存在多個瀏覽器會話,所有會話都有不同的後端伺服器,因此當用戶從其中一個會話登出時,其他會話應該發生什麼?這是教程的第九節,您可以透過閱讀第一節來了解應用程式的基本構建塊或從頭開始構建它,或者您可以直接訪問Github 上的原始碼。
登出模式
本教程中oauth2示例的登出使用者體驗是:您從 UI 應用程式登出,但未從認證伺服器登出,因此當您重新登入 UI 應用程式時,認證伺服器不會再次要求憑據。當認證伺服器是外部的時,這是完全預期、正常且可取的——Google 和其他外部認證伺服器提供商既不希望也不允許您從未受信任的應用程式中從其伺服器登出——但如果認證伺服器實際上是 UI 的一部分,則這不是最佳使用者體驗。
概括地說,OAuth2 客戶端認證的 UI 應用程式有三種登出模式
-
外部認證伺服器(EA,原始示例)。使用者將認證伺服器視為第三方(例如,使用 Facebook 或 Google 進行認證)。您不希望在應用程式會話結束時從認證伺服器登出。您確實希望獲得所有授權的批准。本教程中的
oauth2(和oauth2-vanilla)示例實現了此模式。 -
閘道器和內部認證伺服器 (GIA)。您只需從 2 個應用程式登出,它們在使用者看來是同一系統的一部分。通常您希望自動批准所有授權。
-
單點登出 (SL)。一個認證伺服器和多個 UI 應用程式,它們都有自己的認證,當用戶從其中一個登出時,您希望所有應用程式都隨之登出。由於網路分割槽和伺服器故障,天真的實現可能會失敗——您基本上需要全域性一致的儲存。
有時,即使您有一個外部認證伺服器,您也希望控制認證並新增內部訪問控制層(例如,認證伺服器不支援的範圍或角色)。那麼,使用 EA 進行認證,但擁有一個內部認證伺服器,可以向令牌新增您需要的額外詳細資訊,這是一個好主意。此OAuth2 教程中的auth-server示例向您展示瞭如何以非常簡單的方式實現這一點。然後,您可以將 GIA 或 SL 模式應用於包含內部認證伺服器的系統。
如果您不想要 EA,這裡有一些選項
-
在瀏覽器客戶端中同時從認證伺服器和 UI 應用程式登出。簡單的方法,透過一些仔細的 CRSF 和 CORS 配置即可實現。不支援 SL。
-
令牌一可用,就從認證伺服器登出。這在 UI 中很難實現,因為令牌是在那裡獲取的,並且在那裡您沒有認證伺服器的會話 cookie。Spring OAuth 中有一個功能請求,展示了一種有趣的方法:一旦生成授權碼,就使認證伺服器中的會話失效。Github 問題包含一個實現會話失效的切面,但作為
HandlerInterceptor更容易實現。不支援 SL。 -
透過與 UI 相同的閘道器代理認證伺服器,並希望一個 cookie 足以管理整個系統的狀態。這不起作用,因為除非存在共享會話,這在一定程度上違背了目的(否則認證伺服器沒有會話儲存)。只有當所有應用程式之間共享會話時才支援 SL。
-
閘道器中的 Cookie 轉發。您將閘道器用作認證的真相來源,並且認證伺服器擁有所需的所有狀態,因為閘道器管理 cookie 而不是瀏覽器。瀏覽器從不擁有來自多個伺服器的 cookie。不支援 SL。
-
使用令牌作為全域性認證並在使用者從 UI 應用程式登出時使其失效。缺點:需要客戶端應用程式使令牌失效,這並非其設計目的。SL 可能,但受制於通常的限制。
-
在認證伺服器中建立和管理全域性會話令牌(除了使用者令牌)。這是 OpenId Connect 採取的方法,它確實提供了一些 SL 選項,但代價是增加了一些機制。沒有一個選項能免受常見的分散式系統限制:如果網路和應用程式節點不穩定,則無法保證在需要時登出訊號會在所有參與者之間共享。所有登出規範仍在草案階段,這裡有一些規範連結:會話管理、前端通道登出和後端通道登出。
請注意,當 SL 困難或不可能時,最好將所有 UI 都放在一個閘道器後面。這樣您就可以使用 GIA(更簡單)來控制整個資產的登出。
最簡單的兩個選項,在 GIA 模式中很好地應用,可以在教程示例中實現如下(以oauth2示例為起點並從那裡開始)。
從瀏覽器同時登出兩個伺服器
在瀏覽器客戶端中新增幾行程式碼,以便在 UI 應用程式登出後立即從認證伺服器登出,這非常容易。例如
logout() {
this.http.post('logout', {}).finally(() => {
self.authenticated = false;
this.http.post('https://:9999/uaa/logout', {}, {withCredentials:true})
.subscribe(() => {
console.log('Logged out');
});
}).subscribe();
};
在此示例中,我們將認證伺服器登出端點 URL 硬編碼到 JavaScript 中,但如果需要,很容易將其外部化。它必須是直接 POST 到認證伺服器,因為我們希望會話 cookie 也一起傳送。只有當我們明確要求withCredentials:true時,XHR 請求才會從瀏覽器發出並附帶 cookie。
相反,在伺服器上我們需要一些 CORS 配置,因為請求來自不同的域。例如,在WebSecurityConfigurerAdapter中
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.requestMatchers().antMatchers("/login", "/logout", "/oauth/authorize", "/oauth/confirm_access")
.and()
.cors().configurationSource(configurationSource())
...
}
private CorsConfigurationSource configurationSource() {
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
CorsConfiguration config = new CorsConfiguration();
config.addAllowedOrigin("*");
config.setAllowCredentials(true);
config.addAllowedHeader("X-Requested-With");
config.addAllowedHeader("Content-Type");
config.addAllowedMethod(HttpMethod.POST);
source.registerCorsConfiguration("/logout", config);
return source;
}
“/logout”端點經過特殊處理。它允許從任何來源呼叫,並明確允許傳送憑據(例如 cookie)。允許的標頭只是 Angular 在示例應用程式中傳送的那些。
除了 CORS 配置,我們還需要為登出端點停用 CSRF,因為 Angular 不會在跨域請求中傳送X-XSRF-TOKEN標頭。認證伺服器之前不需要任何 CSRF 配置,但很容易為登出端點新增一個忽略。
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.csrf()
.ignoringAntMatchers("/logout/**")
...
}
| 放棄 CSRF 保護確實不明智,但對於這種受限用例,您可能願意容忍它。 |
透過這兩個簡單的更改,一個在 UI 應用程式客戶端,一個在認證伺服器,您會發現一旦您從 UI 應用程式登出,當您重新登入時,您將始終被要求輸入密碼。
另一個有用的更改是將 OAuth2 客戶端設定為自動批准,這樣使用者就不必批准令牌授予。這在內部認證伺服器中很常見,使用者不將其視為一個獨立的系統。在AuthorizationServerConfigurerAdapter中,您只需要在客戶端初始化時設定一個標誌。
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.inMemory().withClient("acme")
...
.autoApprove(true);
}
使認證伺服器中的會話失效
如果您不喜歡在登出端點上放棄 CSRF 保護,您可以嘗試另一種簡單的方法,即在授予令牌後(實際上是生成授權碼後)立即使認證伺服器中的使用者會話失效。這也很容易實現:從oauth2示例開始,只需向 OAuth2 端點新增一個HandlerInterceptor。
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints)
throws Exception {
...
endpoints.addInterceptor(new HandlerInterceptorAdapter() {
@Override
public void postHandle(HttpServletRequest request,
HttpServletResponse response, Object handler,
ModelAndView modelAndView) throws Exception {
if (modelAndView != null
&& modelAndView.getView() instanceof RedirectView) {
RedirectView redirect = (RedirectView) modelAndView.getView();
String url = redirect.getUrl();
if (url.contains("code=") || url.contains("error=")) {
HttpSession session = request.getSession(false);
if (session != null) {
session.invalidate();
}
}
}
}
});
}
此攔截器查詢RedirectView,這是一個訊號,表示使用者正在被重定向回客戶端應用程式,並檢查該位置是否包含授權碼或錯誤。如果您也使用隱式授權,則可以新增“token=”。
透過這個簡單的更改,一旦您進行認證,認證伺服器中的會話就已經失效,因此無需嘗試從客戶端管理它。當您從 UI 應用程式登出,然後重新登入時,認證伺服器不會識別您並提示輸入憑據。此模式由本教程原始碼中的oauth2-logout示例實現。這種方法的缺點是您不再真正擁有真正的單點登入——系統中作為您系統一部分的任何其他應用程式都會發現認證伺服器會話已失效,並且它們必須再次提示進行認證——如果存在多個應用程式,這並不是一個很好的使用者體驗。
結論
在本節中,我們已經看到了如何實現兩種不同的 OAuth2 客戶端應用程式登出模式(以教程第五節中的應用程式為起點),並討論了其他模式的一些選項。這些選項並非詳盡無遺,但應該能讓您很好地瞭解所涉及的權衡,以及一些思考您的用例最佳解決方案的工具。本節中只有幾行 JavaScript,而且它並不真正特定於 Angular(它為 XHR 請求添加了一個標誌),因此所有教訓和模式都適用於本指南中示例應用程式的狹窄範圍之外。一個反覆出現的主題是,所有涉及多個 UI 應用程式和單個認證伺服器的單點登出 (SL) 方法都傾向於存在某種缺陷:您能做的最好的事情就是選擇最讓您的使用者感到舒適的方法。如果您有一個內部認證伺服器和一個由許多元件組成的系統,那麼可能唯一讓使用者感覺像單個系統的架構就是所有使用者互動的閘道器。
想寫新指南或為現有指南做貢獻嗎?請檢視我們的貢獻指南。
| 所有指南的程式碼均採用 ASLv2 許可,文字內容採用署名-禁止演繹知識共享許可。 |