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

$ 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 網站

如果你願意,也可以直接從 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 checkout 本教程的程式碼。

執行應用程式

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 應用程式看起來像這樣

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 元素的名稱)並透過 @Component 註解渲染一段 HTML 程式碼。我們還需要編輯 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}} 渲染。

新增動態內容

到目前為止,我們有一個應用程式,其中的問候語是硬編碼的。這對於學習如何將各個部分組合在一起很有用,但我們實際上期望內容來自後端伺服器,所以讓我們建立一個 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 載入動態資源

所以讓我們在瀏覽器中獲取該訊息。修改 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);
  }
}

我們注入了一個由 Angular 透過 http 模組提供的http 服務,並使用它來 GET 我們的資源。Angular 將響應傳遞給我們,我們從中提取 JSON 並將其賦給 greeting。

為了使 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。

它是如何工作的?

如果你使用一些開發者工具(通常按 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==

瀏覽器在每次請求時都發送使用者名稱和密碼(所以在生產環境中務必只使用 HTTPS)。這與 "Angular" 沒有任何關係,因此它適用於你選擇的任何 JavaScript 框架或非框架。

有什麼問題嗎?

從表面上看,我們似乎做得很好,它簡潔明瞭,易於實現,所有資料都透過秘密密碼保護,即使我們更改前端或後端技術也能正常工作。但還存在一些問題。

  • Basic 認證僅限於使用者名稱和密碼認證。

  • 認證 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 的 imports 中用於設定指向 "/"("home" 控制器)和 "/login"("login" 控制器)的連結。

我們還在其中偷偷加入了 FormsModule,因為稍後它將用於將資料繫結到使用者登入時要提交的表單。

UI 元件都是“宣告 (declarations)”,服務粘合層是“提供者 (provider)”。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

  • 元件中暴露了一個 logout 函式作為屬性,我們可以稍後用它向後端傳送一個退出請求。它在 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 標誌很簡單。authenticate() 函式在提供了 HTTP Basic 認證憑據時傳送它們,否則不傳送。它還有一個可選的 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 必須獲取問候語,並提供一個 authenticated() 工具函式,該函式從 AppService 中獲取標誌。

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>

這是一個非常標準的登入表單,帶有兩個用於輸入使用者名稱和密碼的輸入框,以及一個透過 Angular 事件處理器 (submit) 提交表單的按鈕。表單標籤上不需要 action,所以最好完全不設定它。還有一個錯誤訊息,只有在 Angular 模型中包含 error 時才會顯示。表單控制元件使用 Angular Forms 中的 ngModel 在 HTML 和 Angular 控制器之間傳遞資料,在這種情況下,我們使用一個 credentials 物件來儲存使用者名稱和密碼。

認證過程

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

提交登入表單

要提交表單,我們需要定義已經在表單中透過 ng-submit 引用的 login() 函式,以及透過 ng-model 引用的 credentials 物件。讓我們充實一下“登入”元件

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() 函式還會根據認證結果相應地設定一個本地的 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 { }

退出

應用程式的功能性部分幾乎完成了。我們需要做的最後一件事是實現我們在主頁中勾勒的退出功能。如果使用者已認證,我們就顯示一個“退出”連結並將其掛接到 AppComponentlogout() 函式。請記住,它會向 "/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());
  }
}

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

它是如何工作的?

如果你使用一些開發者工具(通常按 F12 開啟,在 Chrome 中預設可用,在 Firefox 中可能需要外掛),可以在瀏覽器中看到瀏覽器和後端之間的互動。這裡有一個摘要

動詞 路徑 狀態 響應

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" 資源時,我們確實查詢已認證的使用者,但由於在第一次呼叫中沒有認證使用者,所以該響應被丟棄了。

更仔細地檢視請求,你會發現它們都帶有 cookie。如果你從一個乾淨的瀏覽器開始(例如 Chrome 的隱身模式),第一次請求不會向伺服器傳送任何 cookie,但伺服器會返回設定 "JSESSIONID"(常規的 HttpSession)和 "X-XSRF-TOKEN"(我們上面設定的 CRSF cookie)的 "Set-Cookie" 頭部。隨後的請求都帶有這些 cookie,它們非常重要:應用程式沒有它們就無法工作,並且它們提供了一些非常基本的安全功能(認證和 CSRF 保護)。使用者認證後(POST 請求後),cookie 的值會發生變化,這是另一個重要的安全功能(防止會話固定攻擊)。

僅依靠瀏覽器自動傳送 cookie 來進行 CSRF 保護是不夠的,因為即使你不在從你的應用程式載入的頁面中,瀏覽器也會自動傳送它(這是一種跨站指令碼攻擊,也稱為 XSS)。頭部不會自動傳送,因此其來源是可控的。你可能會發現,在我們的應用程式中,CSRF 令牌作為 cookie 傳送到客戶端,所以我們會看到瀏覽器自動將其傳送回伺服器,但提供保護的是頭部。

救命,我的應用程式如何擴充套件?

“等等…​” 你會說,“在單頁應用程式中使用會話狀態難道不是真的不好嗎?” 對這個問題的回答必須是“大部分情況下”,因為它確實是用於認證和 CSRF 保護的“好”東西。這種狀態必須儲存在某個地方,如果你將其從會話中取出,你就必須將其放在其他地方,並在伺服器和客戶端手動管理它。這隻會增加程式碼量和維護成本,而且通常是在重複發明一個已經很好的輪子。

“但是,但是…​”你會回應,“我現在如何水平擴充套件我的應用程式呢?”這才是你上面提出的“真正”問題,但它往往被簡化為“會話狀態不好,我必須是無狀態的”。不要驚慌。這裡要理解的關鍵點是,安全性*是*有狀態的。你無法擁有一個安全、無狀態的應用程式。那麼,你要把狀態儲存在哪裡呢?這就是問題的全部。 Rob WinchSpring Exchange 2014 上發表了一個非常有益且富有洞察力的演講,解釋了狀態的必要性(以及它的普遍性——TCP 和 SSL 都是有狀態的,所以無論你知不知道,你的系統都是有狀態的),如果你想更深入地研究這個話題,可能值得一看。

好訊息是你有一個選擇。最簡單的選擇是將會話資料儲存在記憶體中,並依賴負載均衡器中的粘性會話(Sticky Sessions)將來自同一會話的請求路由回同一 JVM(它們或多或少都支援這一點)。這足以讓你起步,並且適用於非常多的使用場景。另一個選擇是在你的應用程式例項之間共享會話資料。只要你嚴格只儲存安全資料,這些資料就會很小且不經常變化(僅在使用者登入、登出或會話超時時發生),因此不應該引起任何重大的基礎設施問題。使用Spring Session實現這一點也非常容易。在本系列的下一節中,我們將使用 Spring Session,因此這裡無需詳細介紹如何設定它,但它實際上只需要幾行程式碼和一個 Redis 伺服器,後者速度非常快。

設定共享會話狀態的另一個簡單方法是將應用程式部署為 WAR 檔案到 Cloud Foundry Pivotal Web Services,並將其繫結到 Redis 服務。

但是,我的自定義令牌實現怎麼辦(看,它是無狀態的)?

如果這是你對上一節的反應,那麼請再讀一遍,也許你第一次沒有完全理解。如果你將令牌儲存在某個地方,那麼它很可能不是無狀態的,但即使你沒有(例如,你使用 JWT 編碼令牌),你又將如何提供 CSRF 保護呢?這很重要。這裡有一個經驗法則(歸因於 Rob Winch):如果你的應用程式或 API 將被瀏覽器訪問,你就需要 CSRF 保護。這並不是說你沒有會話就做不到,只是你必須自己編寫所有這些程式碼,而這樣做的意義何在呢?因為它已經在 `HttpSession` 之上完美實現並執行良好(`HttpSession` 本身就是你使用的容器的一部分,並且從一開始就被納入規範)。即使你決定不需要 CSRF,並且有一個完美的“無狀態”(非基於會話)令牌實現,你仍然需要在客戶端編寫額外的程式碼來消費和使用它,而原本你可以直接委託給瀏覽器和伺服器自身的內建功能:瀏覽器總是傳送 cookies,伺服器總是有一個會話(除非你將其關閉)。這些程式碼不是業務邏輯,它們不會為你賺錢,只是一個開銷,甚至更糟的是,它們會讓你花錢。

結論

我們現在的應用程式已經接近使用者在真實生產環境中期望的“真實”應用程式,並且很可能可以作為模板,基於這種架構(單伺服器提供靜態內容和 JSON 資源)構建出功能更豐富的應用程式。我們使用 `HttpSession` 儲存安全資料,依賴客戶端遵守和使用我們傳送的 cookies,我們對此感到滿意,因為它讓我們能夠專注於自己的業務領域。在下一節中,我們將架構擴充套件為一個獨立的認證和 UI 伺服器,再加上一個獨立的 JSON 資源伺服器。這顯然可以很容易地推廣到多個資源伺服器。我們還將引入 Spring Session 到技術棧中,並展示如何使用它來共享認證資料。

資源伺服器

在本節中,我們繼續討論如何在“單頁應用程式”中使用Spring SecurityAngular。這裡,我們首先將應用程式中用作動態內容的“greeting”資源拆分到一個單獨的伺服器中,先作為非保護資源,然後透過不透明令牌進行保護。這是本系列文章的第三部分,你可以透過閱讀第一部分來了解應用程式的基本構建模組或從頭開始構建,或者你也可以直接檢視 Github 上的原始碼,它分為兩部分:一部分是資源未受保護的程式碼,另一部分是透過令牌保護的程式碼。

如果你正在使用示例應用程式學習本節內容,請務必清除瀏覽器快取中的 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://: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://:9000,你應該會看到包含 greeting 的 JSON。你可以在 `application.properties`(位於 "src/main/resources" 中)中固定埠號的修改:

application.properties
server.port: 9000

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

CORS 協商

瀏覽器嘗試與我們的資源伺服器進行協商,以根據跨域資源共享(Cross Origin Resource Sharing)協議確定是否允許訪問。這不是 Angular 的職責,因此就像 cookie 契約一樣,它對瀏覽器中的所有 JavaScript 都以這種方式工作。這兩個伺服器沒有宣告它們擁有共同的源(origin),因此瀏覽器拒絕傳送請求,UI 功能失效。

為了解決這個問題,我們需要支援 CORS 協議,該協議涉及一個“預檢”(pre-flight)OPTIONS 請求和一些用於列出呼叫方允許行為的頭資訊。Spring 4.2 有一些很好的細粒度 CORS 支援,因此我們只需在控制器對映上新增一個註解即可,例如:

ResourceApplication.java
@RequestMapping("/")
@CrossOrigin(origins="*", maxAge=3600)
public Message home() {
  return new Message("Hello World");
}
輕率地使用 `origins=*` 是一種快捷但不安全的方法,雖然它有效,但它不安全,並且絕對不推薦。

保護資源伺服器

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

新增 Spring Security

我們還可以看看如何像在 UI 伺服器中那樣,將安全性作為過濾層新增到資源伺服器。第一步非常簡單:只需在 Maven POM 中將 Spring Security 新增到 classpath 中即可:

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

重新啟動資源伺服器,瞧!它已經受到保護了:

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

我們被重定向到了一個(預設)登入頁面,因為 curl 沒有傳送與我們的 Angular 客戶端相同的頭資訊。修改命令使其傳送更相似的頭資訊:

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

所以我們只需要教導客戶端在每次請求時傳送憑據。

令牌認證

網際網路以及人們的 Spring 後端專案中充斥著各種自定義的基於令牌的認證解決方案。Spring Security 提供了一個基本的 `Filter` 實現,幫助你開始構建自己的解決方案(例如,參閱 `AbstractPreAuthenticatedProcessingFilter``TokenService`)。不過,Spring Security 中沒有一個規範的實現,原因之一可能是有更簡單的方法。

回顧本系列第二部分的內容,Spring Security 預設使用 `HttpSession` 來儲存認證資料。不過,它並不直接與會話互動:中間有一個抽象層(`SecurityContextRepository`),你可以使用它來改變儲存後端。如果我們能將資源伺服器中的該儲存庫指向一個儲存了由我們的 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 並集中儲存會話資料。

僅需這一行程式碼,並且本地執行著 Redis 伺服器,你就可以執行 UI 應用程式,使用一些有效的使用者憑據登入,會話資料(認證資訊)就會儲存在 Redis 中。

如果你的本地沒有執行 Redis 伺服器,你可以輕鬆地使用Docker啟動一個(在 Windows 或 MacOS 上這需要一個虛擬機器)。在Github 上的原始碼中有一個`docker-compose.yml`檔案,你可以在命令列中使用 `docker-compose up` 輕鬆執行它。如果你在虛擬機器中這樣做,Redis 伺服器將執行在與 localhost 不同的主機上,因此你需要將其隧道到 localhost,或者在 `application.properties` 中配置應用程式指向正確的 `spring.redis.host`。

從 UI 傳送自定義令牌

唯一缺失的部分是將儲存中資料對應的 key 進行傳輸的機制。這個 key 就是 `HttpSession` ID,所以如果我們在 UI 客戶端中能夠獲取到這個 key,我們就可以將其作為自定義頭資訊傳送給資源伺服器。因此,“home”控制器需要修改,使其在為 greeting 資源傳送 HTTP 請求時包含該頭資訊。例如:

home.component.ts
  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" 的成功回撥中。其實現非常簡單:

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" 的頭資訊中的會話 ID。

資源伺服器中的認證

為了讓資源伺服器能夠接受自定義頭資訊,需要對其進行一個微小的修改。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");
}

瀏覽器發出的預檢檢查現在將由 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()` 訪問,因為可能存在無意中傳送敏感資料的處理程式,因為它不知道該請求是預檢請求。`cors()` 配置工具透過在過濾層處理所有預檢請求來緩解此問題。

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

ResourceApplication.java
@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`)中明確指定:

application.yml
security:
  sessions: NEVER

這告訴 Spring Security “永不建立會話,但如果存在就使用”(由於在 UI 中進行了認證,會話將已經存在)。

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

為什麼不能完全使用 Cookies?

我們必須使用自定義頭資訊並在客戶端編寫程式碼來填充該頭資訊,這雖然不太複雜,但這似乎與第二部分中關於儘可能使用 cookies 和 sessions 的建議相矛盾。那裡的論點是,不這樣做會引入不必要的額外複雜性,而且可以肯定的是,我們現在的實現是迄今為止我們所見過的最複雜的:解決方案的技術部分遠遠超過了業務邏輯(儘管業務邏輯確實很小)。這無疑是一個合理的批評(也是我們計劃在本系列下一節中解決的問題),但讓我們簡單地看看為什麼不能簡單地對所有事情都使用 cookies 和 sessions。

至少我們仍然在使用會話,這是有道理的,因為 Spring Security 和 Servlet 容器知道如何毫不費力地做到這一點。但我們難道不能繼續使用 cookies 來傳輸認證令牌嗎?那會很不錯,但它不起作用是有原因的,那就是瀏覽器不允許我們這樣做。你當然可以從 JavaScript 客戶端在瀏覽器的 cookie 儲存中檢視,但有一些限制,而且這是有充分理由的。特別是,你無法訪問伺服器設定為 "HttpOnly" 的 cookies(你會發現會話 cookie 預設就是這樣)。你也不能在發出的請求中設定 cookies,所以我們無法設定 "SESSION" cookie(這是 Spring Session 預設的 cookie 名稱),我們必須使用自定義的 "X-Session" 頭資訊。這兩項限制都是為了保護你自己,防止惡意指令碼未經授權訪問你的資源。

TL;DR(太長不看):UI 和資源伺服器沒有共同的源(origin),所以它們不能共享 cookies(儘管我們可以使用 Spring Session 強制它們共享 sessions)。

結論

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

我們在這裡使用 Spring Session 在兩個邏輯上不是同一個應用程式的伺服器之間共享會話。這是一個巧妙的技巧,使用“常規”JEE 分散式會話是無法實現的。

API 閘道器

在本節中,我們繼續討論如何在“單頁應用程式”中使用Spring SecurityAngular。這裡,我們展示如何使用Spring Cloud構建一個 API 閘道器來控制後端資源的認證和訪問。這是本系列文章的第四部分,你可以透過閱讀第一部分來了解應用程式的基本構建模組或從頭開始構建,或者你也可以直接檢視 Github 上的原始碼。在上一節中,我們構建了一個簡單的分散式應用程式,該應用程式使用Spring Session來認證後端資源。在本節中,我們將 UI 伺服器變成後端資源伺服器的反向代理,解決了上一個實現中的問題(由自定義令牌認證引入的技術複雜性),併為我們提供了許多新的選項來控制來自瀏覽器客戶端的訪問。

提醒:如果你正在使用示例應用程式學習本節內容,請務必清除瀏覽器的 cookie 和 HTTP Basic 憑據快取。在 Chrome 中,針對單個伺服器執行此操作的最佳方法是開啟一個新的隱身視窗。

建立 API 閘道器

API 閘道器是前端客戶端(可以是基於瀏覽器的,如本節示例所示,也可以是移動端)的單一入口點(和控制點)。客戶端只需要知道一個伺服器的 URL,後端可以隨意重構而無需改變,這是一個顯著的優勢。在集中化和控制方面還有其他優勢:限流、認證、審計和日誌記錄。使用Spring Cloud實現一個簡單的反向代理非常簡單。

如果你一直在跟隨程式碼實踐,你會知道上一節結束時的應用程式實現有些複雜,所以這不是一個好的起點來繼續迭代。然而,有一箇中間狀態我們可以更容易地開始,即後端資源尚未受到 Spring Security 的保護。它的原始碼是 Github 上的一個獨立專案在此,所以我們將從那裡開始。它有一個 UI 伺服器和一個資源伺服器,並且它們之間可以相互通訊。資源伺服器還沒有 Spring Security,所以我們可以先讓系統工作起來,然後再新增那一層安全性。

一行程式碼實現的宣告式反向代理

為了將其變成一個 API 閘道器,UI 伺服器需要做一點小的調整。在 Spring 配置的某個地方,我們需要新增一個 `@EnableZuulProxy` 註解,例如在主(唯一)應用程式類中:

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

並在一個外部配置檔案中,我們需要將 UI 伺服器中的本地資源對映到 Github 上的外部配置("application.yml")中的遠端資源:

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

這表示“將此伺服器中模式為 `/resource/**` 的路徑對映到遠端伺服器 `localhost:9000` 中的相同路徑”。簡單而有效(好吧,如果算上 YAML 就是 6 行,但你並非總是需要 YAML 配置)!

要使這一切工作,我們只需要在 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”的使用——它是一個 starter POM,就像 Spring Boot 的那樣,但它管理著我們這個 Zuul 代理所需的依賴。我們還使用了 ``,因為我們希望能夠確保所有傳遞性依賴的版本都是正確的。

在客戶端消費代理

完成這些修改後,我們的應用程式仍然可以工作,但在修改客戶端之前,我們實際上還沒有使用新的代理。幸運的是,這非常簡單。我們只需要撤銷在上一節中從“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://:8080 載入 UI 的主頁,然後登入,你就會看到後端在主頁上渲染的訊息。

它是如何工作的?

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

動詞 路徑 狀態 響應

GET

/

200

index.html

GET

/*.js

200

來自 Angular 的資產

GET

/user

401

未授權(已忽略)

GET

/resource

401

對資源的未認證訪問

GET

/user

200

JSON 認證使用者

GET

/resource

200

(代理)JSON greeting

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

我們可以透過檢視 UI 伺服器(來自 Spring Boot Actuator,我們透過 Spring Cloud 依賴添加了它)中的 "/trace" 端點來檢視反向代理的執行情況。在新的瀏覽器中訪問 https://: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" 請求,你可以看到 cookies(由瀏覽器新增)和 CSRF 頭資訊(由 Angular 新增,如第二部分所討論)。第一個條目有 `remote: true`,這意味著它跟蹤的是對資源伺服器的呼叫。你可以看到它發出了對 uri 路徑 "/" 的請求,你也可以看到(至關重要地)cookies 和 CSRF 頭資訊也被髮送了。沒有 Spring Session,這些頭資訊對資源伺服器將毫無意義,但透過我們的設定,它現在可以使用這些頭資訊重新構建包含認證和 CSRF 令牌資料的會話。因此,請求被允許,一切正常!

結論

在本節中我們討論了相當多內容,但我們達到了一個非常好的狀態,兩個伺服器中的樣板程式碼量最少,它們都得到了很好的保護,並且使用者體驗沒有受到影響。僅憑這一點,就已經足夠成為使用 API 閘道器模式的理由了,但實際上我們只觸及了它可以用於哪些方面的皮毛(Netflix 使用它做很多事情)。閱讀Spring Cloud,瞭解更多關於如何輕鬆地向閘道器新增更多功能的資訊。本系列的下一節將透過將認證職責提取到一個獨立的伺服器(單點登入模式)來稍微擴充套件應用程式架構。

使用 OAuth2 實現單點登入

在本節中,我們繼續討論如何在“單頁應用程式”中使用Spring SecurityAngular。這裡,我們展示如何結合使用Spring Security OAuthSpring Cloud來擴充套件我們的 API 閘道器,實現單點登入(Single Sign On)和對後端資源的 OAuth2 令牌認證。這是本系列文章的第五部分,你可以透過閱讀第一部分來了解應用程式的基本構建模組或從頭開始構建,或者你也可以直接檢視 Github 上的原始碼。在上一節中,我們構建了一個小型分散式應用程式,該應用程式使用Spring Session來認證後端資源,並使用Spring Cloud在 UI 伺服器中實現了嵌入式 API 閘道器。在本節中,我們將認證職責提取到一個獨立的伺服器,使我們的 UI 伺服器成為(潛在的)眾多單點登入(Single Sign On)應用程式中第一個連線到授權伺服器的應用。這在當今許多應用程式中是一種常見模式,無論是在企業還是社交初創公司。我們將使用一個 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”的客戶端,帶有 secret 和一些授權許可型別,包括“authorization_code”。

現在讓我們在 9999 埠執行它,並使用一個可預測的密碼進行測試:

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

我們還設定了上下文路徑,使其不使用預設的 (“/”),因為否則你可能會將 localhost 上其他伺服器的 cookies 傳送到錯誤的伺服器。所以,啟動伺服器,我們可以確保它正常工作:

$ mvn spring-boot:run

或者在你的 IDE 中啟動 `main()` 方法。

測試授權伺服器

我們的伺服器使用了 Spring Boot 預設的安全設定,因此就像第一部分中的伺服器一樣,它將受到 HTTP Basic 認證的保護。要啟動一個授權碼令牌授予(authorization code token grant)流程,你可以訪問授權端點,例如 https://:9999/uaa/oauth/authorize?response_type=code&client_id=acme&redirect_uri=http://example.com,一旦認證透過,你將被重定向到 example.com 並附帶一個授權碼,例如 http://example.com/?code=jYWioI

對於這個示例應用程式,我們建立了一個沒有註冊重定向 URI 的客戶端“acme”,這使我們能夠重定向到 example.com。在生產應用程式中,你應該始終註冊重定向 URI(並使用 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。起初我們將使用這個預設 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>

然後從主應用程式類中移除會話 `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://: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 端點,它提供比使用者資訊端點更詳細的資訊,但需要更徹底的身份驗證。不同的選項(自然)提供不同的好處和權衡,但對這些選項的全面討論超出了本節的範圍。

實現使用者端點

在授權伺服器上,我們可以輕鬆新增該端點

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 資源了,因為它們現在都接受由授權伺服器建立的持有者令牌。

$ 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 註解)。

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

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

...

}

回顧 第四部分,UI 伺服器憑藉 @EnableZuulProxy 的功能充當 API 閘道器,我們可以在 YAML 中宣告路由對映。因此 "/user" 端點可以被代理到授權伺服器。

application.yml
zuul:
  routes:
    resource:
      path: /resource/**
      url: https://:9000
    user:
      path: /user/**
      url: https://: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://: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 元件。

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/> 包含在主頁中。

請注意,“登入”的導航連結是一個帶有 href 的常規連結(而不是 Angular 路由)。它指向的 "/login" 端點由 Spring Security 處理,如果使用者未進行身份驗證,將導致重定向到授權伺服器。

它是如何工作的?

現在一起執行所有伺服器,並在瀏覽器中訪問 UI:https://:8080。點選“登入”連結,您將被重定向到授權伺服器進行身份驗證(HTTP Basic 彈窗)並批准令牌授權(白標 HTML),然後重定向回 UI 中的主頁,主頁上的 greeting 是使用與 UI 身份驗證相同的令牌從 OAuth2 資源伺服器獲取的。

如果你使用一些開發者工具(通常按 F12 開啟,在 Chrome 中預設可用,在 Firefox 中可能需要外掛),可以在瀏覽器中看到瀏覽器和後端之間的互動。這裡有一個摘要

動詞 路徑 狀態 響應

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

(代理的)JSON 驗證使用者

GET

/app.html

200

主頁的 HTML 片段

GET

/resource

200

(代理)JSON greeting

帶 (uaa) 字首的請求是發往授權伺服器的。標記為“忽略”的響應是 Angular 在 XHR 呼叫中收到的響應,由於我們不處理這些資料,它們就被丟棄了。我們在處理 "/user" 資源時確實查詢已驗證的使用者,但由於在第一次呼叫中不存在,該響應被丟棄了。

在 UI 的 "/trace" 端點中(向下滾動到底部),您將看到代理的後端請求,目標是 "/user" 和 "/resource",使用 remote:true 和持有者令牌而不是 cookie(就像 第四部分 中一樣)進行身份驗證。Spring Cloud Security 為我們處理了這一切:透過識別我們使用了 @EnableOAuth2Sso@EnableZuulProxy,它已經確定(預設情況下)我們希望將令牌中繼到代理的後端。

與前面章節一樣,嘗試使用不同的瀏覽器訪問 "/trace",這樣可以避免身份驗證交叉的可能性(例如,如果您使用 Chrome 測試 UI,則使用 Firefox)。

退出體驗

如果您點選“退出”連結,您會看到主頁發生變化(greeting 不再顯示),因此使用者不再透過 UI 伺服器進行身份驗證。但是,如果您再次點選“登入”,您實際上不需要再次透過授權伺服器的身份驗證和批准流程(因為您沒有退出授權伺服器)。關於這是否是理想的使用者體驗,人們的意見分歧很大,這是一個眾所周知的棘手問題(單點退出:Science Direct 文章Shibboleth 文件)。理想的使用者體驗在技術上可能不可行,而且您有時也必須懷疑使用者是否真的想要他們所說的。 “我想‘退出’讓我退出”聽起來足夠簡單,但顯而易見的回答是,“退出什麼?您是想退出由該 SSO 伺服器控制的所有系統,還是僅僅退出您點選了‘退出’連結的那個系統?”如果您感興趣,本教程的 後續章節 對此有更深入的討論。

結論

我們的 Spring Security 和 Angular 技術棧的淺嘗之旅即將結束。我們現在擁有一個不錯的架構,在 UI/API 閘道器、資源伺服器和授權伺服器/令牌授予方這三個獨立元件中職責分明。所有層中的非業務程式碼量現在已降至最低,並且很容易看出在哪裡可以透過更多業務邏輯來擴充套件和改進實現。接下來的步驟將是整理授權伺服器中的 UI,並可能新增更多測試,包括對 JavaScript 客戶端的測試。另一個有趣的 M 任務是提取所有樣板程式碼並將其放入一個庫(例如 "spring-security-angular")中,該庫包含 Spring Security 和 Spring Session 自動配置以及 Angular 部分中導航控制器的 webjars 資源。閱讀了本系列中的章節後,任何希望學習 Angular 或 Spring Security 內部工作原理的人可能會感到失望,但如果您想了解它們如何良好地協同工作以及一點點配置如何能產生很大作用,那麼希望您會有一次愉快的體驗。Spring Cloud 是新的,這些示例在編寫時需要快照版本,但現在已有候選釋出版本,GA 版本即將釋出,因此請檢視並 透過 Githubgitter.im 傳送一些反饋。

本系列的 下一節 將討論訪問決策(超越身份驗證)並在同一代理後使用多個 UI 應用程式。

附錄:授權伺服器的 Bootstrap UI 和 JWT 令牌

您可以在 Github 原始碼 中找到此應用程式的另一個版本,該版本具有漂亮的登入頁面和使用者批准頁面,實現方式類似於我們在 第二部分 中實現登入頁面的方式。它還使用 JWT 對令牌進行編碼,因此資源伺服器無需使用 "/user" 端點,而是可以從令牌本身中提取足夠的資訊進行簡單的身份驗證。瀏覽器客戶端仍然使用它,透過 UI 伺服器代理,以便確定使用者是否已驗證(與實際應用程式中可能對資源伺服器進行的呼叫次數相比,它不需要經常這樣做)。

多個 UI 應用程式和一個閘道器

在本節中,我們將繼續 討論 如何在“單頁應用程式”中使用 Spring SecurityAngular。這裡我們將展示如何將 Spring SessionSpring Cloud 結合使用,以整合我們在第二和第四部分構建的系統的特性,最終實際構建出三個職責截然不同的單頁應用程式。目標是構建一個閘道器(類似於 第四部分),該閘道器不僅用於 API 資源,還用於從後端伺服器載入 UI。透過使用閘道器將身份驗證傳遞到後端,我們簡化了 第二部分 中處理令牌的複雜性。然後我們擴充套件系統,展示如何在後端做出本地的、細粒度的訪問決策,同時仍在閘道器處控制身份和身份驗證。這對於構建分散式系統是一個非常強大的模型,並且具有許多優點,我們將在介紹我們構建的程式碼中的功能時進行探討。

提示:如果您使用示例應用程式學習本節內容,請務必清除瀏覽器快取中的 cookie 和 HTTP Basic 憑據。在 Chrome 中,最好的方法是開啟新的隱身視窗。

目標架構

這是我們打算開始構建的基本系統的圖片

Components of the System

與本系列中的其他示例應用程式一樣,它包含一個 UI(HTML 和 JavaScript)和一個資源伺服器。與 第四節 中的示例一樣,它有一個閘道器,但在這裡閘道器是獨立的,不屬於 UI 的一部分。UI 實際上成為了後端的一部分,這使我們在重新配置和重新實現功能方面有了更多選擇,並且帶來了我們稍後將看到的其他好處。

瀏覽器訪問閘道器處理所有事務,它不需要知道後端架構(從根本上說,它不知道後端存在)。瀏覽器在此閘道器中做的一件事是身份驗證,例如,它像 第二節 中那樣傳送使用者名稱和密碼,並獲得一個 cookie 作為回報。在後續請求中,它會自動提供 cookie,閘道器將其傳遞給後端。客戶端無需編寫程式碼即可啟用 cookie 傳遞。後端使用 cookie 進行身份驗證,並且由於所有元件共享會話,它們共享關於使用者的相同資訊。這與 第五節 不同,在第五節中,cookie 必須在閘道器中轉換為訪問令牌,然後該訪問令牌必須由所有後端元件獨立解碼。

第四節 中所述,閘道器簡化了客戶端和伺服器之間的互動,並提供了一個小型、定義明確的介面來處理安全性。例如,我們不需要擔心 跨域資源共享(Cross Origin Resource Sharing),這是一種值得慶幸的緩解,因為它很容易出錯。

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

構建後端

在此架構中,後端與我們在 第三節 中構建的 "spring-session" 示例非常相似,唯一的例外是它實際上不需要登入頁面。這裡最簡單的方法可能是從第三節複製 "resource" 伺服器,並從 第一節"basic" 示例中獲取 UI。要從 "basic" UI 轉換到我們想要的 UI,我們只需新增幾個依賴項(就像我們在第三節首次使用 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 作為身份驗證令牌,但除非 cookie 已存在,否則不會建立它們。

資源伺服器

資源伺服器很容易從我們現有的示例之一生成。它與 第三節 中的 "spring-session" 資源伺服器相同:只需一個 "/resource" 端點和 Spring 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 註解。正如我們在 第一節 中看到的,有幾種方法可以做到這一點,其中一種是使用 Spring Initializr 生成骨架專案。更容易的是使用 Spring Cloud 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 重新命名)。

application.yml
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 在身份驗證時總是建立會話)。最後一點很重要,因為我們希望身份驗證以及隨之而來的會話在閘道器中進行管理。

執行起來

我們現在有三個元件,執行在 3 個埠上。如果您將瀏覽器指向 https://:8080/ui/,您應該會收到 HTTP Basic 挑戰,並且可以使用 "user/password"(您在閘道器中的憑據)進行身份驗證,一旦完成,您應該會在 UI 中看到 greeting,這是透過代理呼叫後端資源伺服器獲取的。

如果你使用一些開發者工具(通常按 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/> 中的某個位置)。

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 中的示例,它還有一個帶有“退出”按鈕的最小導航欄。這是登入表單的截圖。

Login Page

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

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

其中 login() 函式的實現類似於 第二節 中的實現。

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

如果我們執行這個增強型閘道器,就不必記住 UI 的 URL,只需載入主頁並跟隨連結即可。這是已驗證使用者的首頁。

Home Page

後端中的細粒度訪問決策

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

Components of the System

閘道器的 application.yml 中有一個新元件(Admin)和一個新路由。

application.yml
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”角色,以便允許(比如說)審計員使用者檢視主要管理員使用者所做的更改。這是一個細粒度的訪問決策,其規則僅在後端應用程式中已知且應該只在該處已知。在閘道器中,我們只需確保我們的使用者帳戶具有所需的角色,並且該資訊是可用的,但閘道器無需知道如何解釋它。在閘道器中,我們建立使用者帳戶以保持示例應用程式的自包含性。

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”),並且我們還添加了一個“audit”使用者,該使用者擁有“ADMIN”訪問許可權,但沒有“WRITER”許可權。

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

訪問決策放在 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 的約定)。因此,JavaScript 中需要 "ROLE_" 字首,但在 Spring Security 配置中則不需要,因為方法名稱已明確表示“角色”是操作的重點。

閘道器中支援 Admin UI 的更改

我們還將在閘道器中使用這些角色進行訪問決策(這樣我們就可以根據條件顯示指向 admin UI 的連結),因此我們也應該將“roles”新增到閘道器的 "/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://:8080 檢視結果。一切都應該正常工作,UI 應該根據當前已驗證的使用者而改變。

我們為何在此?

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

一個強大的特性是後端可以獨立擁有它們喜歡的任何型別的身份驗證(例如,如果您知道其物理地址和一組本地憑據,可以直接訪問 UI)。閘道器施加了一組完全不相關的約束,只要它能夠驗證使用者身份併為其分配滿足後端訪問規則的元資料。這是一種出色的設計,能夠獨立開發和測試後端元件。如果我們願意,我們可以回退到外部 OAuth2 伺服器(就像 第五節 中那樣,甚至完全不同的東西)來進行閘道器處的身份驗證,而無需觸及後端。

這種架構(單個閘道器控制身份驗證,以及所有元件之間共享會話令牌)的一個額外特性是“單點退出”,我們在 第五節 中認為難以實現的功能,在這裡免費獲得。更準確地說,完成的系統中自動提供了一種特定的單點退出使用者體驗方法:如果使用者從任何一個 UI(閘道器、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

要執行我們的測試程式碼,我們可以使用專案設定時建立的便捷指令碼執行 ./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 應用中登出,但不會從授權伺服器中登出,因此當您再次登入 UI 應用時,授權伺服器不會再次要求提供憑據。當授權伺服器是外部的時,這是完全預期、正常和理想的——Google 和其他外部授權伺服器提供商既不希望也不允許您從不受信任的應用中從其伺服器登出——但如果授權伺服器實際上與 UI 屬於同一系統,則這不是最佳使用者體驗。

廣義上講,從作為 OAuth2 客戶端進行身份驗證的 UI 應用中登出有三種模式

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

  2. 閘道器和內部授權伺服器(GIA)。您只需要從 2 個應用中登出,並且它們在使用者看來是同一系統的一部分。通常您希望自動批准所有授權。

  3. 單點登出(SL)。一個授權伺服器和多個 UI 應用,它們都有自己的身份驗證,當用戶從其中一個登出時,您希望所有其他應用也隨之登出。天真的實現很可能因為網路分割槽和伺服器故障而失敗——您基本上需要全域性一致的儲存。

有時,即使您有外部授權伺服器,您也希望控制身份驗證並新增內部訪問控制層(例如,授權伺服器不支援的作用域或角色)。那麼最好使用 EA 進行身份驗證,但有一個內部授權伺服器可以為令牌新增您需要的附加詳細資訊。這個OAuth2 教程中的 auth-server 示例非常簡單地展示瞭如何做到這一點。然後,您可以將 GIA 或 SL 模式應用於包含內部授權伺服器的系統。

如果您不想使用 EA,這裡有一些選項

  • 在瀏覽器客戶端中同時從授權伺服器和 UI 應用登出。簡單的方法,透過一些仔細的 CRSF 和 CORS 配置可以實現。不支援 SL。

  • 令牌可用後立即從授權伺服器登出。在 UI 端難以實現,因為令牌是在 UI 端獲取的,而那裡沒有授權伺服器的會話 cookie。Spring OAuth 中有一個功能請求,展示了一種有趣的方法:在生成授權碼後立即使授權伺服器中的會話失效。Github issue 中包含一個實現會話失效的切面 (aspect),但作為 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 隨同傳送。XHR 請求只有在我們明確要求 withCredentials:true 時才會附帶 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,這是一個訊號,表明使用者正在被重定向回客戶端應用,並檢查 URL 中是否包含授權碼或錯誤。如果您也使用隱式授權,可以新增“token=”。

透過這個簡單的更改,一旦您完成身份驗證,授權伺服器中的會話就已經失效了,因此無需從客戶端嘗試管理它。當您從 UI 應用登出,然後再次登入時,授權伺服器不會認出您,並提示輸入憑據。本教程的原始碼中的 oauth2-logout 示例實現了這種模式。這種方法的缺點是您不再擁有真正的單點登入——系統中屬於您的其他應用會發現授權伺服器會話已失效,它們必須再次提示進行身份驗證——如果存在多個應用,這不是一個很好的使用者體驗。

結論

在本節中,我們瞭解瞭如何實現從 OAuth2 客戶端應用登出的幾種不同模式(以教程第五節中的應用作為起點),並討論了其他一些模式的選項。這些選項並非詳盡無遺,但應該能讓您很好地瞭解其中涉及的權衡取捨,並提供一些工具來思考適合您的用例的最佳解決方案。本節中只有幾行 JavaScript 程式碼,而且這些程式碼並非特定於 Angular(它只是為 XHR 請求添加了一個標誌),因此所有這些經驗和模式都適用於本指南示例應用之外的更廣泛範圍。一個反覆出現的主題是,在存在多個 UI 應用和單個授權伺服器的情況下,所有單點登出(SL)方法都傾向於在某些方面存在缺陷:您能做的最好是選擇讓使用者最不感到不適的方法。如果您有一個內部授權伺服器和一個由許多元件組成的系統,那麼對於使用者來說,唯一感覺像是一個單一系統的架構可能是所有使用者互動都透過一個閘道器。

想編寫新指南或為現有指南做出貢獻?請檢視我們的貢獻指南

所有指南的程式碼均採用 ASLv2 許可釋出,文字內容採用 署名-禁止演繹創作共享許可

獲取程式碼