Spring Security 與 Angular

安全的單頁應用程式 (A Secure Single Page Application)

在本教學中,我們展示了 Spring Security、Spring Boot 和 Angular 協同工作的一些優秀特性,以提供令人愉快且安全的用戶體驗。 Spring 和 Angular 的初學者應該很容易理解,但其中也有很多細節對這兩方面的專家都有用。 這實際上是 Spring Security 和 Angular 系列的第一部分,每個部分都會陸續公開新功能。 我們將在第二和後續版本中改進應用程式,但此後的主要變化是架構上的,而不是功能上的。

Spring 與單頁應用程式 (Spring and the Single Page Application)

HTML5、豐富的瀏覽器功能和「單頁應用程式」是現代開發人員非常有價值的工具,但任何有意義的互動都將涉及後端伺服器,因此除了靜態內容(HTML、CSS 和 JavaScript)之外,我們還需要一個後端伺服器。 後端伺服器可以扮演多種角色中的任何一種或全部:提供靜態內容、有時(但現在不那麼頻繁)呈現動態 HTML、驗證使用者、保護對受保護資源的訪問,以及(最後但並非最不重要的)通過 HTTP 和 JSON(有時稱為 REST API)與瀏覽器中的 JavaScript 互動。

Spring 一直是構建後端功能(尤其是在企業中)的流行技術,並且隨著 Spring Boot 的出現,事情變得前所未有的容易。 讓我們來看看如何使用 Spring Boot、Angular 和 Twitter Bootstrap 從無到有構建一個新的單頁應用程式。 沒有特別的理由選擇該特定堆疊,但它非常流行,尤其是在企業 Java 商店中的核心 Spring 社群中,因此這是一個值得開始的起點。

建立新專案 (Create a New Project)

我們將逐步詳細創建此應用程式,以便任何不完全熟悉 Spring 和 Angular 的人都可以理解正在發生的事情。 如果您喜歡直接切入主題,您可以跳到最後,查看應用程式如何運作,以及它們如何組合在一起。 創建新專案有多種選擇

我們將要構建的完整專案的原始碼位於 Github 此處,因此您可以直接複製專案並直接從那裡開始工作。 然後跳到下一節

使用 Curl

創建一個新專案以開始的最簡單方法是通過 Spring Boot Initializr。 例如,在類似 UN*X 的系統上使用 curl

$ mkdir ui && cd ui
$ curl https://start.spring.io/starter.tgz -d dependencies=web,security -d name=ui | tar -xzvf -

然後您可以將該專案(預設情況下它是一個普通的 Maven Java 專案)匯入到您最喜歡的 IDE 中,或者只使用檔案並在命令列上使用 "mvn"。 然後跳到下一節

使用 Spring Boot CLI

您可以使用 Spring Boot CLI 創建相同的專案,如下所示

$ spring init --dependencies web,security ui/ && cd ui

然後跳到下一節

使用 Initializr 網站 (Using the Initializr Website)

如果您願意,您也可以直接從 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 應用程式 (Add an Angular App)

如今,Angular(或任何現代前端框架)中的單頁應用程式的核心將是 Node.js 建構。 Angular 提供了一些快速設定它的工具,所以讓我們使用它們,並保留使用 Maven 建構的選項,就像任何其他 Spring Boot 應用程式一樣。 如何設定 Angular 應用程式的詳細資訊在其他地方介紹,或者您可以從 github 檢出本教學課程的程式碼。

執行應用程式 (Running the Application)

Angular 應用程式準備好後,您的應用程式就可以在瀏覽器中載入(即使它還沒有做很多事情)。 在命令列中,您可以執行以下操作

$ mvn spring-boot:run

並在瀏覽器中轉到 https://127.0.0.1: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 應用程式 (Customize the Angular Application)

讓我們自訂「app-root」元件(在「src/app/app.component.ts」中)。

一個最小的 Angular 應用程式如下所示

app.component.ts
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 中,我們在這裡定義「selector」(HTML 元素的名稱)和 HTML 片段,以通過 @Component 註釋進行呈現。 我們還需要編輯 HTML 模板(「app.component.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 在 HTML 中使用 handlebar 佔位符 {{greeting.id}}{{greeting.content}} 呈現。

新增動態內容 (Adding Dynamic Content)

到目前為止,我們有一個帶有硬編碼問候語的應用程式。 這對於了解事物如何組合在一起很有用,但實際上我們希望內容來自後端伺服器,所以讓我們創建一個 HTTP 端點,我們可以通過它來抓取問候語。 在您的 應用程式類別(在「src/main/java/demo」中)中,新增 @RestController 註釋並定義一個新的 @RequestMapping

UiApplication.java
@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 載入動態資源 (Loading a Dynamic Resource from Angular)

所以讓我們在瀏覽器中抓取該訊息。 修改 AppComponent 以使用 XHR 載入受保護的資源

app.component.ts
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);
  }
}

我們注入了一個 http 服務,該服務由 Angular 通過 http 模組提供,並使用它來 GET 我們的資源。 Angular 將響應傳遞給我們,我們提取 JSON 並將其分配給問候語。

為了在我們的自訂元件中啟用 http 服務的依賴注入,我們需要在包含該元件的 AppModule 中聲明它(與初始草稿相比,它只是 imports 中的另一行)

app.module.ts
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。

它是如何運作的? (How Does it Work?)

如果您使用一些開發人員工具,可以在瀏覽器中看到瀏覽器和後端之間的互動(通常 F12 會打開此工具,預設在 Chrome 中有效,可能需要在 Firefox 中安裝插件)。 這是一個摘要

動詞 (Verb) 路徑 (Path) 狀態 (Status) 響應 (Response)

GET

/

401

瀏覽器提示進行身份驗證

GET

/

200

index.html

GET

/*.js

200

從 angular 載入的大量第三方資產

GET

/main.bundle.js

200

應用程式邏輯

GET

/resource

200

JSON 問候語

您可能看不到 401,因為瀏覽器將首頁載入視為單個互動,並且您可能會看到 2 個對 「/resource」 的請求,因為存在 CORS 協商。

更仔細地查看這些請求,您會看到它們都帶有「Authorization」標頭,如下所示

Authorization: Basic dXNlcjpwYXNzd29yZA==

瀏覽器正在通過每個請求發送使用者名稱和密碼(因此請記住在生產環境中專門使用 HTTPS)。 這與「Angular」無關,因此它可以與您選擇的 JavaScript 框架或非框架一起使用。

那裡有什麼問題? (What’s Wrong with That?)

從表面上看,我們似乎做得很好,它簡潔、易於實施,我們所有的數據都受到秘密密碼的保護,如果我們更改前端或後端技術,它仍然可以工作。 但存在一些問題。

  • 基本身份驗證僅限於使用者名稱和密碼身份驗證。

  • 身份驗證 UI 無處不在,但很醜陋(瀏覽器對話方塊)。

  • 沒有針對 跨站請求偽造 (CSRF) 的保護。

CSRF 並不是我們的應用程式真正存在的問題,因為它只需要 GET 後端資源(即伺服器中沒有任何狀態發生變化)。 一旦您的應用程式中有 POST、PUT 或 DELETE,它就根本不再安全,無法以任何合理的現代標準衡量。

本系列的下一節中,我們將擴展應用程式以使用基於表單的身份驗證,這比 HTTP Basic 靈活得多。一旦我們有了表單,我們將需要 CSRF 保護,而 Spring Security 和 Angular 都有一些現成的功能可以幫助我們。劇透:我們將需要使用 HttpSession

感謝:我要感謝所有幫助我開發這個系列的人,特別是 Rob WinchThorsten Spaeth,他們仔細審閱了文本和原始碼,並教我一些我甚至對自己最熟悉的部分都不知道的技巧。

登入頁面

在本節中,我們繼續討論如何在 "單頁應用程式" 中使用 Spring SecurityAngular。在這裡,我們展示如何使用 Angular 通過表單驗證使用者身份並獲取安全資源以在 UI 中呈現。這是系列文章的第二部分,您可以閱讀第一節來了解應用程式的基本構建模組或從頭開始構建它,或者您可以直接前往 Github 中的原始碼。在第一節中,我們構建了一個簡單的應用程式,該應用程式使用 HTTP Basic 身份驗證來保護後端資源。在這一節中,我們添加了一個登入表單,讓使用者可以控制是否進行身份驗證,並修復了第一次迭代的問題(主要是缺乏 CSRF 保護)。

提醒:如果您正在使用範例應用程式來完成本節,請務必清除瀏覽器快取中的 Cookie 和 HTTP Basic 憑證。在 Chrome 中,針對單個伺服器執行此操作的最佳方法是開啟一個新的無痕視窗。

將導覽功能新增到首頁

Angular 應用程式的核心是基本頁面佈局的 HTML 範本。我們已經有一個非常基本的範本,但對於此應用程式,我們需要提供一些導覽功能(登入、登出、首頁),因此讓我們修改它(在 src/app 中)

app.component.html
<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)。以下是模組的實作,它將所有部分組合在一起

app.module.ts
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 的建構函式中。routesAppModule 的匯入中使用,以設定到 "/"("首頁" 控制器)和 "/login"("登入" 控制器)的連結。

我們也偷偷地將 FormsModule 放入其中,因為稍後需要它來將資料繫結到一個我們希望在使用者登入時提交的表單。

UI 元件都是 "宣告",而服務膠水是 "提供者"。AppComponent 實際上沒有做太多事情。與應用程式根目錄一起使用的 TypeScript 元件在此處

app.component.ts
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(),或者僅僅是為了查詢使用者詳細資訊

app.service.ts
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 標誌很簡單。如果提供了 HTTP Basic 身份驗證憑證,authenticate() 函數會發送它們,否則不會。它也有一個可選的 callback 參數,如果身份驗證成功,我們可以使用它來執行一些程式碼。

問候語

舊首頁的問候語內容可以直接放在 "src/app" 中的 "app.component.html" 旁邊

home.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 必須獲取問候語,並且還必須提供從 AppService 中提取標誌的 authenticated() 實用函數

home.component.ts
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; }

}

登入表單

登入表單也有自己的元件

login.component.html
<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>

這是一個非常標準的登入表單,包含 2 個用於使用者名稱和密碼的輸入框,以及一個用於通過 Angular 事件處理器 (submit) 提交表單的按鈕。您不需要表單標籤上的 action,因此最好不要放入任何 action。還有一個錯誤訊息,只有在 angular 模型包含一個 error 時才會顯示。表單控制項使用來自 Angular FormsngModel 在 HTML 和 Angular 控制器之間傳遞資料,並且在這種情況下,我們使用一個 credentials 物件來保存使用者名稱和密碼。

身份驗證流程

為了支援我們剛才新增的登入表單,我們需要新增一些功能。在客戶端,這些功能將在 LoginComponent 中實作,而在伺服器端,它將是 Spring Security 配置。

提交登入表單

要提交表單,我們需要定義我們已經通過 ng-submit 在表單中引用的 login() 函數,以及我們通過 ng-model 引用的 credentials 物件。讓我們充實 "login" 元件

login.component.ts
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() 函數,我們需要向後端新增一個新的端點

UiApplication.java
@SpringBootApplication
@RestController
public class UiApplication {

  @RequestMapping("/user")
  public Principal user(Principal user) {
    return user;
  }

  ...

}

這是 Spring Security 應用程式中的一個有用的技巧。如果 "/user" 資源可訪問,那麼它將返回目前已通過身份驗證的使用者(一個 Authentication),否則 Spring Security 將攔截請求並通過一個 AuthenticationEntryPoint 發送一個 401 響應。

在伺服器上處理登入請求

Spring Security 使處理登入請求變得容易。我們只需要向我們的 主應用程式類別 新增一些配置(例如作為一個內部類別)

UiApplication.java
@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 配置中執行此操作,但由於它是靜態內容,因此最好直接忽略它

application.yml
security:
  ignored:
  - "*.bundle.*"

新增預設 HTTP 請求標頭

如果您在此時執行該應用程式,您會發現瀏覽器會彈出一個 Basic 身份驗證對話框(用於使用者和密碼)。它會這樣做,因為它看到來自對 /user/resource 的 XHR 請求的 401 響應,並帶有一個 "WWW-Authenticate" 標頭。抑制此彈出視窗的方法是抑制標頭,該標頭來自 Spring Security。而抑制響應標頭的方法是發送一個特殊的、傳統的請求標頭 "X-Requested-With=XMLHttpRequest"。它曾經是 Angular 中的預設值,但他們在 1.3.0 中移除了它。因此,以下是如何在 Angular XHR 請求中設定預設標頭。

首先擴展 Angular HTTP 模組提供的預設 RequestOptions

app.module.ts
@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);
  }
}

此處的語法是樣板程式碼。Classimplements 屬性是其基底類別,除了建構函式之外,我們真正需要做的就是覆寫 Angular 始終呼叫的 intercept() 函數,該函數可用於新增其他標頭。

要安裝這個新的 RequestOptions 工廠,我們需要在 AppModuleproviders 中宣告它

app.module.ts
@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 具有 基於 Cookie 的 CSRF 內建支援(它稱之為 "XSRF")。

因此,在伺服器上,我們需要一個自訂篩選器來發送 Cookie。Angular 希望 Cookie 名稱為 "XSRF-TOKEN",而 Spring Security 預設將其作為請求屬性提供,因此我們只需要將該值從請求屬性傳輸到 Cookie。幸運的是,Spring Security(自 4.1.0 起)提供了一個特殊的 CsrfTokenRepository,它正是做這件事

UiApplication.java
@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());
  }
}

完成這些更改後,我們不需要在客戶端做任何事情,並且登入表單現在可以正常工作。

它是如何運作的? (How Does it Work?)

如果您使用一些開發人員工具,可以在瀏覽器中看到瀏覽器和後端之間的互動(通常 F12 會打開此工具,預設在 Chrome 中有效,可能需要在 Firefox 中安裝插件)。 這是一個摘要

動詞 (Verb) 路徑 (Path) 狀態 (Status) 響應 (Response)

GET

/

200

index.html

GET

/*.js

200

來自 angular 的資產

GET

/user

401

未授權 (已忽略)

GET

/home

200

首頁

GET

/user

401

未授權 (已忽略)

GET

/resource

401

未授權 (已忽略)

GET

/user

200

發送憑證並獲取 JSON

GET

/resource

200

JSON 問候語

以上標記為「已忽略」的回應是 Angular 在 XHR 呼叫中收到的 HTML 回應,由於我們沒有處理這些資料,因此 HTML 會被丟棄。在 "/user" 資源的情況下,我們確實會尋找已驗證的使用者,但由於第一次呼叫中沒有,所以該回應也會被丟棄。

仔細觀察這些請求,你會發現它們都帶有 cookies。如果你從一個乾淨的瀏覽器開始(例如 Chrome 的無痕模式),第一個請求不會向伺服器發送任何 cookies,但伺服器會回傳 "Set-Cookie" 給 "JSESSIONID" (一般的 HttpSession) 和 "X-XSRF-TOKEN" (我們上面設定的 CRSF cookie)。後續的請求都會帶有這些 cookies,而且它們非常重要:應用程式沒有它們就無法運作,而且它們提供了一些非常基本的安全功能(驗證和 CSRF 保護)。當使用者通過驗證後(在 POST 之後),cookies 的值會改變,這是另一個重要的安全功能(防止session fixation attacks(會話固定攻擊))。

僅僅依靠 cookie 發送回伺服器來進行 CSRF 保護是不夠的,因為即使你不在從你的應用程式加載的頁面中(跨站腳本攻擊,也稱為 XSS),瀏覽器也會自動發送它。header 不會自動發送,因此 origin 是受控的。你可能會看到在我們的應用程式中,CSRF token 作為 cookie 發送給客戶端,所以我們將看到它被瀏覽器自動發送回來,但真正提供保護的是 header。

求救,我的應用程式要如何擴展?

你可能會說:「等等…在單頁應用程式中使用 session state 難道不是非常糟糕嗎?」這個問題的答案必須是「大部分情況下是」,因為使用 session 進行身份驗證和 CSRF 保護絕對是一件好事。該狀態必須儲存在某個地方,如果你將其從 session 中取出,則必須將其放在其他地方,並在伺服器和客戶端上手動管理它。這只會增加更多的程式碼和可能的維護,而且通常是重新發明一個完美的輪子。

你可能會回應說:「但是,但是…我現在該如何水平擴展我的應用程式?」這才是你上面提出的「真正」問題,但它往往被簡化為「session state 不好,我必須是無狀態的」。別慌。這裡要記住的重點是安全是有狀態的。你不能擁有一個安全的、無狀態的應用程式。所以你打算把狀態儲存在哪裡?這就是全部了。 Rob WinchSpring Exchange 2014 上發表了一個非常有用的、富有洞察力的演講,解釋了狀態的必要性(以及它的普遍性 - TCP 和 SSL 都是有狀態的,所以你的系統是有狀態的,無論你是否知道),如果你想更深入地研究這個主題,它可能值得一看。

好消息是你還有選擇。最簡單的選擇是將 session 資料儲存在記憶體中,並依靠負載平衡器中的 sticky sessions(黏性會話)將來自同一 session 的請求路由回相同的 JVM(它們都以某種方式支援這一點)。這足以讓你起步,並且適用於非常大量的用例。另一個選擇是在應用程式的實例之間共享 session 資料。只要你嚴格且只儲存安全資料,它就很小並且很少更改(僅當使用者登入和登出或他們的 session 超時時),因此不應該有任何主要的基礎架構問題。使用 Spring Session 也很容易做到。我們將在本系列的下一節中使用 Spring Session,所以這裡不需要詳細介紹如何設定它,但它實際上只是幾行程式碼和一個 Redis 伺服器,它非常快。

設定共享 session state 的另一個簡單方法是將你的應用程式作為 WAR 檔案部署到 Cloud Foundry Pivotal Web Services 並將其綁定到 Redis 服務。

但是,我的自訂 Token 實作呢(它是無狀態的,你看)?

如果那是你對上一節的回應,那就再讀一遍,因為你可能第一次沒有理解。如果你將 token 儲存在某個地方,它可能不是無狀態的,但即使你沒有(例如,你使用 JWT 編碼的 token),你將如何提供 CSRF 保護?這很重要。這裡有一個經驗法則(歸功於 Rob Winch):如果你的應用程式或 API 將被瀏覽器訪問,你需要 CSRF 保護。並不是說沒有 sessions 你就不能做到,而是你必須自己編寫所有這些程式碼,而這樣做的意義是什麼呢,因為它已經在 HttpSession 之上實現並完美地運作了(而 HttpSession 本身就是你正在使用的容器的一部分,並且從一開始就被烘焙到規範中)?即使你決定不需要 CSRF,並且擁有一個完美的「無狀態」(非基於 session)的 token 實作,你仍然必須在客戶端編寫額外的程式碼來消耗和使用它,而你可以簡單地委託給瀏覽器和伺服器自己的內建功能:瀏覽器總是發送 cookies,而伺服器總是擁有一個 session(除非你關閉它)。該程式碼不是業務邏輯,它不會為你賺錢,它只是一個開銷,更糟糕的是,它會花你的錢。

結論

我們現在的應用程式接近使用者在實際環境中的「真實」應用程式中的期望,並且它可以用作構建具有該架構(具有靜態內容和 JSON 資源的單一伺服器)的更豐富功能的應用程式的範本。我們正在使用 HttpSession 儲存安全資料,依靠我們的客戶端尊重和使用我們發送給他們的 cookies,我們對此感到滿意,因為它可以讓我們專注於我們自己的業務領域。在下一節中,我們將架構擴展到一個單獨的身份驗證和 UI 伺服器,以及一個用於 JSON 的獨立資源伺服器。這顯然很容易推廣到多個資源伺服器。我們還將把 Spring Session 引入到堆疊中,並展示如何使用它來共享身份驗證資料。

資源伺服器

在本節中,我們繼續我們的討論,關於如何在「單頁應用程式」中使用 Spring SecurityAngular。在這裡,我們首先將我們用作應用程式中動態內容的 "greeting" 資源分離到一個單獨的伺服器,首先作為一個不受保護的資源,然後由一個不透明的 token 保護。這是系列中的第三節,你可以通過閱讀第一節來了解應用程式的基本構建模組或從頭開始構建它,或者你可以直接跳轉到 Github 中的原始碼,它分為兩部分:一部分是 資源不受保護,另一部分是 受到 token 保護

如果你正在使用範例應用程式完成本節,請務必清除瀏覽器中 cookies 和 HTTP Basic 憑證的快取。在 Chrome 中,針對單個伺服器執行此操作的最佳方法是打開一個新的無痕視窗。

一個單獨的資源伺服器

客戶端變更

在客戶端,將資源移動到不同的後端並沒有太多事情要做。這是 上一節中的 "home" 元件

home.component.ts
@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 上執行新的資源,它可以像這樣

home.component.ts
        http.get('https://127.0.0.1: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"。

只需將一個 @RequestMapping 新增到 主應用程式類別中,從 舊的 UI 複製實作

ResourceApplication.java
@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://127.0.0.1:9000,你應該會看到帶有 greeting 的 JSON。你可以在 application.properties(在 "src/main/resources" 中)中加入端口變更

application.properties
server.port: 9000

如果你嘗試從瀏覽器中的 UI(在 8080 端口上)加載該資源,你會發現它無法運作,因為瀏覽器不允許 XHR 請求。

CORS 協商

瀏覽器嘗試與我們的資源伺服器協商,以了解是否允許它根據 跨域資源共享協議訪問它。這不是 Angular 的責任,所以就像 cookie 協定一樣,它會在瀏覽器中與所有 JavaScript 一起工作。這兩個伺服器沒有聲明它們具有共同的 origin,因此瀏覽器拒絕發送請求,並且 UI 被破壞。

為了修復它,我們需要支援 CORS 協議,該協議涉及一個「預檢」OPTIONS 請求和一些 headers 來列出呼叫者的允許行為。 Spring 4.2 有一些不錯的 細粒度 CORS 支援,因此我們可以簡單地將註釋新增到我們的控制器映射,例如

ResourceApplication.java
@RequestMapping("/")
@CrossOrigin(origins="*", maxAge=3600)
public Message home() {
  return new Message("Hello World");
}
輕率地使用 origins=* 快速而骯髒,而且它有效,但它並不安全,並且絕不推薦。

保護資源伺服器

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

新增 Spring Security

我們還可以看看如何像在 UI 伺服器中那樣,以篩選器層的形式將安全性新增到資源伺服器。第一步非常簡單:只需將 Spring Security 新增到 Maven POM 中的類別路徑

pom.xml
<dependencies>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
  </dependency>
  ...
</dependencies>

重新啟動資源伺服器,嘿 presto!它是安全的

$ curl -v localhost:9000
< HTTP/1.1 302 Found
< Location: https://127.0.0.1:9000/login
...

我們正在被重新導向到一個(白標)登入頁面,因為 curl 沒有發送與我們的 Angular 客戶端相同的 headers。修改命令以發送更相似的 headers

$ curl -v -H "Accept: application/json" \
    -H "X-Requested-With: XMLHttpRequest" localhost:9000
< HTTP/1.1 401 Unauthorized
...

因此,我們需要做的就是教導客戶端在每個請求中發送憑證。

Token 身份驗證

網際網路,以及人們的 Spring 後端專案,充斥著客製化的基於權杖的身份驗證解決方案。Spring Security 提供了一個最基本的 Filter 實作,讓您可以開始建立自己的解決方案(例如,請參閱 AbstractPreAuthenticatedProcessingFilterTokenService)。然而,Spring Security 中沒有規範的實作,其中一個原因可能是因為有更簡單的方法。

回想一下本系列的第二部分,Spring Security 預設使用 HttpSession 來儲存身份驗證資料。但它不會直接與 session 互動:在中間有一個抽象層 (SecurityContextRepository),您可以使用它來更改儲存後端。如果我們可以在資源伺服器中,將該 repository 指向一個由 UI 驗證過的身份驗證儲存,那麼我們就有一種方法可以在兩個伺服器之間共享身份驗證。UI 伺服器已經有這樣一個儲存(HttpSession),所以如果我們可以分配該儲存並將其開放給資源伺服器,我們就已經有了大部分的解決方案。

Spring Session

有了 Spring Session,這個部分的解決方案變得非常簡單。我們只需要一個共享的資料儲存(支援 Redis 和 JDBC),以及伺服器中的幾行設定來建立 Filter

在 UI 應用程式中,我們需要將一些依賴項添加到我們的 POM

pom.xml
<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 並集中儲存 session 資料。

透過這 1 行程式碼,並且在 localhost 上執行一個 Redis 伺服器,您可以執行 UI 應用程式,使用一些有效的用戶憑證登入,並且 session 資料(身份驗證)將儲存在 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 客戶端中取得該金鑰,我們可以將其作為自訂標頭發送到資源伺服器。因此,「首頁」控制器需要更改,以便它將標頭作為對 greeting 資源的 HTTP 請求的一部分發送。例如

home.component.ts
  constructor(private app: AppService, private http: HttpClient) {
    http.get('token').subscribe(data => {
      const token = data['token'];
      http.get('https://127.0.0.1:9000', {headers : new HttpHeaders().set('X-Auth-Token', token)})
        .subscribe(response => this.greeting = response);
    }, () => {});
  }

(一個更優雅的解決方案可能是根據需要抓取權杖,並使用我們的 RequestOptionsService 將標頭添加到對資源伺服器的每個請求中。)

我們沒有直接前往「https://127.0.0.1:9000」,而是將該呼叫包裝在對 UI 伺服器上 "/token" 的新自訂端點的呼叫的成功回呼中。該實作很簡單

UiApplication.java
@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" 的標頭。

資源伺服器中的身份驗證

資源伺服器只需要進行一個小的更改,才能夠接受自訂標頭。CORS 設定必須指定該標頭為允許來自遠端客戶端的標頭,例如

ResourceApplication.java
@RequestMapping("/")
@CrossOrigin(origins = "*", maxAge = 3600,
    allowedHeaders={"x-auth-token", "x-requested-with", "x-xsrf-token"})
public Message home() {
  return new Message("Hello World");
}

來自瀏覽器的 pre-flight 檢查現在將由 Spring MVC 處理,但我們需要告訴 Spring Security 允許它通過

ResourceApplication.java
public class ResourceApplication extends WebSecurityConfigurerAdapter {

  @Override
  protected void configure(HttpSecurity http) throws Exception {
    http.cors().and().authorizeRequests()
      .anyRequest().authenticated();
  }

  ...
沒有必要 permitAll() 存取所有資源,並且可能會有一個處理器無意中發送敏感資料,因為它不知道請求是 pre-flight。cors() 設定公用程式通過在篩選器層處理所有 pre-flight 請求來減輕此問題。

剩下的就是拾取資源伺服器中的自訂權杖,並使用它來驗證我們的用戶。事實證明,這非常簡單,因為我們需要做的就是告訴 Spring Security session repository 在哪裡,以及從哪裡在傳入請求中尋找權杖(session ID)。首先,我們需要添加 Spring Session 和 Redis 依賴項,然後我們可以設定 Filter

ResourceApplication.java
@SpringBootApplication
@RestController
class ResourceApplication {

  ...

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

}

建立的此 Filter 是 UI 伺服器中 Filter 的鏡像,因此它將 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)中明確指定。

application.yml
security:
  sessions: NEVER

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

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

為什麼不能全部使用 Cookie?

我們必須使用自訂標頭並在客戶端中編寫程式碼來填充標頭,這並不是很複雜,但似乎與第二部分中關於盡可能使用 cookie 和 session 的建議相矛盾。那裡的論點是不這樣做會引入額外的非必要複雜性,並且可以肯定的是,我們現在擁有的實作是我們迄今為止看到的最複雜的:解決方案的技術部分遠遠超過業務邏輯(這無可否認是很小的)。這絕對是一個公平的批評(我們計劃在本系列的下一節中解決),但讓我們簡要地看一下為什麼僅使用 cookie 和 session 來處理所有事情並不像想像的那麼簡單。

至少我們仍然在使用 session,這是合理的,因為 Spring Security 和 Servlet 容器知道如何在無需我們任何努力的情況下執行此操作。但是,我們是否無法繼續使用 cookie 來傳輸身份驗證權杖?這本來是不錯的,但有一個原因它不起作用,那就是瀏覽器不會讓我們這樣做。您可以直接從 JavaScript 客戶端在瀏覽器的 cookie 儲存中四處尋找,但是有一些限制,並且是有充分理由的。特別是,您無法存取伺服器作為 "HttpOnly" 發送的 cookie(您會發現預設情況下 session cookie 就是這種情況)。您也無法在傳出的請求中設定 cookie,因此我們無法設定 "SESSION" cookie(這是 Spring Session 預設 cookie 名稱),我們必須使用自訂的 "X-Session" 標頭。這兩個限制都是為了保護您自己,因此惡意腳本無法在未經適當授權的情況下存取您的資源。

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

結論

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

我們在這裡使用 Spring Session 在兩個邏輯上不相同的應用程式的伺服器之間共享 session。這是一個很棒的技巧,並且無法使用「常規」JEE 分散式 session 來實現。

API Gateway

在本節中,我們將繼續討論如何在「單頁應用程式」中使用 Spring SecurityAngular。在這裡,我們展示了如何使用 Spring Cloud 建立 API Gateway 來控制後端資源的身份驗證和存取。這是本系列中的第四節,您可以閱讀第一節來了解應用程式的基本建構區塊或從頭開始建立它,或者您可以直接前往 Github 上的原始碼。在上一節中,我們建立了一個簡單的分散式應用程式,該應用程式使用 Spring Session 來驗證後端資源。在這一節中,我們將 UI 伺服器變成後端資源伺服器的反向代理,解決了上一個實作中的問題(自訂權杖身份驗證引入的技術複雜性),並為我們提供了許多用於控制瀏覽器客戶端存取的新選項。

提醒:如果您正在使用範例應用程式來完成本節,請務必清除瀏覽器快取中的 Cookie 和 HTTP Basic 憑證。在 Chrome 中,針對單個伺服器執行此操作的最佳方法是開啟一個新的無痕視窗。

建立 API Gateway

API Gateway 是前端客戶端的單一入口點(和控制點),前端客戶端可以是基於瀏覽器的(如本節中的範例)或行動裝置。客戶端只需要知道一個伺服器的 URL,並且可以隨意重構後端而無需更改,這是一個顯著的優勢。在集中化和控制方面還有其他優勢:速率限制、身份驗證、稽核和記錄。並且使用 Spring Cloud 實現簡單的反向代理非常簡單。

如果您一直在關注程式碼,您就會知道在上一節末尾的應用程式實作有點複雜,因此這不是一個很好的迭代起點。但是,有一個中間點我們可以更輕鬆地開始,其中後端資源尚未受到 Spring Security 的保護。此程式碼的原始碼是一個單獨的專案 在 Github 上,因此我們將從那裡開始。它有一個 UI 伺服器和一個資源伺服器,它們正在相互通信。資源伺服器還沒有 Spring Security,因此我們可以先讓系統正常工作,然後再添加該層。

一行宣告式反向代理

要將其變成 API Gateway,UI 伺服器需要進行一個小的調整。我們需要在 Spring 配置中的某個地方添加一個 @EnableZuulProxy 註釋,例如,在主 (唯一) 應用程式類別

UiApplication.java
@SpringBootApplication
@RestController
@EnableZuulProxy
public class UiApplication {
  ...
}

在外部設定檔中,我們需要將 UI 伺服器中的本地資源映射到 外部設定 ("application.yml") 中的遠端資源

application.yml
security:
  ...
zuul:
  routes:
    resource:
      path: /resource/**
      url: https://127.0.0.1:9000

這表示「將此伺服器中模式為 /resource/** 的路徑映射到 localhost:9000 上遠端伺服器中的相同路徑」。簡單但有效(好的,包括 YAML 在內總共有 6 行,但您並非總是需要它)!

我們只需要在類別路徑 (classpath) 上放置正確的東西,就能讓一切順利運作。為此,我們在 Maven POM 檔中新增了幾行設定。

pom.xml
<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 (starter POM),就像 Spring Boot 的啟動器一樣,但它管理 Zuul 代理所需的依賴項。我們也使用 <dependencyManagement>,因為我們希望能夠依賴所有傳遞依賴項的正確版本。

在客戶端使用代理

完成這些變更後,我們的應用程式仍然可以運作,但在我們修改客戶端之前,我們還沒有真正使用新的代理。幸運的是,這很簡單。我們只需要還原在上一節中從 "single" 範例變更為 "vanilla" 範例時所做的變更即可。

home.component.ts
constructor(private app: AppService, private http: HttpClient) {
  http.get('resource').subscribe(data => this.greeting = data);
}

現在當我們啟動伺服器時,一切正常運作,請求會透過 UI (API 閘道) 代理到資源伺服器。

進一步簡化

更好的是:我們不再需要在資源伺服器中使用 CORS 篩選器。我們很快地把這個篩選器湊合在一起,而且我們必須手動處理技術上的問題 (特別是關於安全性),這應該是一個警訊。幸運的是,現在它變得多餘了,所以我們可以把它丟掉,然後回去安心睡覺!

保護資源伺服器

您可能還記得,在我們開始的中間狀態中,資源伺服器沒有任何安全保護。

題外話:如果您的網路架構反映了應用程式架構 (您可以讓資源伺服器在物理上無法被 UI 伺服器以外的任何人訪問),那麼缺乏軟體安全保護甚至可能不是問題。作為一個簡單的演示,我們可以讓資源伺服器只能在 localhost 上訪問。只需將此添加到資源伺服器的 application.properties

application.properties
server.address: 127.0.0.1

哇,這太容易了!使用僅在您的資料中心可見的網路位址來執行此操作,您將擁有一個適用於所有資源伺服器和所有使用者桌面的安全解決方案。

假設我們決定確實需要在軟體層級上進行安全保護 (這很可能由於許多原因)。這不會是一個問題,因為我們只需要將 Spring Security 新增為依賴項 (在 資源伺服器 POM 中)

pom.xml
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-security</artifactId>
</dependency>

這足以讓我們獲得一個安全的資源伺服器,但它還無法讓我們的應用程式正常運作,原因與第三部分相同:兩個伺服器之間沒有共享的身份驗證狀態。

共享身份驗證狀態

我們可以使用相同的機制來共享身份驗證 (和 CSRF) 狀態,就像我們在上一節中所做的那樣,即 Spring Session。我們像之前一樣將依賴項添加到兩個伺服器

pom.xml
<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 伺服器,明確宣告我們希望轉發所有標頭 (即,沒有任何標頭是 "敏感的")

application.yml
zuul:
  routes:
    resource:
      sensitive-headers:

然後我們可以轉到資源伺服器。需要進行兩個小變更:一個是明確禁用資源伺服器中的 HTTP Basic (以防止瀏覽器彈出身份驗證對話框)

ResourceApplication.java
@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 中使用非無狀態的會話建立策略

application.properties
security.sessions: NEVER

只要 redis 仍在後台執行 (如果您想啟動它,可以使用 docker-compose.yml),系統就可以正常運作。在 https://127.0.0.1:8080 載入 UI 的首頁,登入後您將看到從後端呈現的訊息。

它是如何運作的? (How Does it Work?)

現在幕後發生了什麼?首先,我們可以查看 UI 伺服器 (和 API 閘道) 中的 HTTP 請求

動詞 (Verb) 路徑 (Path) 狀態 (Status) 響應 (Response)

GET

/

200

index.html

GET

/*.js

200

Angular 的資源

GET

/user

401

未授權 (已忽略)

GET

/resource

401

對資源的未經身份驗證的訪問

GET

/user

200

JSON 驗證的使用者

GET

/resource

200

(代理的) JSON 歡迎訊息

這與第二部分結尾處的序列相同,除了 cookie 名稱略有不同 ("SESSION" 而不是 "JSESSIONID"),因為我們正在使用 Spring Session。但架構是不同的,最後一個到 "/resource" 的請求很特別,因為它被代理到資源伺服器。

我們可以透過查看 UI 伺服器中的 "/trace" 端點 (來自 Spring Boot Actuator,我們使用 Spring Cloud 依賴項新增了它) 來查看反向代理的作用。在新瀏覽器中前往 https://127.0.0.1:8080/trace (如果您還沒有,請為您的瀏覽器取得一個 JSON 插件,使其更易於閱讀)。您需要使用 HTTP Basic (瀏覽器彈出視窗) 進行身份驗證,但與您的登入表單相同的憑證有效。在開始時或附近,您應該會看到一對類似這樣的請求

嘗試使用不同的瀏覽器,這樣就沒有身份驗證交叉的機會 (例如,如果使用 Chrome 測試 UI,請使用 Firefox) - 這不會阻止應用程式運作,但如果追蹤包含來自同一瀏覽器的混合身份驗證,則會使追蹤更難以閱讀。
/trace
{
  "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 SecurityAngular。在這裡,我們展示如何使用 Spring Security OAuthSpring 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 中,我們新增

pom.xml
<dependency>
  <groupId>org.springframework.security.oauth</groupId>
  <artifactId>spring-security-oauth2</artifactId>
</dependency>

授權伺服器很容易實作。最小版本如下所示

AuthserverApplication.java
@SpringBootApplication
@EnableAuthorizationServer
public class AuthserverApplication extends WebMvcConfigurerAdapter {

  public static void main(String[] args) {
    SpringApplication.run(AuthserverApplication.class, args);
  }

}

我們只需要再做一件事 (在新增 @EnableAuthorizationServer 之後)

application.properties
---
...
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 端口上執行,並為測試設定一個可預測的密碼

application.properties
server.port=9999
security.user.password=password
server.contextPath=/uaa
...

我們也設定了上下文路徑,以便它不使用預設路徑 ("/"),否則您可以從 localhost 上的其他伺服器收到 cookie,這些 cookie 會被傳送到錯誤的伺服器。因此,啟動伺服器,我們可以確保它正在運作

$ mvn spring-boot:run

或在您的 IDE 中啟動 main() 方法。

測試授權伺服器

我們的伺服器使用 Spring Boot 的預設安全性設定,所以和第一部分中的伺服器一樣,它會受到 HTTP Basic 驗證保護。 若要啟動授權碼權杖授予,請訪問授權端點,例如 https://127.0.0.1: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)。

可以使用 token endpoint 上的 "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 依賴項,因此請替換此項

pom.xml
<dependency>
  <groupId>org.springframework.session</groupId>
  <artifactId>spring-session</artifactId>
</dependency>
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-redis</artifactId>
</dependency>

替換為此項

pom.xml
<dependency>
  <groupId>org.springframework.security.oauth</groupId>
  <artifactId>spring-security-oauth2</artifactId>
</dependency>

然後從 main 應用程式類別中移除 session Filter,並將其替換為方便的 @EnableResourceServer 註釋 (來自 Spring Security OAuth2)

ResourceApplication.java
@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" 中),以允許資源伺服器解碼收到的權杖並驗證使用者身份

application.properties
...
security.oauth2.resource.userInfoUri: https://127.0.0.1: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" 標頭,指示它需要一個 bearer 權杖。

userInfoUri 絕不是將資源伺服器與解碼權杖的方式連接起來的唯一方法。 實際上,它有點像是最低公分母(並且不是規範的一部分),但 OAuth2 提供者(例如 Facebook、Cloud Foundry、Github)通常會提供它,並且還有其他選擇可用。 例如,您可以將使用者驗證編碼在權杖本身中(例如,使用 JWT),或使用共用的後端儲存區。 CloudFoundry 中還有一個 /token_info 端點,它提供比使用者資訊端點更詳細的資訊,但需要更徹底的驗證。 不同的選項(自然地)提供不同的好處和權衡,但對這些的完整討論超出了本節的範圍。

實作使用者端點

在授權伺服器上,我們可以輕鬆地添加該端點

AuthserverApplication.java
@SpringBootApplication
@RestController
@EnableAuthorizationServer
@EnableResourceServer
public class AuthserverApplication {

  @RequestMapping("/user")
  public Principal user(Principal user) {
    return user;
  }

  ...

}

我們添加了一個與第二部分中的 UI 伺服器相同的 @RequestMapping,以及來自 Spring OAuth 的 @EnableResourceServer 註釋,預設情況下,它會保護授權伺服器中的所有內容,除了 "/oauth/*" 端點。

有了該端點,我們可以測試它和 greeting 資源,因為它們現在都接受由授權伺服器建立的 bearer 權杖

$ 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(這會為透過 Proxy 轉發權杖設定一些自動配置)。

完成此操作後,我們也可以移除 session filter 和 "/user" 端點,並設定應用程式以重新導向到授權伺服器(使用 @EnableOAuth2Sso 註釋)

UiApplication.java
@SpringBootApplication
@EnableZuulProxy
@EnableOAuth2Sso
public class UiApplication {

  public static void main(String[] args) {
    SpringApplication.run(UiApplication.class, args);
  }

...

}

回想一下第四部分,UI 伺服器藉由 @EnableZuulProxy 的優點,充當 API Gateway,我們可以在 YAML 中宣告路由對應。 因此,"/user" 端點可以被 Proxy 到授權伺服器

application.yml
zuul:
  routes:
    resource:
      path: /resource/**
      url: https://127.0.0.1:9000
    user:
      path: /user/**
      url: https://127.0.0.1:9999/uaa/user

最後,我們需要將應用程式變更為 WebSecurityConfigurerAdapter,因為現在它將用於修改 @EnableOAuth2Sso 設定的 SSO 過濾器鏈中的預設值

SecurityConfiguration.java
@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 中添加此項

application.yml
security:
  ...
  oauth2:
    client:
      accessTokenUri: https://127.0.0.1:9999/uaa/oauth/token
      userAuthorizationUri: https://127.0.0.1:9999/uaa/oauth/authorize
      clientId: acme
      clientSecret: acmesecret
    resource:
      userInfoUri: https://127.0.0.1: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 應用程式進行一些調整,以觸發重新導向到授權伺服器。 在這個簡單的 Demo 中,我們可以將 Angular 應用程式簡化為其基本要素,以便您可以更清楚地了解正在發生的事情。 因此,我們暫時放棄使用表單或路由,然後回到單個 Angular 元件

app.component.ts
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 處理所有事情,獲取使用者詳細資訊,如果成功,則獲取 greeting。 它還提供 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/> 包含在首頁中。

請注意,"Login" 的導覽連結是帶有 href 的常規連結(不是 Angular 路由)。 此連結指向的 "/login" 端點由 Spring Security 處理,如果使用者未通過驗證,它將導致重新導向到授權伺服器。

它是如何運作的? (How Does it Work?)

現在一起執行所有伺服器,並在瀏覽器中訪問 UI,網址為 https://127.0.0.1:8080。 點擊 "login" 連結,您將被重新導向到授權伺服器以進行身份驗證(HTTP Basic 彈出視窗)並批准權杖授予(預設 HTML),然後重新導向到 UI 中的首頁,並使用與我們驗證 UI 時相同的權杖從 OAuth2 資源伺服器獲取 greeting。

如果您使用一些開發人員工具,可以在瀏覽器中看到瀏覽器和後端之間的互動(通常 F12 會打開此工具,預設在 Chrome 中有效,可能需要在 Firefox 中安裝插件)。 這是一個摘要

動詞 (Verb) 路徑 (Path) 狀態 (Status) 響應 (Response)

GET

/

200

index.html

GET

/*.js

200

來自 angular 的資產

GET

/user

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

/user

200

(已 Proxy) JSON 已驗證的使用者

GET

/app.html

200

首頁的 HTML partial

GET

/resource

200

(代理的) JSON 歡迎訊息

帶有 (uaa) 前綴的請求是對授權伺服器的請求。 標記為 "已忽略" 的回應是 Angular 在 XHR 呼叫中收到的回應,由於我們沒有處理該資料,因此它們被丟棄。 我們確實會在 "/user" 資源的情況下尋找已驗證的使用者,但由於它在第一次呼叫中不存在,因此該回應被丟棄。

在 UI 的 "/trace" 端點中(向下滾動到底部),您將看到對 "/user" 和 "/resource" 的 Proxy 後端請求,帶有 remote:true 和 bearer 權杖,而不是 cookie(就像在第四部分中一樣)被用於身份驗證。 Spring Cloud Security 已經為我們處理了這個問題:透過識別我們有 @EnableOAuth2Sso@EnableZuulProxy,它已經確定(預設情況下)我們想要將權杖轉發到 Proxy 後端。

與之前的章節一樣,嘗試為 "/trace" 使用不同的瀏覽器,這樣就不會有身份驗證交叉的可能性(例如,如果您使用 Chrome 進行 UI 測試,請使用 Firefox)。

登出體驗

如果您點擊 "logout" 連結,您將看到首頁發生變化(不再顯示 greeting),因此使用者不再使用 UI 伺服器進行身份驗證。 但是,如果再次點擊 "login",您實際上不需要再次通過授權伺服器中的身份驗證和批准週期(因為您尚未從該伺服器登出)。 對於這是否是理想的使用者體驗,意見會有分歧,而且這是一個出了名的棘手問題(單一登出:Science Direct 文章Shibboleth 文件)。 理想的使用者體驗可能在技術上不可行,而且您有時也必須懷疑使用者是否真的想要他們所說的。 "我希望 'logout' 登出我" 聽起來很簡單,但顯而易見的回應是,"從什麼登出? 您是否希望從所有由這個 SSO 伺服器控制的系統登出,還是僅從您點擊 'logout' 連結的系統登出?" 如果您有興趣,那麼本教學課程的稍後章節會更深入地討論它。

結論

這幾乎是我們對 Spring Security 和 Angular 技術堆疊進行的簡略導覽的尾聲。我們現在有一個不錯的架構,在三個獨立的元件中擁有清晰的職責:UI/API 閘道、資源伺服器和授權伺服器/權杖授予者。所有層中的非業務程式碼量現在已降至最低,並且很容易看出在哪裡可以擴展和改進實作,以添加更多業務邏輯。接下來的步驟將是整理我們授權伺服器中的 UI,並可能添加更多測試,包括對 JavaScript 客戶端的測試。另一個有趣的任務是提取所有樣板程式碼並將其放入一個函式庫(例如 "spring-security-angular")中,其中包含 Spring Security 和 Spring Session 自動配置,以及一些用於 Angular 導航控制器的 webjars 資源。閱讀完本系列文章後,任何希望學習 Angular 或 Spring Security 內部運作原理的人可能會感到失望,但如果您想了解它們如何協同工作,以及少量的配置如何產生顯著的效果,那麼希望您能有良好的體驗。Spring Cloud 是新的,這些範例在編寫時需要使用快照版本,但目前已有候選發布版本,並且即將推出正式版本,因此請查看並通過 Githubgitter.im 發送一些回饋意見。

本系列中的下一節是有關存取決策(超越身份驗證),並採用同一個代理後面的多個 UI 應用程式。

附錄:用於授權伺服器的 Bootstrap UI 和 JWT 權杖

您可以在 Github 上的原始碼中找到此應用程式的另一個版本,其中有一個漂亮的登入頁面和使用者授權頁面,其實作方式與我們在 Part II 中實作登入頁面的方式類似。它還使用 JWT 來編碼權杖,因此資源伺服器可以從權杖本身提取足夠的資訊來進行簡單的身份驗證,而不是使用 "/user" 端點。瀏覽器客戶端仍然使用它,通過 UI 伺服器進行代理,以便它可以確定使用者是否已通過身份驗證(與在實際應用程式中對資源伺服器的可能調用次數相比,它不需要經常這樣做)。

多個 UI 應用程式和閘道

在本節中,我們繼續 討論 如何在 "單頁應用程式" 中將 Spring SecurityAngular 結合使用。在這裡,我們展示了如何將 Spring SessionSpring Cloud 結合使用,以結合我們在 Part II 和 IV 中構建的系統的功能,實際上最終構建了 3 個具有完全不同職責的單頁應用程式。目的是構建一個閘道(如 Part IV 中一樣),該閘道不僅用於 API 資源,還用於從後端伺服器載入 UI。我們通過使用閘道將身份驗證傳遞到後端,從而簡化了 Part II 的權杖處理部分。然後,我們擴展該系統以展示如何在後端進行本地的、細粒度的存取決策,同時仍然在閘道控制身份和身份驗證。這是一個非常強大的模型,可用於構建分散式系統,並且具有許多優點,我們可以在介紹我們在程式碼中構建的功能時進行探索。

提醒:如果您正在使用範例應用程式完成本節,請務必清除瀏覽器快取中的 Cookie 和 HTTP Basic 憑證。在 Chrome 中,最好的方法是打開一個新的無痕視窗。

目標架構

這是我們將要構建的基本系統的圖片

Components of the System

與本系列中的其他範例應用程式一樣,它具有 UI(HTML 和 JavaScript)和資源伺服器。與 Section IV 中的範例一樣,它有一個閘道,但這裡它是獨立的,而不是 UI 的一部分。UI 有效地成為後端的一部分,使我們有更多選擇來重新配置和重新實作功能,並且還帶來了其他好處,我們將會看到。

瀏覽器訪問閘道以獲取所有內容,並且不必了解後端的架構(從根本上講,它不知道有後端)。瀏覽器在此閘道中所做的事情之一是身份驗證,例如,它發送一個使用者名稱和密碼,如 Section II 中一樣,並收到一個 Cookie 作為回報。在後續請求中,它會自動呈現 Cookie,並且閘道會將其傳遞到後端。無需在客戶端上編寫任何程式碼即可啟用 Cookie 傳遞。後端使用 Cookie 進行身份驗證,並且由於所有元件共享一個 Session,因此它們共享有關使用者的相同資訊。將其與 Section V 進行比較,在 Section V 中,Cookie 必須在閘道中轉換為存取權杖,然後所有後端元件必須獨立解碼該存取權杖。

Section IV 中一樣,閘道簡化了客戶端和伺服器之間的互動,並提供了一個小的、定義明確的介面來處理安全性。例如,我們無需擔心 跨來源資源共享,這是一種可喜的解脫,因為它很容易出錯。

我們將要構建的完整專案的原始碼位於 Github 上的此處,因此您可以直接複製該專案並從那裡開始工作。此系統的最終狀態中還有一個額外的元件 ("double-admin"),因此現在忽略它。

構建後端

在此架構中,後端與我們在 "spring-session" 範例中構建的非常相似,Section III 中構建的,唯一的例外是它實際上不需要登入頁面。達到我們想要目標的最簡單方法可能是複製 Section III 中的 "resource" 伺服器,並從 "basic" 範例中獲取 UI,Section I 中。要從 "basic" UI 轉到我們想要的 UI,我們只需要添加幾個依賴項(就像我們第一次在 Section III 中使用 Spring Session 時一樣)

pom.xml
<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 中)

application.properties
server.port: 8081
security.sessions: NEVER

如果那是 整個 application.properties 內容,則該應用程式將是安全的,並且可以使用名為 "user" 的使用者和一個隨機密碼進行存取,但該密碼會在啟動時在主控台上列印出來(記錄層級為 INFO)。"security.sessions" 設定意味著 Spring Security 將接受 Cookie 作為身份驗證權杖,但除非它們已經存在,否則不會建立它們。

資源伺服器

可以很容易地從我們現有的範例之一生成資源伺服器。它與 Section III 中的 "spring-session" 資源伺服器相同:只是一個 "/resource" 端點 Spring Session 來獲取分散式 Session 資料。我們希望此伺服器具有一個非預設的監聽埠,並且我們希望能夠在 Session 中查找身份驗證,因此我們需要這個(在 application.properties 中)

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 註釋。正如我們在 Section I 中看到的那樣,有幾種方法可以做到這一點,其中一種是使用 Spring Initializr 來生成一個骨架專案。更簡單的方法是使用 Spring Cloud Initializr,它與上述相同,但適用於 Spring Cloud 應用程式。使用與 Section I 中相同的命令列操作序列

$ 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 重新命名)

application.yml
zuul:
  sensitive-headers:
  routes:
    ui:
      url: https://127.0.0.1:8081
   resource:
      url: https://127.0.0.1:9000
security:
  user:
    password:
      password
  sessions: ALWAYS

代理中有 2 條路由,它們都使用 sensitive-headers 屬性將 Cookie 向下傳遞,一條用於 UI,另一條用於資源伺服器,並且我們已設定預設密碼和 Session 持久性策略(告訴 Spring Security 始終在身份驗證時建立 Session)。最後一點很重要,因為我們希望在閘道中管理身份驗證,因此也要管理 Session。

啟動並執行

我們現在有三個元件,運行在 3 個端口上。如果你的瀏覽器指向 https://127.0.0.1:8080/ui/,應該會出現 HTTP Basic 驗證提示,你可以使用 "user/password" 進行驗證 (你在 Gateway 中的憑證),驗證成功後,你應該會在 UI 中看到一個問候語,這是透過 Proxy 對 Resource 伺服器進行後端呼叫取得的。

如果您使用一些開發人員工具,可以在瀏覽器中看到瀏覽器和後端之間的互動(通常 F12 會打開此工具,預設在 Chrome 中有效,可能需要在 Firefox 中安裝插件)。 這是一個摘要

動詞 (Verb) 路徑 (Path) 狀態 (Status) 響應 (Response)

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 錯誤,因為瀏覽器將首頁載入視為單次互動。所有請求都會被代理 (Gateway 中除了管理用的 Actuator 端點之外,目前沒有其他內容)。

太棒了,它運作了!你現在有兩個後端伺服器,一個是 UI,每個都有獨立的功能,並且能夠獨立進行測試,它們透過一個你控制的安全 Gateway 連接在一起,並且你已經為其配置了身份驗證。如果瀏覽器無法存取後端,這也沒關係 (事實上,這可能是一個優點,因為它可以讓你對實體安全性擁有更多的控制權)。

新增登入表單

就像 第一部分 中的 "basic" 範例一樣,我們現在可以向 Gateway 新增一個登入表單,例如,透過複製 第二部分 中的程式碼。當我們這樣做時,我們也可以在 Gateway 中新增一些基本的導覽元素,這樣使用者就不必知道 Proxy 中 UI 後端的路徑。所以讓我們首先將 "single" UI 中的靜態資源複製到 Gateway 中,刪除訊息渲染,並將登入表單插入到我們的首頁中 (在 <app/> 某處)

app.html
<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>

我們將擁有一個漂亮的導覽按鈕,而不是訊息渲染

index.html
<div class="container" [hidden]="!authenticated">
	<a class="btn btn-primary" href="/ui/">Go To User Interface</a>
</div>

如果你正在查看 Github 上的範例,它還會有一個帶有 "Logout" 按鈕的最小導覽列。這是登入表單的截圖

Login Page

為了支援登入表單,我們需要一些 TypeScript,其中包含一個實作我們在 <form/> 中宣告的 login() 函數的元件,並且我們需要設定 authenticated 標誌,以便首頁會根據使用者是否已驗證而呈現不同的內容。例如

app.component.ts
include::src/app/app.component.ts

其中 login() 函數的實作與 第二部分 中的類似。

我們可以透過使用 self 來儲存 authenticated 標誌,因為在這個簡單的應用程式中只有一個元件。

如果我們運行這個增強的 Gateway,就不需要記住 UI 的 URL,我們可以簡單地載入首頁並點擊連結。這是已驗證使用者的首頁

Home Page

後端中的細緻存取決策

到目前為止,我們的應用程式在功能上與 第三部分第四部分 中的應用程式非常相似,但有一個額外的專用 Gateway。額外層的優勢可能還不明顯,但我們可以透過擴展系統來強調它。假設我們想要使用該 Gateway 來公開另一個後端 UI,供使用者 "管理" 主要 UI 中的內容,並且我們想要將對此功能的存取限制為具有特殊角色的使用者。因此,我們將在 Proxy 後面新增一個 "Admin" 應用程式,並且系統將如下所示

Components of the System

Gateway 中有一個新的元件 (Admin) 和一個新的路由,在 application.yml

application.yml
zuul:
  sensitive-headers:
  routes:
    ui:
      url: https://127.0.0.1:8081
    admin:
      url: https://127.0.0.1:8082
    resource:
      url: https://127.0.0.1:9000

現有的 UI 可供具有 "USER" 角色的使用者使用,這在上面的 Gateway 框中的方塊圖上有所指示 (綠色字體),並且需要 "ADMIN" 角色才能進入 Admin 應用程式。 "ADMIN" 角色的存取決策可以在 Gateway 中應用,在這種情況下,它會出現在 WebSecurityConfigurerAdapter 中,或者可以在 Admin 應用程式本身中應用 (我們將在下面看到如何執行此操作)。

因此,首先,建立一個新的 Spring Boot 應用程式,或複製 UI 並編輯它。除了名稱之外,你不需要更改 UI 應用程式中的任何內容。完成的應用程式位於 Github 此處

假設在 Admin 應用程式中,我們想要區分 "READER" 和 "WRITER" 角色,以便我們允許 (假設) 稽核員查看主要管理使用者所做的更改。這是一個細緻的存取決策,規則僅在後端應用程式中已知,並且應該僅在後端應用程式中已知。在 Gateway 中,我們只需要確保我們的使用者帳戶具有所需的角色,並且此資訊可用,但 Gateway 不需要知道如何解釋它。在 Gateway 中,我們建立使用者帳戶以保持範例應用程式的自給自足

SecurityConfiguration.class
@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" 存取的 "audit" 使用者,但沒有 "WRITER"。

在生產系統中,使用者帳戶資料將在後端資料庫 (最可能是目錄服務) 中管理,而不是硬式編碼在 Spring 配置中。在網路上很容易找到連接到此類資料庫的範例應用程式,例如在 Spring Security Samples 中。

存取決策在 Admin 應用程式中進行。對於 "ADMIN" 角色 (此後端全域需要),我們在 Spring Security 中執行此操作

SecurityConfiguration.java
@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 實作的,因此我們需要在其中做出存取決策。一種方法是擁有一個首頁,其中透過路由器嵌入了計算出的視圖

app.component.html
<div class="container">
	<h1>Admin</h1>
	<router-outlet></router-outlet>
</div>

當元件載入時,會計算路由

app.component.ts
@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']);
      }
    }
  }
...
}

應用程式做的第一件事是檢查使用者是否已驗證,並透過查看使用者資料來計算路由。路由在主模組中宣告

app.module.ts
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 的範例

read.component.ts
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);
  }

}
read.component.html
<h1>Greeting</h1>
<div>
	<p>The ID is {{greeting.id}}</p>
	<p>The content is {{greeting.content}}</p>
</div>

WriteComponent 類似,但有一個表單可以更改後端中的訊息

write.component.ts
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;
    });
  }

}
write.component.html
<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() 函數中,我們看到了這個

app.service.ts
        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 端點,例如在我們的主應用程式類別中

AdminApplication.java
@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 的事情)。因此,"ROLE_" 字首在 JavaScript 中是必需的,但在 Spring Security 配置中則不是必需的,因為從方法名稱可以清楚地看出 "roles" 是操作的重點。

Gateway 中支援 Admin UI 的變更

我們也將在 Gateway 中使用角色來做出存取決策 (因此我們可以有條件地顯示指向 admin UI 的連結),因此我們也應該將 "roles" 新增到 Gateway 中的 "/user" 端點。完成後,我們可以新增一些 JavaScript 來設定一個標誌,以指示目前的使用者是 "ADMIN"。在 authenticated() 函數中

app.component.ts
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

app.component.ts
this.logout = function() {
    http.post('logout', {}).subscribe(function() {
        self.authenticated = false;
        self.admin = false;
    });
}

然後在 HTML 中,我們可以有條件地顯示一個新連結

app.component.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://127.0.0.1:8080 查看結果。一切都應該運作正常,並且 UI 應該會根據目前已驗證的使用者而改變。

我們為什麼在這裡?

現在我們有一個不錯的小系統,具有 2 個獨立的使用者介面和一個後端 Resource 伺服器,所有這些都受到 Gateway 中相同身份驗證的保護。 Gateway 充當微型代理的事實使得後端安全問題的實作極其簡單,並且它們可以自由地專注於自己的業務問題。 Spring Session 的使用 (再次) 避免了大量的麻煩和潛在的錯誤。

一個強大的功能是,後端可以獨立擁有任何種類的身份驗證 (例如,如果你知道其物理位址和一組本機憑證,你可以直接進入 UI)。 Gateway 強加了一組完全不相關的約束,只要它可以驗證使用者身份並為他們分配滿足後端存取規則的中繼資料即可。這是一種能夠獨立開發和測試後端元件的絕佳設計。如果我們願意,我們可以回到外部 OAuth2 伺服器 (如 第五部分 中,甚至是完全不同的東西) 進行 Gateway 的身份驗證,而無需觸及後端。

此架構的一個額外功能 (單個 Gateway 控制身份驗證,並且所有元件共享會話 Token) 是 "單一登出",我們在 第五部分 中認定該功能難以實作,但現在免費提供。更精確地說,單一登出使用者體驗的一種特定方法在我們完成的系統中自動可用:如果使用者從任何 UI (Gateway、UI 後端或 Admin 後端) 登出,他將從所有其他 UI 登出,前提是每個單獨的 UI 都以相同的方式實作了 "登出" 功能 (使會話無效)。

感謝:我想再次感謝所有幫助我開發此系列的人,特別是 Rob WinchThorsten Späth,他們仔細審閱了各個章節和原始程式碼。自從 第一部分 發布以來,它沒有太大變化,但所有其他部分都根據讀者的評論和見解而發展,因此也感謝所有閱讀過這些章節並費心參與討論的人。

測試 Angular 應用程式

在本節中,我們繼續 討論 如何在 "單頁應用程式" 中將 Spring SecurityAngular 結合使用。在這裡,我們展示如何使用 Angular 測試框架為用戶端程式碼編寫和運行單元測試。你可以趕上應用程式的基本建構區塊或從頭開始建構它,方法是閱讀 第一部分,或者你可以直接前往 Github 中的原始程式碼 (與第一部分相同的原始程式碼,但現在新增了測試)。本節實際上很少有使用 Spring 或 Spring Security 的程式碼,但它涵蓋了用戶端測試,這可能不容易在常用的 Angular 社群資源中找到,並且我們認為大多數 Spring 使用者都會感到舒適。

提醒:如果您正在使用範例應用程式來完成本節,請務必清除瀏覽器快取中的 Cookie 和 HTTP Basic 憑證。在 Chrome 中,針對單個伺服器執行此操作的最佳方法是開啟一個新的無痕視窗。

編寫規格

我們在 "basic" 應用程式中的 "app" 元件非常簡單,因此徹底測試它不會花費太多時間。這是程式碼的提醒

app.component.ts
include::basic/src/app/app.component.ts

我們面臨的主要挑戰是在測試中提供 http 物件,以便我們可以斷言它們在元件中的使用方式。實際上,甚至在我們面臨這個挑戰之前,我們需要能夠建立一個元件實例,以便我們可以測試載入時會發生什麼。這是你可以這樣做的方式。

在從 ng new 建立的 Angular 應用程式中,建置時就已經包含了 spec 檔案以及執行它的相關設定。產生的 spec 檔案位於 "src/app" 目錄中,並且像這樣開始:

app.component.ts
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();
  }));
  ...
}

在這個非常基本的測試套件中,我們有以下重要的元素:

  1. 我們使用 describe() 函式來描述要測試的對象(在本例中是 "AppComponent")。

  2. 在這個函式內部,我們提供一個 beforeEach() 回呼函式,用於載入 Angular 元件。

  3. 行為透過呼叫 it() 來表達,我們用文字說明預期的結果,然後提供一個執行斷言的函式。

  4. 測試環境在任何事情發生之前會先初始化。 這對於大多數 Angular 應用程式來說是樣板程式碼。

這裡的測試函式非常簡單,實際上只斷言該元件是否存在,如果失敗,測試就會失敗。

改進單元測試:模擬 HTTP 後端

為了將 spec 檔案改進到生產等級,我們需要實際斷言控制器載入時會發生什麼事。 由於它會呼叫 http.get(),我們需要模擬該呼叫,以避免僅僅為了單元測試而運行整個應用程式。 為了做到這一點,我們使用 Angular 的 HttpClientTestingModule

app.component.spec
Unresolved directive in testing.adoc - include::basic/src/app/app.component.spec[indent=0]

這裡新增的部分是:

  • beforeEach() 中的 TestBed 裡,宣告 HttpClientTestingModule 作為 imports。

  • 在測試函式中,我們在建立元件之前設定後端的預期行為,告訴它預期對 'resource/' 進行呼叫,以及預期的回應內容。

執行 Specs

要執行我們的 "test" 程式碼,我們可以使用在專案建立時建立的便利指令碼 ./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 SecurityAngular。 在這裡,我們展示如何採用 OAuth2 範例並新增不同的登出體驗。 許多實作 OAuth2 單一登入的人發現他們需要解決一個 "如何乾淨地登出" 的難題? 這個難題的原因是因為沒有一個正確的方法可以做到,而且您選擇的解決方案將取決於您正在尋找的使用者體驗,以及您願意承擔的複雜程度。 複雜性的原因是系統中可能有多個瀏覽器會話,它們都連接到不同的後端伺服器,所以當使用者從其中一個登出時,其他會話應該發生什麼事? 這是本教學課程的第九節,您可以閱讀 第一節 來了解應用程式的基本建構塊或從頭開始建置它,或者您可以直接前往 Github 中的原始碼

登出模式

本教學課程中 oauth2 範例的登出使用者體驗是您從 UI 應用程式登出,但不會從 authserver 登出,所以當您重新登入 UI 應用程式時,authserver 不會再次要求您輸入憑證。 當 authserver 是外部的時(例如 Google 和其他外部 authserver 提供者既不想要也不允許您從不受信任的應用程式登出他們的伺服器),這是完全預期的、正常的和理想的,但如果 authserver 實際上與 UI 屬於同一個系統,這就不是最佳的使用者體驗。

廣義地說,從以 OAuth2 客戶端身份驗證的 UI 應用程式登出有三種模式:

  1. 外部 Authserver (EA,原始範例)。 使用者將 authserver 視為第三方(例如使用 Facebook 或 Google 進行身份驗證)。 您不希望在應用程式會話結束時從 authserver 登出。 您希望批准所有授權。 本教學課程中的 oauth2(和 oauth2-vanilla)範例實作了這種模式。

  2. 閘道和內部 Authserver (GIA)。 您只需要登出 2 個應用程式,而且它們屬於同一個系統,使用者可以感知到這一點。 通常您希望自動批准所有授權。

  3. 單一登出 (SL)。 一個 authserver 和多個 UI 應用程式,它們都有自己的身份驗證,當使用者從其中一個登出時,您希望所有應用程式都跟隨登出。 由於網路分割和伺服器故障,很可能在使用天真的實作方式時會失敗 - 您基本上需要全域一致的儲存。

有時,即使您有外部 authserver,您也希望控制身份驗證並新增一個內部存取控制層(例如 authserver 不支援的範圍或角色)。 那麼,使用 EA 進行身份驗證是一個好主意,但要有一個內部 authserver,它可以將您需要的其他詳細資訊新增到 token 中。 來自另一個 OAuth2 教學課程 中的 auth-server 範例向您展示如何以非常簡單的方式做到這一點。 然後,您可以將 GIA 或 SL 模式應用於包含內部 authserver 的系統。

如果您不想要 EA,這裡有一些選項:

  • 從瀏覽器客戶端中的 authserver 以及 UI 應用程式登出。 方法簡單,並且在經過仔細的 CRSF 和 CORS 配置後可以正常運作。 沒有 SL。

  • 一旦 token 可用,立即從 authserver 登出。 在取得 token 的 UI 中很難實作,因為您沒有 authserver 的會話 cookie。 Spring OAuth 中有一個功能請求,它展示了一種有趣的方法:一旦產生 auth code,就使 authserver 中的會話失效。 Github issue 包含一個實作會話失效的 aspect,但更容易將其作為 HandlerInterceptor 執行。 沒有 SL。

  • 透過與 UI 相同的閘道代理 authserver,並希望一個 cookie 足以管理整個系統的狀態。 無法運作,除非有一個共享的會話,這在一定程度上破壞了目的(否則 authserver 沒有會話儲存)。 只有當所有應用程式共享會話時,才可以使用 SL。

  • 閘道中的 Cookie 中繼。 您正在使用閘道作為身份驗證的真相來源,並且 authserver 擁有它需要的所有狀態,因為閘道管理 cookie 而不是瀏覽器。 瀏覽器永遠不會有多個伺服器的 cookie。 沒有 SL。

  • 使用 token 作為全域身份驗證,並在使用者從 UI 應用程式登出時使其失效。 缺點:需要客戶端應用程式使 token 失效,而這並不是它們的設計目的。 可以使用 SL,但通常的約束適用。

  • 在 authserver 中建立和管理一個全域會話 token(除了使用者 token 之外)。 這是 OpenId Connect 採用的方法,它確實提供了一些 SL 的選項,但代價是一些額外的機制。 沒有任何選項可以免受通常的分布式系統限制:如果網路和應用程式節點不穩定,則無法保證在需要時在所有參與者之間共享登出訊號。 所有登出規範仍處於草案階段,以下是一些規範的連結:會話管理前端通道登出後端通道登出

請注意,在 SL 很難或不可能的情況下,最好將所有 UI 放在一個閘道後面。 然後您可以使用更容易的 GIA 來控制從您的整個系統登出。

以下是在教學課程範例中可以實作的最簡單的兩個選項,它們可以很好地應用於 GIA 模式(採用 oauth2 範例並從那裡開始)。

從瀏覽器登出兩個伺服器

只需在瀏覽器客戶端中新增幾行程式碼,即可在 UI 應用程式登出後立即從 authserver 登出。

logout() {
    this.http.post('logout', {}).finally(() => {
        self.authenticated = false;
        this.http.post('https://127.0.0.1:9999/uaa/logout', {}, {withCredentials:true})
            .subscribe(() => {
                console.log('Logged out');
        });
    }).subscribe();
};

在本範例中,我們將 authserver 登出端點 URL 硬編碼到 JavaScript 中,但如果需要,很容易將其外部化。 它必須直接 POST 到 authserver,因為我們希望會話 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 標頭。 authserver 在此之前不需要任何 CSRF 配置,但很容易為登出端點新增一個忽略:

@Override
protected void configure(HttpSecurity http) throws Exception {
  http
    .csrf()
      .ignoringAntMatchers("/logout/**")
    ...
}
放棄 CSRF 保護並非真的明智之舉,但您可能會準備好容忍這種受限制的使用案例。

透過這兩個簡單的變更,一個在 UI 應用程式客戶端中,另一個在 authserver 中,您會發現一旦您從 UI 應用程式登出,當您重新登入時,系統總是會提示您輸入密碼。

另一個有用的變更是將 OAuth2 客戶端設定為自動批准,這樣使用者就不必批准 Token 的授權。 這在內部授權伺服器中很常見,因為使用者不會將其視為一個獨立的系統。在 AuthorizationServerConfigurerAdapter 中,您只需要在初始化客戶端時設定一個標記即可。

@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
  clients.inMemory().withClient("acme")
    ...
  .autoApprove(true);
}

在授權伺服器中使 Session 失效

如果您不想放棄登出端點上的 CSRF 保護,您可以嘗試另一種簡單的方法,即在授予 Token 時(實際上是在生成授權碼時)立即讓授權伺服器中的使用者 Session 失效。 這也很容易實現:從 oauth2 範例開始,只需將一個 HandlerInterceptor 添加到 OAuth2 端點。

@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="。

透過這個簡單的變更,一旦您通過身份驗證,授權伺服器中的 Session 就已經失效,因此無需嘗試從客戶端管理它。 當您從 UI 應用程式登出,然後重新登入時,授權伺服器無法識別您並提示您輸入憑證。 此模式由 原始碼oauth2-logout 範例實作。 這種方法的缺點是您實際上不再擁有真正的單一登入 - 您的系統中的任何其他應用程式都會發現授權伺服器 Session 已失效,並且它們必須再次提示進行身份驗證 - 如果有多個應用程式,這不是一個好的使用者體驗。

結論

在本節中,我們已經了解了如何實作幾種不同的從 OAuth2 客戶端應用程式登出的模式(從本教學課程的第五節中的應用程式開始),並且討論了其他模式的一些選項。 這些選項並不詳盡,但應該讓您對所涉及的權衡取捨有一個很好的了解,以及一些思考最適合您用例的解決方案的工具。 本節中只有幾行 JavaScript,而且這些 JavaScript 並非特定於 Angular(它向 XHR 請求添加了一個標記),因此所有課程和模式都適用於本指南中範例應用程式的狹窄範圍之外。 一個反覆出現的主題是,所有具有多個 UI 應用程式和單一授權伺服器的單一登出 (SL) 方法都往往存在一些缺陷:您能做的最好的事情就是選擇讓您的使用者最不舒服的方法。 如果您有一個內部授權伺服器和一個由多個元件組成的系統,那麼可能只有一種架構可以讓使用者感覺像一個單一系統,那就是所有使用者互動的閘道。

想要撰寫新的指南或貢獻現有的指南嗎? 請查看我們的貢獻指南

所有指南均以 ASLv2 許可證發布用於程式碼,並以姓名標示-禁止改作創用CC授權條款用於寫作。

取得程式碼