$ mkdir ui && cd ui
$ curl https://start.spring.io/starter.tgz -d dependencies=web,security -d name=ui | tar -xzvf -Spring Security および Angular
セキュアなシングルページアプリケーション
このチュートリアルでは、Spring Security、Spring Boot、および Angular が連携して、快適で安全なユーザーエクスペリエンスを提供するいくつかの優れた機能を示します。Spring と Angular の初心者がアクセスできる必要がありますが、どちらの専門家にも役立つ詳細もたくさんあります。これは実際には、Spring Security と Angular に関する一連のセクションの最初のものであり、それぞれに新しい機能が次々と公開されています。2 回目以降の記事でアプリケーションを改善しますが、その後の主な変更点は機能ではなくアーキテクチャです。
Spring とシングルページアプリケーション
HTML5、リッチブラウザーベースの機能、「シングルページアプリケーション」は、現代の開発者にとって非常に価値のあるツールですが、意味のあるインタラクションにはバックエンドサーバーが必要です。バックエンドサーバーは、静的なコンテンツ(HTML、CSS、JavaScript)の提供、時には(最近はそうでもないですが)動的な HTML のレンダリング、ユーザーの認証、保護されたリソースへのアクセスの確保、(最後になりますが)HTTP や JSON(REST API と呼ばれることもあります)を介してブラウザー上で JavaScript と対話するなど、いくつかのロールのうちのどれか、すべてを果たすことができます。
Spring は常にバックエンド機能 (特に企業で) を構築するための人気のある技術であり、Spring Boot の出現により、物事はかつてないほど簡単になりました。Spring Boot、Angular、Twitter Bootstrap を使って、ゼロから新しいシングルページアプリケーションを構築する方法を見てみましょう。特定のスタックを選択する特別な理由はありませんが、特にエンタープライズ Java ショップのコア Spring 支持者には非常に人気があるため、出発点として価値があります。
新規プロジェクトの作成
Spring と Angular を完全に使いこなしていない人でも、何が起こっているのかを追うことができるように、このアプリケーションの作成を少し詳しく説明します。追いかけたい場合は、アプリケーションが動作している最後までスキップして、すべてがどのように適合するかを確認できます。新しいプロジェクトを作成するためのさまざまなオプションがあります。
ビルドするプロジェクト全体のソースコードはここの Github (英語) にあるため、必要に応じてプロジェクトを複製し、そこから直接作業することができます。次に、次のセクションにジャンプします。
Curl の使用
開始する新しいプロジェクトを作成する最も簡単な方法は、Spring Boot Initializr を使用することです。例: UN*X のようなシステムで curl を使用:
次に、そのプロジェクト(デフォルトでは通常の Maven Java プロジェクト)をお気に入りの IDE にインポートするか、コマンドラインでファイルと "mvn" を操作するだけです。次に、次のセクションにジャンプします。
Spring Boot CLI の使用
次のように、Spring Boot CLI を使用して同じプロジェクトを作成できます。
$ spring init --dependencies web,security ui/ && cd ui次に、次のセクションにジャンプします。
Initializr Web サイトの使用
必要に応じて、Spring Boot Initializr から .zip ファイルと同じコードを直接取得することもできます。ブラウザーで開き、依存関係 "Web" と「セキュリティ」を選択して、「プロジェクトを生成」をクリックします。.zip ファイルには、ルートディレクトリに標準の Maven または Gradle プロジェクトが含まれているため、展開する前に空のディレクトリを作成することをお勧めします。次に、次のセクションにジャンプします。
Eclipse Spring Tool Suite の使用
Pleiades All in One (JDK, STS, Lombok 付属) または Eclipse Spring Tool Suite (英語) (Eclipse プラグインのセット)では、File->New->Spring Starter Project のウィザードを使用してプロジェクトを作成およびインポートすることもできます。次に、次のセクションにジャンプします。IntelliJ IDEA と NetBeans には同様の機能があります。
Angular アプリを追加する
Angular(または最新のフロントエンドフレームワーク)のシングルページアプリケーションの中核は、最近 Node.js ビルドになります。Angular には、これをすばやくセットアップするためのツールがいくつかあるため、使用できます。また、他の Spring Boot アプリケーションと同様に、Maven でビルドするオプションも保持できます。Angular アプリのセットアップ方法の詳細は別の場所 [GitHub] (英語) で説明されています。または、github からこのチュートリアルのコードをチェックアウトすることもできます。
アプリケーションの実行
Angular アプリの準備が整うと、アプリケーションはブラウザーにロード可能になります(まだあまり機能していません)。コマンドラインでこれを行うことができます
$ mvn spring-boot:runhttp://localhost:8080 のブラウザーに移動します。ホームページをロードすると、ユーザー名とパスワードを要求するブラウザーダイアログが表示されます(ユーザー名は "user" であり、パスワードは起動時にコンソールログに出力されます)。実際にはまだコンテンツがありません(または ng CLI のデフォルトの「ヒーロー」チュートリアルコンテンツ)。本質的に空白のページを取得する必要があります。
パスワードのコンソールログをスクレイピングしたくない場合は、これを "application.properties" ("src/main/resources" 内)に追加します: spring.security.user.password=password (そして独自のパスワードを選択します)。これは、"application.yml" を使用してサンプルコードで行いました。 |
IDE では、アプリケーションクラスで main() メソッドを実行するだけです(クラスは 1 つだけで、上記の "curl" コマンドを使用した場合は UiApplication と呼ばれます)。
スタンドアロン JAR としてパッケージ化して実行するには、次のようにします。
$ mvn package
$ java -jar target/*.jarAngular アプリケーションのカスタマイズ
"app-root" コンポーネント( "src/app/app.component.ts" 内)をカスタマイズしましょう。
最小限の Angular アプリケーションは次のようになります。
import { Component } from '@angular/core';
@Component({
selector: 'app-root',
standalone: true,
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent {
title = 'Demo';
greeting = {'id': 'XXX', 'content': 'Hello World'};
} この TypeScript のコードのほとんどはボイラープレートです。興味深いものはすべて AppComponent にあり、ここで「セレクター」(HTML 要素の名前)と @Component アノテーションを介してレンダリングする HTML のスニペットを定義します。HTML テンプレート("app.component.html" )も編集する必要があります。
<div style="text-align:center"class="container">
<h1>
Welcome {{title}}!
</h1>
<div class="container">
<p>Id: <span>{{greeting.id}}</span></p>
<p>Message: <span>{{greeting.content}}</span></p>
</div>
</div> これらのファイルを "src/app" に追加してアプリを再構築すると、安全で機能するようになり、"Hello World!" と表示されます。greeting は、handlebar プレースホルダー {{greeting.id}} および {{greeting.content}} を使用して、HTML で Angular によってレンダリングされます。
動的コンテンツの追加
これまでに、グリーティングがハードコードされたアプリケーションがあります。これは、物事がどのように組み合わされるかを学習できますが、実際にはコンテンツがバックエンドサーバーから来ることを期待しているため、グリーティングを取得するために使用できる HTTP エンドポイントを作成しましょう。アプリケーションクラス [GitHub] (英語) ( "src/main/java/demo" )で、@RestController アノテーションを追加し、新しい @RequestMapping を定義します。
@SpringBootApplication
@RestController
public class UiApplication {
@RequestMapping("/resource")
public Map<String,Object> home() {
Map<String,Object> model = new HashMap<String,Object>();
model.put("id", UUID.randomUUID().toString());
model.put("content", "Hello World");
return model;
} 新しいプロジェクトの作成方法によっては、UiApplication と呼ばれない場合があります。 |
そのアプリケーションを実行し、"/resource" エンドポイントを curl しようとすると、デフォルトで安全であることがわかります。
$ curl localhost:8080/resource
{"timestamp":1420442772928,"status":401,"error":"Unauthorized","message":"Full authentication is required to access this resource","path":"/resource"}Angular からの動的リソースのロード
ブラウザーでそのメッセージを取得しましょう。AppComponent を変更して、XHR を使用して保護されたリソースをロードします。
import { Component, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
interface Greeting {
id?: number;
content?: string;
}
@Component({
selector: 'app-root',
standalone: true,
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent {
title = 'Demo';
greeting: Greeting = {};
private http = inject(HttpClient);
constructor() {
this.http.get<Greeting>('resource').subscribe(data => this.greeting = data);
}
}Angular の inject() 関数を使って HttpClient (英語) サービスを取得し、それを使ってリソースを取得します。Angular からレスポンスが渡されるため、JSON を取り出し、グリーティングに割り当てます。
アプリケーションで HttpClient サービスを有効にするには、アプリケーションブートストラップでこれを提供する必要があります。
import { bootstrapApplication } from '@angular/platform-browser';
import { AppComponent } from './app/app.component';
import { provideHttpClient } from '@angular/common/http';
bootstrapApplication(AppComponent, {
providers: [
provideHttpClient()
]
}).catch(err => console.error(err));アプリケーションを再度実行(またはブラウザーでホームページを再読み込み)すると、動的メッセージとその一意の ID が表示されます。そのため、リソースは保護されており、直接 curl することはできませんが、ブラウザーはコンテンツにアクセスできました。100 行未満のコードで安全な単一ページのアプリケーションがあります!
| 静的リソースを変更した後、ブラウザーに強制的にリロードさせる必要がある場合があります。Chrome(およびプラグイン付き Firefox)では、「開発者ツール」(F12)を使用できますが、それで十分かもしれません。または、CTRL + F5 を使用する必要がある場合があります。 |
仕組みは?
一部の開発者ツールを使用すると、ブラウザーとバックエンド間の相互作用をブラウザーで確認できます(通常、F12 はこれを開き、デフォルトで Chrome で動作し、Firefox でプラグインが必要になる場合があります)。概要は次のとおりです。
| 動詞 | パス | ステータス | レスポンス |
|---|---|---|---|
GET | / | 401 | 認証のためのブラウザープロンプト |
GET | / | 200 | index.html |
GET | /*.js | 200 | Angular からの 3 番目のアセットのロード |
GET | /main.bundle.js | 200 | アプリケーションロジック |
GET | /resource | 200 | JSON グリーティング |
ブラウザーがホームページの読み込みを単一のインタラクションとして扱うため、401 が表示されない場合があります。また、CORS [Mozilla] ネゴシエーションがあるため、"/resource" に対する 2 つのリクエストが表示される場合があります。
リクエストをより詳細に見ると、すべてのリクエストに "Authorization" ヘッダーがあることがわかります。次のようなものです。
Authorization: Basic dXNlcjpwYXNzd29yZA==ブラウザーは、すべてのリクエストでユーザー名とパスワードを送信しています(そのため、本番環境で HTTPS のみを使用することを忘れないでください)。それについて "Angular" はないため、JavaScript フレームワークまたは選択した非フレームワークで動作します。
それのどこが悪いんだい?
一見、かなり良い作業をしたようです。簡潔で実装しやすく、すべてのデータは秘密のパスワードで保護されています。フロントエンドまたはバックエンドのテクノロジーを変更しても機能します。しかし、いくつかの課題があります。
基本認証は、ユーザー名とパスワードの認証に制限されています。
認証 UI はどこにでもありますが、不格好なものです (ブラウザーダイアログ)。
クロスサイトリクエストフォージェリ [Wikipedia] (CSRF)からの保護はありません。
CSRF は、バックエンドリソースを取得するだけでよいため(つまり、サーバーの状態が変更されないため)、実際のアプリケーションでは課題になりません。アプリケーションに POST、PUT、DELETE があるとすぐに、合理的な最新の手段ではもはや安全ではなくなります。
このシリーズの次のセクションでは、フォームベース認証を使用するようにアプリケーションを継承します。これは、HTTP Basic よりもはるかに柔軟です。フォームを作成したら、CSRF 保護が必要になります。Spring Security と Angular の両方には、これを支援するためのすぐに使える機能がいくつかあります。ネタバレ: HttpSession を使用する必要があります。
ログインページ
このセクションでは、「シングルページアプリケーション」で Spring Security を Angular (英語) と使用する方法について引き続き説明します。ここでは、Angular を使用して、フォームを介してユーザーを認証し、安全なリソースをフェッチして UI でレンダリングする方法を示します。これは一連のセクションの 2 番目であり、アプリケーションの基本的な構成要素に追いつくか、最初のセクションを読んでゼロからビルドするか、Github のソースコードに直接 (英語) 進むことができます。最初のセクションでは、HTTP 基本認証を使用してバックエンドリソースを保護する単純なアプリケーションを構築しました。これでは、ログインフォームを追加し、ユーザーに認証するかどうかを制御し、最初の反復での課題を修正します(主に CSRF 保護の欠如)。
リマインダー: このセクションでサンプルアプリケーションを使用している場合は、ブラウザーのキャッシュの Cookie と HTTP 基本認証情報を必ずクリアしてください。Chrome では、単一サーバーでこれを行う最良の方法は、新しいシークレットウィンドウを開くことです。
ホームページにナビゲーションを追加
Angular アプリケーションの中核は、基本的なページレイアウト用の HTML テンプレートです。すでに非常に基本的なものがありましたが、このアプリケーションではいくつかのナビゲーション機能(ログイン、ログアウト、ホーム)を提供する必要があるため、それを(src/app で)変更しましょう。
<div class="container">
<ul class="nav nav-pills">
<li class="nav-item"><a class="nav-link" routerLinkActive="active" routerLink="/home">Home</a></li>
<li class="nav-item"><a class="nav-link" routerLinkActive="active" routerLink="/login">Login</a></li>
<li class="nav-item"><a class="nav-link" (click)="logout()">Logout</a></li>
</ul>
</div>
<div class="container">
<router-outlet></router-outlet>
</div> メインコンテンツは <router-outlet/> で、ログインリンクとログアウトリンクのあるナビゲーションバーがあります。
<router-outlet/> セレクターは Angular によって提供され、アプリケーションのブートストラップ内のコンポーネントに接続する必要があります。ルート(メニューリンク)ごとに 1 つのコンポーネントと、結合して状態を共有するヘルパーサービス(AppService)が必要になります。以下は、Angular のスタンドアロンコンポーネントアーキテクチャを使用して、これらすべての要素をまとめた実装です。
import { bootstrapApplication } from '@angular/platform-browser';
import { provideRouter, Routes } from '@angular/router';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { AppComponent } from './app/app.component';
import { HomeComponent } from './app/home.component';
import { LoginComponent } from './app/login.component';
import { xhrInterceptor } from './app/xhr.interceptor';
const routes: Routes = [
{ path: '', pathMatch: 'full', redirectTo: 'home' },
{ path: 'home', component: HomeComponent },
{ path: 'login', component: LoginComponent }
];
bootstrapApplication(AppComponent, {
providers: [
provideRouter(routes),
provideHttpClient(withInterceptors([xhrInterceptor]))
]
}).catch(err => console.error(err)); ルート配列を持つ provideRouter() を使用して、"/" ( "home" にリダイレクト)、"/home"(HomeComponent)、"/login"(LoginComponent)へのリンクを設定します。
また、インターセプターを使用して provideHttpClient() を構成します。これは、後で HTTP リクエストにデフォルトのヘッダーを追加するために必要になります。
AppComponent は実はあまり機能しません。アプリのルートディレクトリに付属する TypeScript コンポーネントはここにあります。
import { Component, inject } from '@angular/core';
import { Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
import { HttpClient } from '@angular/common/http';
import { AppService } from './app.service';
import { finalize } from 'rxjs/operators';
@Component({
selector: 'app-root',
standalone: true,
imports: [RouterOutlet, RouterLink, RouterLinkActive],
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent {
private app = inject(AppService);
private http = inject(HttpClient);
private router = inject(Router);
constructor() {
this.app.authenticate(undefined, undefined);
}
logout(): void {
this.http.post('logout', {}).pipe(
finalize(() => {
this.app.authenticated = false;
this.router.navigateByUrl('/login');
})
).subscribe();
}
}顕著な特徴:
Angular の
inject()関数を使用した依存性注入があり、これにはAppServiceも含まれます。コンポーネントのメソッドとして公開されているログアウト関数があり、これを使用してバックエンドにログアウトリクエストを送信できます。この関数は
appサービスにフラグを設定し、ユーザーをログイン画面に戻します(これはfinalize()コールバックを介して無条件に実行されます)。templateUrlを使用して、テンプレート HTML を別のファイルに外部化します。authenticate()関数は、コントローラーが読み込まれた際に呼び出され、ユーザーが実際にすでに認証されているかどうか(たとえば、セッションの途中でブラウザーをリフレッシュしたかどうか)を確認します。authenticate()関数を使用してリモートを呼び出す必要があるのは、実際の認証はサーバーによって行われるため、ブラウザーが認証情報を追跡することを信頼したくないためです。
上記で注入した app サービスには、ユーザーが現在認証されているかどうかを確認できるブール値フラグと、バックエンドサーバーでの認証に使用できる、または単にユーザーの詳細を照会するために使用できる関数 authenticate() が必要です:
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpHeaders } from '@angular/common/http';
@Injectable({ providedIn: 'root' })
export class AppService {
authenticated = false;
private http = inject(HttpClient);
authenticate(credentials: { username: string; password: string } | undefined, callback?: () => void): void {
const headers = new HttpHeaders(credentials ? {
authorization: 'Basic ' + btoa(credentials.username + ':' + credentials.password)
} : {});
this.http.get<{ name?: string }>('user', { headers }).subscribe({
next: (response) => {
this.authenticated = !!response?.name;
if (callback) {
callback();
}
},
error: () => {
this.authenticated = false;
}
});
}
}authenticated フラグは単純です。authenticate() 関数は、提供されている場合は HTTP 基本認証資格情報を送信し、提供されていない場合は送信しません。また、オプションの callback 引数があり、認証が成功した場合にコードを実行するために使用できます。
挨拶
古いホームページの挨拶の内容は、"src/app" の "app.component.html" のすぐ隣に置くことができます。
<h1>Greeting</h1>
<div [hidden]="!authenticated()">
<p>The ID is {{greeting.id}}</p>
<p>The content is {{greeting.content}}</p>
</div>
<div [hidden]="authenticated()">
<p>Login to see your greeting</p>
</div> ユーザーはログインするかどうか(ブラウザーですべて制御される前)を選択できるようになったため、UI で安全なコンテンツとそうでないコンテンツを区別する必要があります。(まだ存在しない) authenticated() 関数への参照を追加することにより、これを予測しました。
HomeComponent はグリーティングを取得する必要があり、AppService からフラグをプルする authenticated() ユーティリティ機能も提供する必要があります。
import { Component, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { AppService } from './app.service';
interface Greeting {
id?: number;
content?: string;
}
@Component({
selector: 'app-home',
standalone: true,
templateUrl: './home.component.html'
})
export class HomeComponent {
title = 'Demo';
greeting: Greeting = {};
private app = inject(AppService);
private http = inject(HttpClient);
constructor() {
this.http.get<Greeting>('resource').subscribe(data => this.greeting = data);
}
authenticated(): boolean {
return this.app.authenticated;
}
}ログインフォーム
ログインフォームには、独自のコンポーネントも取得されます。
<div class="alert alert-danger" [hidden]="!error">
There was a problem logging in. Please try again.
</div>
<form role="form" (submit)="login()">
<div class="form-group">
<label for="username">Username:</label> <input type="text"
class="form-control" id="username" name="username" [(ngModel)]="credentials.username"/>
</div>
<div class="form-group">
<label for="password">Password:</label> <input type="password"
class="form-control" id="password" name="password" [(ngModel)]="credentials.password"/>
</div>
<button type="submit" class="btn btn-primary">Submit</button>
</form> これは非常に標準的なログインフォームで、ユーザー名とパスワードの 2 つの入力と、Angular イベントハンドラー (submit) を介してフォームを送信するためのボタンがあります。form タグでアクションを実行する必要はありません。そのため、アクションをまったく挿入しない方がよいでしょう。Angular モデルに error が含まれる場合にのみ表示されるエラーメッセージもあります。フォームコントロールは Angular フォーム (英語) の ngModel を使用して HTML と Angular コントローラー間でデータを渡します。この場合、credentials オブジェクトを使用してユーザー名とパスワードを保持します。
認証プロセス
追加したログインフォームをサポートするには、さらに機能を追加する必要があります。クライアント側ではこれらは LoginComponent に実装され、サーバーでは Spring Security 構成になります。
ログインフォームの送信
フォームを送信するには、すでに (submit) を介してフォームで参照した login() 関数と、ngModel を介して参照した credentials オブジェクトを定義する必要があります。「ログイン」コンポーネントを具体化します。
import { Component, inject } from '@angular/core';
import { Router } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { AppService } from './app.service';
@Component({
selector: 'app-login',
standalone: true,
imports: [FormsModule],
templateUrl: './login.component.html'
})
export class LoginComponent {
credentials = { username: '', password: '' };
error = false;
private app = inject(AppService);
private router = inject(Router);
login(): boolean {
this.app.authenticate(this.credentials, () => {
this.router.navigateByUrl('/');
});
return false;
}
}credentials オブジェクトの初期化に加えて、フォームで必要な login() を定義します。
authenticate() は、相対リソース(アプリケーションのデプロイルートに対して)"/user" に対して GET を実行します。login() 関数から呼び出されると、Base64 でエンコードされたクレデンシャルがヘッダーに追加されるため、サーバー上で認証が行われ、代わりに Cookie が受け入れられます。login() 関数は、認証の結果を取得すると、それに応じてローカル error フラグも設定します。これは、ログインフォームの上のエラーメッセージの表示を制御するために使用されます。
現在認証されているユーザー
authenticate() 関数を処理するには、新しいエンドポイントをバックエンドに追加する必要があります。
@GetMapping("/user")
@ResponseBody
public Principal user(Principal user) {
return user;
} これは、Spring Security アプリケーションで役立つトリックです。"/user" リソースが到達可能である場合、現在認証されているユーザー(Authentication [GitHub] (英語) )を返します。そうでない場合、Spring Security はリクエストをインターセプトし、AuthenticationEntryPoint [GitHub] (英語) を介して 401 レスポンスを送信します。
サーバーでのログインリクエストの処理
Spring Security を使用すると、ログインリクエストを簡単に処理できます。メインアプリケーションクラス [GitHub] (英語) にいくつかの構成を追加するだけです(たとえば、内部クラスとして)。
@Configuration
protected static class SecurityConfiguration {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.httpBasic(basic -> basic
.securityContextRepository(new HttpSessionSecurityContextRepository())
)
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/index.html", "/", "/home", "/login", "/*.js", "/*.css", "/*.ico").permitAll()
.anyRequest().authenticated()
)
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()));
return http.build();
}
}これは、Spring Security をカスタマイズした標準 Spring Boot アプリケーションであり、静的(HTML)リソースへの匿名アクセスのみを許可します。HTML リソースは、明確になる理由のため、Spring Security によって無視されるだけでなく、匿名ユーザーが利用できる必要があります。
デフォルトの HTTP リクエストヘッダーの追加
この時点でアプリを実行すると、ブラウザーが(ユーザーとパスワードの)基本認証ダイアログをポップアップすることがわかります。これは、"WWW-Authenticate" ヘッダーを持つ /user および /resource への XHR リクエストからの 401 応答を確認するためです。このポップアップを抑制する方法は、Spring Security から来るヘッダーを抑制することです。また、応答ヘッダーを抑制する方法は、特別な従来のリクエストヘッダー "X-Requested-With = XMLHttpRequest" を送信することです。以前は Angular のデフォルトでしたが、1.3.0 では削除されました [GitHub] (英語) 。Angular XHR リクエストでデフォルトのヘッダーを設定する方法は次のとおりです。
すべてのリクエストにヘッダーを追加する HTTP インターセプター関数を作成します。
import { HttpInterceptorFn } from '@angular/common/http';
export const xhrInterceptor: HttpInterceptorFn = (req, next) => {
const xhr = req.clone({
headers: req.headers.set('X-Requested-With', 'XMLHttpRequest')
});
return next(xhr);
};ここでの構文は単純です。リクエストを複製し、追加ヘッダーを追加して渡す関数インターセプターを定義します。このインターセプターは、Angular によってすべての HTTP リクエストに対して呼び出され、追加のヘッダーを追加するために使用できます。
このインターセプターをインストールするには、アプリケーションブートストラップの provideHttpClient() 呼び出しでそれを宣言します。
bootstrapApplication(AppComponent, {
providers: [
provideRouter(routes),
provideHttpClient(withInterceptors([xhrInterceptor]))
]ログアウト
アプリケーションはほぼ関数に終了しています。最後に行う必要があるのは、ホームページでスケッチしたログアウト機能を実装することです。ユーザーが認証されている場合は、「ログアウト」リンクを表示し、AppComponent の logout() 関数にフックします。"/logout" に HTTP POST を送信することを忘れないでください。これは、サーバーに実装する必要があります。これは、Spring Security によってすでに追加されているため簡単です(つまり、この単純なユースケースでは何もする必要はありません)。ログアウトの動作をより細かく制御するには、SecurityFilterChain で 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 はまさにこれを実行する特別な CsrfTokenRepository を提供しています。
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()));これらの変更が行われると、クライアント側で何もする必要がなくなり、ログインフォームが機能するようになります。
仕組みは?
一部の開発者ツールを使用すると、ブラウザーとバックエンド間の相互作用をブラウザーで確認できます(通常、F12 はこれを開き、デフォルトで Chrome で動作し、Firefox でプラグインが必要になる場合があります)。概要は次のとおりです。
| 動詞 | パス | ステータス | レスポンス |
|---|---|---|---|
GET | / | 200 | index.html |
GET | /*.js | 200 | 角度からのアセット |
GET | /user | 401 | 不許可 (無視されました) |
GET | /home | 200 | ホームページ |
GET | /user | 401 | 不許可 (無視されました) |
GET | /resource | 401 | 不許可 (無視されました) |
GET | /user | 200 | 資格情報を送信して JSON を取得する |
GET | /resource | 200 | JSON グリーティング |
上記で「無視」とマークされているレスポンスは、XHR 呼び出しで Angular によって受信された HTML レスポンスであり、そのデータを処理していないため、HTML はフロアにドロップされます。"/user" リソースの場合、認証されたユーザーを探しますが、最初の呼び出しには存在しないため、そのレスポンスはドロップされます。
リクエストをよく見ると、すべてのリクエストに Cookie が含まれていることがわかります。クリーンなブラウザー(たとえば Chrome のシークレットモード)で起動した場合、最初のリクエストではサーバーに送信される Cookie はありませんが、サーバーは "JSESSIONID"(通常の HttpSession)および "X- XSRF-TOKEN」(上記で設定した CRSF Cookie)。後続のリクエストにはすべてこれらの Cookie があり、それらは重要です。アプリケーションはそれらの Cookie なしでは機能せず、いくつかの本当に基本的なセキュリティ機能(認証および CSRF 保護)を提供しています。Cookie の値は、ユーザーが(POST 後に)認証すると変更されます。これは、別の重要なセキュリティ機能です(セッション固定攻撃 [Wikipedia] を防止します)。
| アプリケーションからロードされたページにない場合でもブラウザーが自動的に送信するため、サーバーに送り返される Cookie に依存する CSRF 保護には不十分です(クロスサイトスクリプティング攻撃、別名 XSS [Wikipedia] )。ヘッダーは自動的に送信されないため、オリジンは制御されます。このアプリケーションでは、CSRF トークンが Cookie としてクライアントに送信されるため、ブラウザーによって自動的に返送されますが、保護を提供するのはヘッダーです。 |
ヘルプ、私のアプリケーションはどのように拡張されますか?
「しかし... 1 ページのアプリケーションでセッションステートを使用するのは非常に悪いことではありませんか ? 」この質問に対する答えは、「ほとんど」使用します。なぜなら、認証と CSRF 保護のためにセッションを使用することは、間違いなく良いことです。この状態はどこかに保存する必要があり、セッションから取り出す場合は、別の場所に置き、サーバーとクライアントの両方で手動で管理する必要があります。これは単にコードが増えただけであり、メンテナンスも増えただけであり、全体として完全に車輪を再発明したことになります。
「しかし、しかし … アプリケーションを今どのように水平にスケーリングするのですか?」これは上記で尋ねていた「本当」の質問ですが、「セッション状態が悪い、ステートレスでなければなりません」に短縮される傾向があります。パニックにならないでください。ここで重要なのは、セキュリティがステートフルであることです。安全でステートレスなアプリケーションを持つことはできません。状態をどこに保存しますか? これですべてです。Rob Winch (英語) は Spring Exchange 2014 (英語) で非常に有益でインサイトに富んだ講演を行い、状態の必要性(およびその普遍性: TCP と SSL はステートフルであるため、システムはそれを知っているかどうかに関係なくステートフルです)。このトピックをさらに詳しく調べます。
良いニュースは、選択肢があることです。最も簡単な選択は、セッションデータをメモリに保存し、ロードバランサーのスティッキーセッションに依存して、同じセッションからのリクエストを同じ JVM にルーティングすることです(すべてサポートしています)。これで十分に理解でき、非常に多くのユースケースで機能します。もう 1 つの選択肢は、アプリケーションのインスタンス間でセッションデータを共有することです。厳格でセキュリティデータのみを保存している限り、データは小さく、変更はめったに行われません(ユーザーがログインまたはログアウトしたとき、またはセッションがタイムアウトしたときのみ)。インフラストラクチャに大きな問題はありません。Spring Session [GitHub] (英語) を使用するのも簡単です。このシリーズの次のセクションで Spring Session を使用するため、ここで設定する方法について詳しく説明する必要はありませんが、文字通り数行のコードと Redis サーバーであり、非常に高速です。
| 共有セッション状態をセットアップするもう 1 つの簡単な方法は、アプリケーションを WAR ファイルとして Cloud Foundry Pivotal Web サービス (英語) にデプロイし、それを Redis サービスにバインドすることです。 |
しかし、カスタムトークンの実装(ステートレス、外観)はどうですか?
それが最後のセクションへのレスポンスであった場合、それをもう一度参照してください。トークンをどこかに保存した場合はおそらくステートレスではありませんが、保存していない場合(たとえば、JWT エンコードトークンを使用している場合)、CSRF 保護をどのように提供しますか? それは重要です。経験則(Rob Winch に起因)は次のとおりです。アプリケーションまたは API がブラウザーからアクセスされる場合、CSRF 保護が必要です。セッションなしでそれができないということではなく、自分ですべてのコードを書かなければならないということです。そして、すでに実装されており、HttpSession の上で完全にうまく機能するため、何がポイントになるでしょう使用し、最初から仕様に焼き付けているコンテナーの? ) CSRF を必要とせず、完全に「ステートレス」(非セッションベース)のトークン実装を持っていると判断したとしても、それを消費して使用するためにクライアントに追加のコードを記述する必要がありました。ブラウザーとサーバーの独自の組み込み機能: ブラウザーは常に Cookie を送信し、サーバーは常にセッションを保持します(スイッチをオフにしない限り)。そのコードはビジネスロジックではなく、収益を上げるものではなく、単なるオーバーヘッドであるため、さらに悪いことにお金がかかります。
結論
現在のアプリケーションは、ユーザーがライブ環境の「実際の」アプリケーションに期待するものに近いものであり、おそらく、そのアーキテクチャを備えたより機能豊富なアプリケーション(静的な単一サーバーコンテンツと JSON リソース)。HttpSession を使用してセキュリティデータを保存し、クライアントが送信する Cookie を考慮して使用することを頼りにしていますが、独自のビジネスドメインに集中できるため、それに満足しています。次のセクションでは、アーキテクチャを個別の認証および UI サーバーに加えて、JSON 用のスタンドアロンリソースサーバーに拡張します。これは明らかに、複数のリソースサーバーに簡単に一般化できます。また、Spring Session をスタックに導入し、それを使用して認証データを共有する方法を示します。
リソースサーバー
このセクションでは、「シングルページアプリケーション」で Spring Security を Angular (英語) と使用する方法について引き続き説明します。ここでは、アプリケーションの動的コンテンツとして使用している「あいさつ」リソースを、まず保護されていないリソースとして別のサーバーに分割し、次に Opaque トークンで保護します。これは一連のセクションの 3 番目であり、アプリケーションの基本的な構成要素に追いつくか、最初のセクションを読んでゼロからビルドするか、Github のソースコードに直接進むことができます。2 つの部分: リソースが保護されていない [GitHub] (英語) 部分と、トークンによって保護されている部分 [GitHub] (英語) 。
| このセクションでサンプルアプリケーションを使用している場合は、ブラウザーのキャッシュの Cookie と HTTP 基本認証情報を必ずクリアしてください。Chrome では、単一サーバーでこれを行う最良の方法は、新しいシークレットウィンドウを開くことです。 |
別のリソースサーバー
クライアント側の変更
クライアント側では、リソースを別のバックエンドに移動するために行うことはあまりありません。最後のセクション [GitHub] (英語) の「ホーム」コンポーネントは次のとおりです。
import { Component, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { AppService } from './app.service';
interface Greeting {
id?: number;
content?: string;
}
@Component({
selector: 'app-home',
standalone: true,
templateUrl: './home.component.html'
})
export class HomeComponent {
title = 'Demo';
greeting: Greeting = {};
private app = inject(AppService);
private http = inject(HttpClient);
constructor() {
this.http.get<Greeting>('resource').subscribe(data => this.greeting = data);
}
authenticated(): boolean {
return this.app.authenticated;
}
}これに必要なのは、URL を変更することだけです。例: localhost で新しいリソースを実行する場合、次のようになります。
import { Component, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { AppService } from './app.service';
interface Greeting {
id?: number;
content?: string;
}
@Component({
selector: 'app-home',
standalone: true,
templateUrl: './home.component.html'
})
export class HomeComponent {
title = 'Demo';
greeting: Greeting = {};
private app = inject(AppService);
private http = inject(HttpClient);
constructor() {
this.http.get<Greeting>('http://localhost:9000').subscribe(data => this.greeting = data);
}
authenticated(): boolean {
return this.app.authenticated;
}
}サーバー側の変更
UI サーバー [GitHub] (英語) の変更は簡単です。グリーティングリソースの @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" を操作するだけです。
メインアプリケーションクラス [GitHub] (英語) に @RequestMapping を追加し、古い UI [GitHub] (英語) から実装をコピーするだけです:
package demo;
import java.util.UUID;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@SpringBootApplication
@RestController
public class ResourceApplication {
@RequestMapping("/")
@CrossOrigin(origins="*", maxAge=3600)
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;
Message() {
}
public Message(String content) {
this.content = content;
}
public String getId() {
return id;
}
public String getContent() {
return content;
}
}それが完了すると、アプリケーションはブラウザーにロード可能になります。コマンドラインでこれを行うことができます
$ mvn spring-boot:run -Dserver.port=9000http://localhost:9000 のブラウザーに移動すると、挨拶付きの JSON が表示されます。application.properties ( "src/main/resources" 内)でポートの変更をベイクできます。
server.port: 9000ブラウザーの UI(ポート 8080)からそのリソースをロードしようとすると、ブラウザーが XHR リクエストを許可しないため、機能しないことがわかります。
CORS ネゴシエーション
ブラウザーは、クロスオリジンリソース共有 [Mozilla] プロトコルに従ってリソースサーバーへのアクセスが許可されているかどうかを確認するために、リソースサーバーとネゴシエートしようとします。Angular の責任ではないため、Cookie 契約と同様に、ブラウザー内のすべての JavaScript でこのように機能します。2 つのサーバーは共通の発信元を宣言していないため、ブラウザーはリクエストの送信を拒否し、UI は壊れています。
これを修正するには、CORS プロトコルをサポートする必要があります。これには、「事前」OPTIONS リクエストと、呼び出し元の許可された動作をリストするいくつかのヘッダーが含まれます。Spring はきめ細かな CORS サポートを備えているため、コントローラーマッピングにアノテーションを追加するだけで済みます。例:
@RequestMapping("/")
@CrossOrigin(origins="*", maxAge=3600)
public Message home() {
return new Message("Hello World");
}origins=* を簡単に使用するのは簡単で汚れており、動作しますが、安全ではなく、決して推奨されません。 |
リソースサーバーのセキュリティ保護
すごい! 新しいアーキテクチャーで動作するアプリケーションがあります。唯一の問題は、リソースサーバーにセキュリティがないことです。
Spring Security の追加
UI サーバーのように、フィルターレイヤーとしてリソースサーバーにセキュリティを追加する方法も確認できます。最初のステップは本当に簡単です: Spring Security を Maven POM のクラスパスに追加するだけです:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
...
</dependencies>リソースサーバーを再起動します。安全です:
$ curl -v localhost:9000
< HTTP/1.1 302 Found
< Location: http://localhost:9000/login
...curl は Angular クライアントと同じヘッダーを送信していないため、(ホワイトラベル)ログインページへのリダイレクトを取得しています。より類似したヘッダーを送信するようにコマンドを変更します。
$ curl -v -H "Accept: application/json" \
-H "X-Requested-With: XMLHttpRequest" localhost:9000
< HTTP/1.1 401 Unauthorized
...クライアントにすべてのリクエストで資格情報を送信するように教えるだけです。
トークン認証
インターネットおよび人々の Spring バックエンドプロジェクトには、カスタムトークンベースの認証ソリューションが散らばっています。Spring Security は、あなた自身で始めるための最低限の Filter 実装を提供します(たとえば、AbstractPreAuthenticatedProcessingFilter [GitHub] (英語) および TokenService [GitHub] (英語) を参照)。ただし、Spring Security には正規の実装はありません。その理由の 1 つは、おそらくさらに簡単な方法があるからです。
このシリーズのパート II から、Spring Security はデフォルトで HttpSession を使用して認証データを保管することを思い出してください。ただし、セッションと直接対話することはありません。その間にはストレージバックエンドを変更するために使用できる抽象化レイヤー(SecurityContextRepository [GitHub] (英語) )があります。リソースサーバーで、UI によって認証が検証されたストアをそのリポジトリにポイントできる場合、2 つのサーバー間で認証を共有する方法があります。UI サーバーにはすでにそのようなストア(HttpSession)があります。そのため、そのストアを配布してリソースサーバーに開くことができる場合、ほとんどのソリューションがあります。
Spring Session
ソリューションのその部分は Spring Session [GitHub] (英語) で非常に簡単です。必要なのは、共有データストア(Redis と JDBC はすぐにサポートされています)、および Filter をセットアップするためのサーバーの構成行です。
UI アプリケーションでは、POM [GitHub] (英語) にいくつかの依存関係を追加する必要があります。
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>Spring Boot と Spring Session は連携して Redis に接続し、セッションデータを一元的に保存します。
この構成が完了し、Redis サーバーがローカルホスト上で実行されると、UI アプリケーションを実行し、有効なユーザー資格情報を使用してログインすることができ、セッションデータ (認証) が redis に保存されます。
ローカルで実行している redis サーバーがない場合は、Docker (英語) で簡単に起動できます(Windows または MacOS では VM が必要です)。Github のソースコード [GitHub] (英語) には docker-compose.yml (英語) ファイルがあり、docker-compose up を使用してコマンドラインで簡単に実行できます。VM でこれを行うと、Redis サーバーは localhost とは異なるホストで実行されるため、localhost にトンネリングするか、application.properties の正しい spring.redis.host を指すようにアプリを構成する必要があります。 |
UI からカスタムトークンを送信する
不足している唯一の部分は、ストア内のデータへのキーの転送メカニズムです。キーは HttpSession ID であるため、UI クライアントでそのキーを取得できる場合、リソースサーバーにカスタムヘッダーとして送信できます。そのため、「ホーム」コントローラーは、グリーティングリソースの HTTP リクエストの一部としてヘッダーを送信するように変更する必要があります。例:
import { Component, inject } from '@angular/core';
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { AppService } from './app.service';
interface Greeting {
id?: number;
content?: string;
}
interface TokenResponse {
token?: string;
}
@Component({
selector: 'app-home',
standalone: true,
templateUrl: './home.component.html'
})
export class HomeComponent {
title = 'Demo';
greeting: Greeting = {};
private app = inject(AppService);
private http = inject(HttpClient);
constructor() {
this.http.get<TokenResponse>('token').subscribe({
next: (data) => {
const token = data.token;
if (token) {
this.http.get<Greeting>('http://localhost:9000', {
headers: new HttpHeaders().set('X-Auth-Token', token)
}).subscribe(response => this.greeting = response);
}
},
error: () => {}
});
}
authenticated(): boolean {
return this.app.authenticated;
}
}(より洗練された解決策としては、必要に応じてトークンを取得し、HTTP インターセプターを使用してリソースサーバーへのすべてのリクエストにヘッダーを追加することが考えられます)
"http://localhost:9000" に直接移動する代わりに、"/token" の UI サーバー上の新しいカスタムエンドポイントへの呼び出しの成功コールバックでその呼び出しをラップしました。その実装は簡単です。
@GetMapping("/token")
@ResponseBody
public Map<String, String> token(HttpSession session) {
return Collections.singletonMap("token", session.getId());
}これで、UI アプリケーションの準備が整い、バックエンドへのすべての呼び出しに対して "X-Auth-Token" というヘッダーにセッション ID が含まれます。
リソースサーバーでの認証
リソースサーバーがカスタムヘッダーを受け入れることができるように、リソースサーバーに小さな変更が 1 つあります。CORS 構成では、そのヘッダーをリモートクライアントからの許可されたヘッダーとして指定する必要があります。
@RequestMapping("/")
@CrossOrigin(origins = "*", maxAge = 3600, allowedHeaders = { "x-auth-token", "x-requested-with", "x-xsrf-token" })
public Message home() {
return new Message("Hello World");
}ブラウザーからのプリフライトチェックは Spring MVC によって処理されるようになりましたが、Spring Security に通過を許可することを伝える必要があります。
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.cors(Customizer.withDefaults())
.authorizeHttpRequests(authorize -> authorize.anyRequest().authenticated());
return http.build();
} すべてのリソースに permitAll() アクセスする必要はありません。また、リクエストがプリフライトであることを認識していないため、機密データを誤って送信するハンドラーが存在する場合があります。cors() 構成ユーティリティは、フィルター層ですべてのプリフライトリクエストを処理することにより、これを軽減します。 |
あとは、リソースサーバーでカスタムトークンを取得し、それを使用してユーザーを認証するだけです。必要なのは、Spring Security にセッションリポジトリの場所と、受信リクエストでトークン(セッション ID)を探す場所を伝えるだけなので、これは非常に簡単です。最初に Spring Session と Redis の依存関係を追加する必要があり、次に HttpSessionIdResolver をセットアップできます。
@Bean
HttpSessionIdResolver sessionStrategy() {
return HeaderHttpSessionIdResolver.xAuthToken();
} このリゾルバーは UI サーバーのリゾルバーのミラーイメージであるため、Redis をセッションストアとして確立します。唯一の違いは、デフォルトの Cookie( "SESSION" )ではなく、ヘッダー(デフォルトでは "X-Auth-Token" )を参照するカスタム HttpSessionIdResolver を使用する点です。また、認証されていないクライアントでブラウザーがダイアログを表示しないようにする必要があります。アプリは安全ですが、デフォルトで WWW-Authenticate: Basic を使用して 401 を送信するため、ブラウザーはユーザー名とパスワードを入力するダイアログで応答します。これを実現する方法は複数ありますが、Angular が "X-Requested-With" ヘッダーを送信するようにすでに設定しているため、デフォルトでは Spring Security が処理します。
リソースサーバーを再起動し、新しいブラウザーウィンドウで UI を開きます。
なぜすべてが Cookie で機能しないのですか?
カスタムヘッダーを使用し、クライアントにヘッダーを入力するコードを記述する必要がありました。これはそれほど複雑ではありませんが、可能な限り Cookie とセッションを使用するというパート II のアドバイスと矛盾するようです。そうしないと、不必要な複雑さが追加されるという議論がありましたが、確かに、現在の実装はこれまで見てきた中で最も複雑です: ソリューションの技術的な部分は、ビジネスロジック(明らかに小さい)をはるかに上回っています。これは間違いなく公正な批判です(そして、このシリーズの次のセクションで説明する予定です)が、すべての目的で Cookie とセッションを使用するほど単純ではない理由を簡単に見てみましょう。
少なくともセッションは引き続き使用しています。Spring Security とサーブレットコンテナーは、何もしなくてもセッションを使用できる方法を知っているため、これは理にかなっています。しかし、認証トークンの転送に Cookie を使い続けることはできなかったのでしょうか? そうできれば良かったのですが、うまくいかない理由があります。それは、ブラウザーがそれを認可しなかったからです。JavaScript クライアントからブラウザーの Cookie ストアを調べることはできますが、いくつかの制限があり、それには正当な理由があります。特に、サーバーから "HttpOnly" として送信された Cookie(セッション Cookie の場合はデフォルトでこの名前になっていることがわかります)にはアクセスできません。また、送信リクエストに Cookie を設定することもできないため、"SESSION" Cookie(Spring Session のデフォルトの Cookie 名)を設定することができず、カスタムの "X-Auth-Token" ヘッダーを使用する必要がありました。これらの制限はどちらも、悪意のあるスクリプトが適切な認証なしにリソースにアクセスできないようにするためのものです。
TL; DR UI とリソースサーバーは共通の起源を持たないため、Cookie を共有できません(Spring Session を使用して強制的にセッションを共有できます)。
結論
このシリーズのパート II のアプリケーションの機能を複製しました。リモートバックエンドから取得した挨拶を含むホームページで、ナビゲーションバーにログインとログアウトのリンクがあります。違いは、グリーティングが UI サーバーに組み込まれているためはなく、スタンドアロンのリソースサーバーから送信されることです。これにより、実装が大幅に複雑になりましたが、幸いなことに、ほとんど構成ベースの (実際には 100% 宣言型) ソリューションを使用できます。新しいコードをすべてライブラリ (Spring 構成と Angular カスタムディレクティブ) に抽出することで、ソリューション 100% を宣言型にすることもできます。この興味深いタスクは、次の 2 回の記事以降に延期します。次のセクションでは、現在の実装の複雑さをすべて軽減するための、別の本当に優れた方法として、API ゲートウェイパターン (クライアントはすべてのリクエストを 1 つの場所に送信し、その場所で認証処理) を見ていきます。
| ここでは、Spring Session を使用して、論理的に同じアプリケーションではない 2 つのサーバー間でセッションを共有しました。これは巧妙なトリックであり、「通常の」JEE 分散セッションでは不可能です。 |
API Gateway
このセクションでは、「シングルページアプリケーション」で Spring Security を Angular (英語) と使用する方法について引き続き説明します。ここでは、Spring Cloud を使用して認証とバックエンドリソースへのアクセスを制御する API ゲートウェイを構築する方法を示します。これは一連のセクションの 4 番目であり、アプリケーションの基本的な構成要素に追いつくか、最初のセクションを読んでゼロから構築するか、Github のソースコードに直接 (英語) 進むことができます。前のセクションでは、Spring Session [GitHub] (英語) を使用してバックエンドリソースを認証する単純な分散アプリケーションを構築しました。これでは、UI サーバーをバックエンドリソースサーバーへのリバースプロキシにし、最後の実装(カスタムトークン認証によって導入された技術的な複雑さ)の課題を修正し、ブラウザークライアントからのアクセスを制御するための多くの新しいオプションを提供します。
リマインダー: このセクションでサンプルアプリケーションを使用している場合は、ブラウザーのキャッシュの Cookie と HTTP 基本認証情報を必ずクリアしてください。Chrome では、単一サーバーでこれを行う最良の方法は、新しいシークレットウィンドウを開くことです。
API Gateway の作成
API Gateway は、フロントエンドクライアントの単一のエントリポイント(および制御)であり、ブラウザーベース(このセクションの例のような)またはモバイルの場合があります。クライアントは 1 つのサーバーの URL を知るだけでよく、バックエンドを変更せずに自由にリファクタリングできます。これは大きな利点です。集中化と制御に関して、レート制限、認証、監査、ログ記録などの利点があります。Spring Cloud を使用すると、単純なリバースプロキシの実装は非常に簡単です。
コードを順守している場合、最後のセクションの最後のアプリケーション実装が少し複雑であることがわかるため、それを繰り返すのに最適な場所ではありません。ただし、より簡単に開始できる中間点があり、バックエンドリソースはまだ Spring Security で保護されていませんでした。このソースコードは Github (英語) の別個のプロジェクトなので、そこから始めます。UI サーバーとリソースサーバーがあり、お互いに通信しています。リソースサーバーには Spring Security がまだないため、最初にシステムを動作させてから、そのレイヤーを追加できます。
1 行の宣言的リバースプロキシ
API ゲートウェイとして動作させるには、UI サーバーに少し手を加える必要があります。Spring Cloud Gateway をクラスパスに追加し、ルートを設定する必要があります。まず、Maven POM [GitHub] (英語) に依存関係を追加します。
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>2025.0.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway-server-webmvc</artifactId>
</dependency>
...
</dependencies> "spring-cloud-starter-gateway-server-webmvc" の使用に注意してください。これは Spring Boot と同様にスターター POM ですが、Spring Cloud Gateway MVC プロキシに必要な依存関係を管理します。また、推移的な依存関係のすべてのバージョンが正しいことを前提とするため、<dependencyManagement> も使用しています。
次に、外部構成ファイルで、UI サーバーのローカルリソースを外部構成 [GitHub] (英語) の リモートリソース ("application.yml" ) にマップする必要があります。
spring:
security:
user:
password: password
session:
store-type: redis
web:
resources:
static-locations: classpath:/static/browser/
cloud:
gateway:
mvc:
routes:
- id: resource
uri: http://localhost:9000
predicates:
- Path=/resource/**
filters:
- StripPrefix=1 これは「このサーバー内のパターン /resource/** のパスを、リモートサーバーの localhost:9000 にある同じパスにマッピングする」という意味です。StripPrefix=1 フィルターはリクエストを転送する前に /resource プレフィックスを削除します。そのため、/resource/ へのリクエストはバックエンドでは / へのリクエストになります。シンプルでありながら効果的です!
クライアントでプロキシを使用する
これらの変更が適切に適用されていても、アプリケーションは引き続き機能しますが、クライアントを変更するまで、実際には新しいプロキシを使用していません。幸いなことにそれは簡単です。最後のセクションで 「単一」から「バニラ」サンプルに行った変更を元に戻す必要があります。
constructor(private app: AppService, private http: HttpClient) {
http.get('resource').subscribe(data => this.greeting = data);
}サーバーを起動すると、すべてが機能し、リクエストは UI(API Gateway)を介してリソースサーバーにプロキシされます。
さらなる簡素化
さらに良い: リソースサーバーに CORS フィルターはもう必要ありません。すぐにそれを一緒に投げましたが、技術的に手作業で焦点を合わせなければならないことは特に危険でした(特にセキュリティに関する場合)。幸いなことにそれは冗長であるため、それを捨てて、夜眠りに戻ることができます!
リソースサーバーのセキュリティ保護
中間状態では、リソースサーバーにセキュリティが設定されていないことを覚えているかもしれません。
余談: ネットワークアーキテクチャがアプリケーションアーキテクチャを反映している場合、ソフトウェアセキュリティの欠如は問題にならない場合があります(リソースサーバーに UI サーバー以外の人が物理的にアクセスできないようにすることができます)。その簡単なデモンストレーションとして、リソースサーバーにローカルホストでのみアクセスできるようにします。これをリソースサーバーの
application.propertiesに追加するだけです。
server.address: 127.0.0.1うわー、簡単でした! データセンターでのみ表示されるネットワークアドレスを使用して、すべてのリソースサーバーとすべてのユーザーデスクトップで機能するセキュリティソリューションを実現します。
ソフトウェアレベルでセキュリティが必要であると判断したと仮定します(多くの理由により、かなりの可能性があります)。問題になることはありません。依存関係として Spring Security を(リソースサーバー POM [GitHub] (英語) に)追加するだけですから:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>セキュアなリソースサーバーを取得するにはこれで十分ですが、パート III にはなかったのと同じ理由で、まだ動作しているアプリケーションは取得できません。2 つのサーバー間で共有認証状態はありません。
認証状態の共有
認証(および CSRF)状態を共有するために、前回と同じメカニズム、つまり Spring Session [GitHub] (英語) を使用できます。以前のように、両方のサーバーに依存関係を追加します。
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>しかし今回は、Spring Cloud Gateway がデフォルトですべてのヘッダーを転送するため、構成ははるかに簡単になります。
次に、リソースサーバーに移ります。2 つの小さな変更が必要です。1 つは、リソースサーバーで HTTP Basic を明示的に無効にすること(ブラウザーに認証ダイアログが表示されないようにするため)。もう 1 つは、セッション作成ポリシーを NEVER に設定して、リソースサーバーがセッションを作成せず、既存のセッションを使用するようにすることです。
package demo;
import java.util.UUID;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@SpringBootApplication
@RestController
public class ResourceApplication {
@RequestMapping("/")
public Message home() {
return new Message("Hello World");
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.httpBasic(httpBasic -> httpBasic.disable());
http.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.NEVER));
http.authorizeHttpRequests(authorize -> authorize.anyRequest().authenticated());
return http.build();
}
public static void main(String[] args) {
SpringApplication.run(ResourceApplication.class, args);
}
}
class Message {
private String id = UUID.randomUUID().toString();
private String content;
Message() {
}
public Message(String content) {
this.content = content;
}
public String getId() {
return id;
}
public String getContent() {
return content;
}
}余談: 認証ダイアログを防ぐ別の方法は、HTTP Basic を保持したまま、401 チャレンジを "Basic" 以外に変更することです。
HttpSecurity構成コールバックのAuthenticationEntryPointの 1 行の実装でそれを行うことができます。
UI サーバーは、HTTP 基本認証によるセッションベースのセキュリティコンテキストストレージを使用するように構成する必要もあります。
package demo;
import java.security.Principal;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ResponseBody;
@SpringBootApplication
@Controller
public class UiApplication {
@GetMapping("/user")
@ResponseBody
public Principal user(Principal user) {
return user;
}
@GetMapping(value = "/{path:[^\\.]*}")
public String redirect() {
return "forward:/";
}
public static void main(String[] args) {
SpringApplication.run(UiApplication.class, args);
}
@Configuration
protected static class SecurityConfiguration {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.httpBasic(httpBasic -> httpBasic
.securityContextRepository(new HttpSessionSecurityContextRepository()))
.logout(Customizer.withDefaults())
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/index.html", "/", "/home", "/login").permitAll()
.anyRequest().authenticated()
)
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()));
return http.build();
}
}
} ここで重要な設定は、securityContextRepository を HttpSessionSecurityContextRepository に設定する httpBasic カスタマイザです。これにより、認証状態がセッションに適切に保存され、Redis を介してリソースサーバーと共有されるようになります。
redis がバックグラウンドで実行されている限り(起動する場合は docker-compose.yml [GitHub] (英語) を使用してください)、システムは動作します。http://localhost:8080 で UI のホームページをロードしてログインすると、バックエンドからのメッセージがホームページに表示されます。
仕組みは?
バックグラウンドで何が起こっていますか? 最初に、UI サーバー(および API Gateway)で HTTP リクエストを確認できます。
| 動詞 | パス | ステータス | レスポンス |
|---|---|---|---|
GET | / | 200 | index.html |
GET | /*.js | 200 | 角度からのアセット |
GET | /user | 401 | 不許可 (無視されました) |
GET | /resource | 401 | リソースへの認証されていないアクセス |
GET | /user | 200 | JSON 認証済みユーザー |
GET | /resource | 200 | (プロキシ)JSON グリーティング |
これは、Spring Session を使用しているため、Cookie 名がわずかに異なる( "JSESSIONID" ではなく "SESSION" )という点を除いて、パート II の最後のシーケンスと同じです。ただし、アーキテクチャは異なり、"/resource" への最後のリクエストは、リソースサーバーにプロキシされているため、特別です。
Spring Cloud Gateway のデバッグログを有効にすると、リバースプロキシの動作を確認できます。application.yml に以下のコードを追加してください。
logging:
level:
org.springframework.cloud.gateway: DEBUG| 認証の重複が発生しないように、別のブラウザーを使用するようにしてください (例: UI のテストに Chrome を使用した場合は Firefox を使用します)。アプリの動作は停止しませんが、同じブラウザーからの認証が混在している場合、ログが読みにくくなります。 |
/resource にリクエストを送信すると、受信リクエストとプロキシされたリクエストの両方を確認できます。ブラウザーの開発者ツール(ネットワークタブ)を使用すると、クライアントからのリクエストを確認できます。
GET /resource/ HTTP/1.1
Host: localhost:8080
Accept: application/json, text/plain, */*
X-Requested-With: XMLHttpRequest
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ゲートウェイはこのリクエストをリソースサーバーにプロキシします。デバッグログには、ルーティングの決定と転送されたリクエストが表示されます。
DEBUG o.s.c.g.s.m.HandlerFunctions : Matched route: resource
DEBUG o.s.c.g.s.m.HandlerFunctions : Mapping [/resource/] to http://localhost:9000
Forwarded Request:
method: GET
path: /
headers:
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プロキシされたリクエストでは、いくつかの重要な点に注意してください。
パスが
/resource/から/に変更されました (StripPrefix=1フィルターはプレフィックスを削除しました)x-forwarded-prefixとx-forwarded-hostヘッダーが追加され、バックエンドは元のリクエストコンテキストを認識するようになりました。クッキー(
SESSION、XSRF-TOKEN)と CSRF ヘッダー(x-xsrf-token)はすべて転送されました
Spring Session がなければ、これらの Cookie はリソースサーバーにとって意味をなさないでしょう。しかし、今回の設定により、これらのヘッダーを使用して、認証情報と CSRF トークンデータを含むセッションを再構成できるようになりました。リソースサーバーは、SESSION Cookie 値を使用して Redis でセッションを検索し、認証済みのプリンシパルを見つけてリクエストを許可します。つまり、リクエストは許可され、処理が開始します。
結論
このセクションではかなり多くのことを説明しましたが、2 台のサーバーで定型コードが最小限に抑えられ、セキュリティが確保され、ユーザーエクスペリエンスが損なわれることなく、非常に良好な状態に到達しました。これだけでも API ゲートウェイパターンを使用する理由になりますが、実際には、その活用方法のほんの一部を紹介したに過ぎません。ゲートウェイに簡単に機能を追加する方法については、Spring Cloud を参照してください。このシリーズの次のセクションでは、認証処理を別のサーバーに分離することで、アプリケーションアーキテクチャを少し拡張します(シングルサインオンパターン)。
OAuth2 を使用したシングルサインオン
このセクションでは、「シングルページアプリケーション」で Spring Security を Angular (英語) と使用する方法について引き続き説明します。ここでは、Spring Authorization Server と Spring Cloud Gateway を使用して API Gateway を継承し、シングルサインオンと OAuth2 トークン認証をバックエンドリソースに実行する方法を示します。これは一連のセクションの 5 番目であり、アプリケーションの基本的な構成要素に追いつくか、最初のセクションを読んでゼロからビルドするか、Github のソースコードに直接 (英語) 進むことができます。最後のセクションでは、Spring Session [GitHub] (英語) を使用してバックエンドリソースを認証し、Spring Cloud を使用して UI サーバーに組み込み API ゲートウェイを実装する小さな分散アプリケーションを構築しました。このセクションでは、認証サーバーへの多くのシングルサインオンアプリケーションの最初の UI サーバーを作成するために、認証の責任を別のサーバーに抽出します。これは、企業およびソーシャルスタートアップの両方で、最近の多くのアプリケーションで一般的なパターンです。OAuth2 サーバーをオーセンティケーターとして使用するため、OAuth2 サーバーを使用してバックエンドリソースサーバーのトークンを付与することもできます。Spring Cloud Gateway はアクセストークンをバックエンドに自動的に中継し、UI サーバーとリソースサーバーの両方の実装をさらに簡素化できるようにします。
リマインダー: このセクションでサンプルアプリケーションを使用している場合は、ブラウザーのキャッシュの Cookie と HTTP 基本認証情報を必ずクリアしてください。Chrome では、単一サーバーでこれを行う最良の方法は、新しいシークレットウィンドウを開くことです。
OAuth2 認可サーバーの作成
最初のステップは、認証とトークン管理を処理する新しいサーバーを作成することです。パート I の手順に従って、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 Authorization Server 依存関係を追加する必要があるため、POM [GitHub] (英語) に以下を追加します。
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-authorization-server</artifactId>
</dependency>認可サーバーにはいくつかの設定が必要です。2 つのセキュリティフィルターチェーン(1 つは OAuth2 エンドポイント用、もう 1 つはユーザー認証用)、登録済みクライアント、JWT 署名キー、ユーザーの詳細が必要です。
package demo;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.Principal;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import java.util.UUID;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
import org.springframework.security.oauth2.server.authorization.client.InMemoryRegisteredClientRepository;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
import org.springframework.security.oauth2.server.authorization.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration;
import org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers.OAuth2AuthorizationServerConfigurer;
import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
import com.nimbusds.jose.jwk.source.JWKSource;
import com.nimbusds.jose.proc.SecurityContext;
@SpringBootApplication
@RestController
public class AuthserverApplication {
@RequestMapping("/user")
public Principal user(Principal user) {
return user;
}
public static void main(String[] args) {
SpringApplication.run(AuthserverApplication.class, args);
}
@Configuration
public static class SecurityConfig {
@Bean
@Order(1)
public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
.oidc(Customizer.withDefaults());
http.formLogin(Customizer.withDefaults());
return http.build();
}
@Bean
@Order(2)
public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(authorize -> authorize.anyRequest().authenticated())
.formLogin(Customizer.withDefaults());
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
@Bean
public UserDetailsService userDetailsService(PasswordEncoder encoder) {
return new InMemoryUserDetailsManager(
User.withUsername("user").password(encoder.encode("password")).roles("USER").build()
);
}
@Bean
public RegisteredClientRepository registeredClientRepository(PasswordEncoder encoder) {
RegisteredClient client = RegisteredClient.withId(UUID.randomUUID().toString())
.clientId("acme")
.clientSecret(encoder.encode("acmesecret"))
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
.redirectUri("http://localhost:8080/login/oauth2/code/acme")
.scope("openid")
.build();
return new InMemoryRegisteredClientRepository(client);
}
@Bean
public JWKSource<SecurityContext> jwkSource() {
KeyPair keyPair = generateRsaKey();
RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
RSAKey rsaKey = new RSAKey.Builder(publicKey)
.privateKey(privateKey)
.keyID(UUID.randomUUID().toString())
.build();
JWKSet jwkSet = new JWKSet(rsaKey);
return new ImmutableJWKSet<>(jwkSet);
}
private static KeyPair generateRsaKey() {
try {
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
keyPairGenerator.initialize(2048);
return keyPairGenerator.generateKeyPair();
} catch (Exception ex) {
throw new IllegalStateException(ex);
}
}
@Bean
public AuthorizationServerSettings authorizationServerSettings() {
return AuthorizationServerSettings.builder().build();
}
}
}主なコンポーネントは次のとおりです。
2 つの
SecurityFilterChainBean : オーダー 1 は OAuth2 認可サーバーのデフォルトを適用し、OIDC を有効にします。オーダー 2 はフォームベースのユーザー認証を処理します。RegisteredClientRepository: "acme" クライアントを、シークレット、許可型、リダイレクト URI、スコープとともに登録します。JWKSource: JWT トークンに署名するための RSA キーを提供しますUserDetailsService: 認証用のメモリ内ユーザーを定義する
次に、テスト用の予測可能なパスワードを使用して、ポート 9999 で実行します。
server.port: 9999
spring.security.user.password: password
logging.level.org.springframework.security: DEBUG
server.servlet.session.cookie.name: AUTHSESSIONID 認可サーバーのセッション Cookie と UI アプリケーションの JSESSIONID Cookie は両方ともローカルホストで実行されるため、両者が衝突しないように server.servlet.session.cookie.name=AUTHSESSIONID を設定します。 |
それでは、サーバーを実行して、動作していることを確認してみましょう。
$ mvn spring-boot:run または、IDE で main() メソッドを開始します。
認可サーバーのテスト
当社のサーバーはフォームベースのログインを使用しているため、保護されており、ログインページが表示されます。認可コードトークン [IETF] (英語) の付与を開始するには、認可エンドポイント(例: http://localhost:9999/oauth2/authorize?response_type=code&client_id=acme&scope=openid&redirect_uri=http://localhost:8080/login/oauth2/code/acme)にアクセスしてください。認証が完了すると、認可コード(例: ?code=jYWioI)が添付されたリダイレクトが表示されます。
| このサンプルアプリケーションでは、特定のリダイレクト URI を登録したクライアント "acme" を作成しました。本番環境のアプリケーションでは、必ずリダイレクト URI を登録し、HTTPS を使用してください。 |
トークンエンドポイントの "acme" クライアント資格情報を使用して、コードをアクセストークンに交換できます。
$ curl acme:acmesecret@localhost:9999/oauth2/token \
-d grant_type=authorization_code -d client_id=acme \
-d redirect_uri=http://localhost:8080/login/oauth2/code/acme -d code=jYWioI
{"access_token":"eyJra...","token_type":"Bearer","expires_in":299,...}アクセストークンは JWT で、サーバーに設定された RSA 鍵でバックアップされています。また、現在のアクセストークンの有効期限が切れたときに新しいアクセストークンを取得するために使用できるリフレッシュトークンも取得します。
リソースサーバーの変更
パート IV に続いて、リソースサーバーが認証に Spring Session [GitHub] (英語) を使用しているため、これを削除して Spring OAuth2 リソースサーバーに置き換えます。また、Spring Session と Redis の依存関係も削除する必要があるため、以下のコードを置き換えます。
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-redis</artifactId>
</dependency>これとともに:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>次に、JWT 検証を使用するようにセキュリティフィルターチェーンを構成します。
package demo;
import java.util.UUID;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@SpringBootApplication
@RestController
public class ResourceApplication {
@RequestMapping("/")
public Message home() {
return new Message("Hello World");
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(authorize -> authorize
.anyRequest().authenticated())
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()));
return http.build();
}
public static void main(String[] args) {
SpringApplication.run(ResourceApplication.class, args);
}
}
class Message {
private String id = UUID.randomUUID().toString();
private String content;
Message() {
}
public Message(String content) {
this.content = content;
}
public String getId() {
return id;
}
public String getContent() {
return content;
}
}この変更により、アプリは HTTP Basic 認証の代わりにアクセストークンを要求する準備が整いましたが、実際にプロセスを完了するには設定の変更が必要です。リソースサーバーが与えられたトークンを検証できるように、"application.properties" に少し外部設定を追加します。
server.port: 9000
server.address: 127.0.0.1
spring.security.oauth2.resourceserver.jwt.jwk-set-uri: http://localhost:9999/oauth2/jwksこれは、認可サーバーの JWK セットエンドポイントを使用して、JWT 署名を検証するための公開鍵を取得するようにサーバーに指示します。
アプリケーションを実行し、コマンドラインクライアントでホームページにアクセスします。
$ 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
...また、ベアラートークンが必要であることを示す "WWW-Authenticate" ヘッダーを持つ 401 が表示されます。
| JWK Set URI を用いた JWT 検証は、リソースサーバーが公開鍵を用いてローカルでトークンを検証できるため、リクエストごとにネットワーク呼び出しを行う必要がなく、効率的です。鍵はキャッシュされ、定期的にリフレッシュされます。 |
ユーザーエンドポイントの実装
認可サーバーでは、そのエンドポイントを簡単に追加できます。
@SpringBootApplication
@RestController
public class AuthserverApplication {
@RequestMapping("/user")
public Principal user(Principal user) {
return user;
}
...
} パート II の UI サーバーと同じ @RequestMapping を追加しました。
エンドポイントを配置したら、認可サーバーによって作成されたベアラートークンを受け入れるようになったため、それとグリーティングリソースをテストできます。
$ TOKEN=eyJra...
$ curl -H "Authorization: Bearer $TOKEN" localhost:9000
{"id":"03af8be3-2fc3-4d75-acf7-c484d9cf32b1","content":"Hello World"}
$ curl -H "Authorization: Bearer $TOKEN" localhost:9999/user
{"details":...,"principal":{"username":"user",...},"name":"user"}(自分で認証サーバーから取得したアクセストークンの値を代入して、それを自分で動作させます)。
UI サーバー
このアプリケーションの最後の部分は、認証部分を抽出し、認可サーバーに委譲する UI サーバーです。リソースサーバーと同様に、まず Spring Session と Redis への依存関係を削除し、Spring OAuth2 クライアントに置き換える必要があります。また、バックエンドへのリクエストをプロキシするために Spring Cloud Gateway も必要です。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway-server-webmvc</artifactId>
</dependency>これが完了したら、OAuth2 ログインを使用するようにアプリケーションを構成し、セキュリティフィルターチェーンを設定できます。
package demo;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
import org.springframework.web.filter.OncePerRequestFilter;
@SpringBootApplication
public class UiApplication {
public static void main(String[] args) {
SpringApplication.run(UiApplication.class, args);
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
CsrfTokenRequestAttributeHandler requestHandler = new CsrfTokenRequestAttributeHandler();
http
.oauth2Login(Customizer.withDefaults())
.logout(logout -> logout.logoutSuccessUrl("/"))
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/index.html", "/", "/login", "/*.js", "/*.css", "/assets/**", "/favicon.ico").permitAll()
.anyRequest().authenticated())
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.csrfTokenRequestHandler(requestHandler))
.addFilterAfter(new CsrfCookieFilter(), BasicAuthenticationFilter.class);
return http.build();
}
private static final class CsrfCookieFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
CsrfToken csrfToken = (CsrfToken) request.getAttribute(CsrfToken.class.getName());
if (csrfToken != null) {
csrfToken.getToken();
}
filterChain.doFilter(request, response);
}
}
}主な機能は次のとおりです。
oauth2Login(): OAuth2/OIDC ログインを有効にする - Spring Security は認証コードフロー全体を処理します静的リソースが許可される : Angular のコンパイルされたアセット(
.js、.css)は認証なしでアクセス可能でなければならないCSRF 設定 :
CsrfTokenRequestAttributeHandlerを使用して BREACH 保護を無効にし、CsrfCookieFilterを使用して Angular アプリの CSRF トークンを積極的にロードします。
明示的な logout() 構成では、保護されていない成功 URL が明示的に追加されるため、/logout への XHR リクエストが正常に返されます。
OAuth2 クライアントが適切な認可サーバーに接続して認証を行うためには、必須の外部設定も必要です。そのため、application.yml では以下の設定が必要です。
spring:
aop:
proxy-target-class: true
web:
resources:
static-locations: classpath:/static/browser/
security:
oauth2:
client:
registration:
acme:
client-id: acme
client-secret: acmesecret
authorization-grant-type: authorization_code
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
scope: openid
provider:
acme:
authorization-uri: http://localhost:9999/oauth2/authorize
token-uri: http://localhost:9999/oauth2/token
jwk-set-uri: http://localhost:9999/oauth2/jwks
user-info-uri: http://localhost:9999/userinfo
cloud:
gateway:
mvc:
routes:
- id: resource
uri: http://localhost:9000
predicates:
- Path=/resource/**
filters:
- StripPrefix=1
- TokenRelay
- id: user
uri: http://localhost:9999
predicates:
- Path=/user/**
filters:
- TokenRelay
logging:
level:
org.springframework.security: DEBUG
org.springframework.web: DEBUG その大部分は OAuth2 クライアント( "acme" )と認可サーバーの位置に関するものです。ゲートウェイは、設定プロキシ /resource/ をリソースサーバーに、/user/ を認可サーバーにルーティングし、TokenRelay フィルターを使用してアクセストークンを転送します。
クライアント
フロントエンドの UI アプリケーションには、認可サーバーへのリダイレクトをトリガーするためにまだ行う必要がある調整がいくつかあります。この簡単なデモでは、Angular アプリを必要最低限の要素にまとめて、何が起こっているかをより明確に確認できます。そのため、今のところ、フォームやルートの使用を控え、単一の Angular コンポーネントに戻ります。
import { Component, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { finalize } from 'rxjs/operators';
@Component({
selector: 'app-root',
standalone: true,
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent {
private http = inject(HttpClient);
title = 'Demo';
authenticated = false;
greeting: { id?: string; content?: string } = {};
constructor() {
this.authenticate();
}
authenticate() {
this.http.get<{ name?: string }>('user').subscribe({
next: (response) => {
if (response['name']) {
this.authenticated = true;
this.http.get<{ id?: string; content?: string }>('resource').subscribe(data => this.greeting = data);
} else {
this.authenticated = false;
}
},
error: () => {
this.authenticated = false;
}
});
}
logout() {
this.http.post('logout', {}).pipe(
finalize(() => {
this.authenticated = false;
})
).subscribe();
}
}AppComponent はすべてを処理し、ユーザーの詳細と、成功した場合は挨拶を取得します。また、logout 機能も提供します。
次に、この新しいコンポーネントのテンプレートを作成する必要があります。
<div class="container">
<ul class="nav nav-pills">
<li class="nav-item"><a class="nav-link">Home</a></li>
<li class="nav-item"><a class="nav-link" href="login">Login</a></li>
<li class="nav-item"><a class="nav-link" (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 によって処理され、ユーザーが認証されていない場合は、認証サーバーにリダイレクトされます。
仕組みは?
すべてのサーバーを同時に実行し、ブラウザーで http://localhost:8080 の UI にアクセスします。「ログイン」リンクをクリックすると、認可サーバーにリダイレクトされ、認証(フォームログイン)とトークン付与の認可が行われます。その後、UI の認証に使用したのと同じトークンを使用して OAuth2 リソースサーバーから取得した挨拶文を含む UI のホームページにリダイレクトされます。
一部の開発者ツールを使用すると、ブラウザーとバックエンド間の相互作用をブラウザーで確認できます(通常、F12 はこれを開き、デフォルトで Chrome で動作し、Firefox でプラグインが必要になる場合があります)。概要は次のとおりです。
| 動詞 | パス | ステータス | レスポンス |
|---|---|---|---|
GET | / | 200 | index.html |
GET | /*.js | 200 | 角度からのアセット |
GET | /user | 302 | ログインページにリダイレクト |
GET | /login | 302 | 認証サーバーにリダイレクトする |
GET | (認証サーバー)/oauth2/authorize | 302 | ログインフォームにリダイレクト |
GET | (認証サーバー)/login | 200 | ログインフォーム |
POST | (認証サーバー)/login | 302 | 承認にリダイレクト |
GET | (認証サーバー)/oauth2/authorize | 302 | ユーザーは許可を承認し、/login/oauth2/code/acme にリダイレクトします |
GET | /login/oauth2/code/acme | 302 | コードをトークンに交換し、ホームページにリダイレクトします |
GET | /user | 200 | (プロキシ)JSON 認証済みユーザー |
GET | /resource | 200 | (プロキシ)JSON グリーティング |
(authserver) で始まるリクエストは認可サーバーへのリクエストです。Spring Cloud Gateway の TokenRelay フィルターは、プロキシされたバックエンドへのアクセストークンの転送を処理します。
| 認証のクロスオーバーが発生しないように、テストには別のブラウザーを使用するようにしてください (たとえば、UI のテストに Chrome を使用した場合は Firefox を使用します)。 |
ログアウトエクスペリエンス
「ログアウト」リンクをクリックすると、ユーザーが UI サーバーで認証されなくなるため、ホームページが変更される(グリーティングが表示されなくなる)ことがわかります。「ログイン」をクリックしますが、実際には認証サーバーで認証と認可のサイクルに戻る必要はありません(ログアウトしていないため)。それが望ましいユーザーエクスペリエンスであるかどうかについて意見は分かれますが、悪名高いトリッキーな問題です(シングルサインアウト: Science Direct の記事 (英語) および Shibboleth のドキュメント (英語) )。理想的なユーザーエクスペリエンスは技術的に実現可能ではないかもしれません。また、ユーザーが望むことを本当に望んでいることを疑わなければならないこともあります。「「ログアウト」してログアウトしてほしい」というのは簡単ですが、「何からログアウトしますか? この SSO サーバーによって制御されているすべてのシステムからログアウトしますか、それともあなただけのシステムからログアウトしますか?」 「ログアウト」リンクをクリックしましたか?」興味がある場合は、このチュートリアルの後のセクションで詳細に説明されています。
結論
Spring Security と Angular スタックの簡単な解説はこれでほぼ終わりです。UI/API ゲートウェイ、リソースサーバー、認可サーバー / トークングランターという 3 つの独立したコンポーネントに明確なロールが割り当てられた、優れたアーキテクチャが完成しました。すべてのレイヤーにおける非ビジネスコードの量は最小限に抑えられ、ビジネスロジックを追加することで実装を継承・改善できる箇所が容易に把握できるようになりました。次のステップは、認可サーバーの UI を整理し、JavaScript クライアントを含むいくつかのテストを追加することです。もう一つの興味深いタスクは、すべてのボイラープレートコードを抽出し、Spring Security と Spring Session の自動構成と、Angular のナビゲーションコントローラー用の Webjar リソースを含むライブラリ(例: "spring-security-angular")に配置することです。このシリーズのセクションを読んだ後、Angular または Spring Security の内部の仕組みを知りたいと思っていた人はおそらくがっかりするでしょう。しかし、それらがどのように連携して機能するか、少しの設定でどれだけの効果が得られるかを知りたかった人にとっては、良い経験になったことでしょう。
補遺: 認可サーバーのブートストラップ UI および JWT トークン
このアプリケーションの別のバージョンは、Github のソースコードにあります。Github のソースコードには、パート II (英語) のログインページと同じように、きれいなログインページとユーザー承認ページが実装されています。また、JWT (英語) を使用してトークンをエンコードするため、"/user" エンドポイントを使用する代わりに、リソースサーバーはトークン自体から十分な情報をプルして単純な認証を行うことができます。ブラウザークライアントは引き続き UI サーバーを介してプロキシされてそれを使用するため、ユーザーが認証されているかどうかを判断できます(実際のアプリケーションでのリソースサーバーへの呼び出しの可能性と比較して、それほど頻繁に行う必要はありません))。
複数の UI アプリケーションとゲートウェイ
このセクションでは、「シングルページアプリケーション」で Spring Security と Angular (英語) を使用する方法について引き続き説明します。ここでは、Spring Session と Spring Cloud Gateway を組み合わせて、パート II とパート IV で構築したシステムの機能を組み合わせ、実際には全く異なるロールを持つ 3 つのシングルページアプリケーションを構築する方法を示します。ゴールは、API リソースだけでなく、バックエンドサーバーから UI をロードするためにも使用されるゲートウェイ(パート IV と同様)を構築することです。パート II のトークン処理部分を簡素化するため、ゲートウェイを使用して認証をバックエンドにパススルーします。次に、システムを継承して、ゲートウェイで ID と認証を制御しながら、バックエンドでローカルかつきめ細かなアクセス決定を行う方法を示します。これは、一般的に分散システムを構築するための非常に強力なモデルであり、構築するコードに機能を導入するにつれて、多くの利点を探求することができます。
リマインダー: このセクションでサンプルアプリケーションを使用している場合は、ブラウザーのキャッシュの Cookie と HTTP 基本認証情報を必ずクリアしてください。Chrome でこれを行う最良の方法は、新しいシークレットウィンドウを開くことです。
ターゲットアーキテクチャ
以下は、最初に構築する基本システムの図です。

このシリーズの他のサンプルアプリケーションと同様に、UI(HTML および JavaScript)とリソースサーバーがあります。セクション IV のサンプルと同様に、ゲートウェイがありますが、ここでは UI の一部ではなく、別個のものです。UI は事実上バックエンドの一部になり、機能を再構成および再実装するための選択肢がさらに広がります。また、これから説明する他の利点ももたらします。
ブラウザーはすべてのためにゲートウェイにアクセスし、バックエンドのアーキテクチャを知る必要はありません(基本的に、バックエンドがあることを知りません)。このゲートウェイでブラウザーが行うことの 1 つに認証があります。セクション II のようにユーザー名とパスワードを送信し、代わりに Cookie を取得します。後続のリクエストでは、Cookie が自動的に提示され、ゲートウェイはそれをバックエンドに渡します。Cookie の受け渡しを有効にするために、クライアントでコードを記述する必要はありません。バックエンドは Cookie を使用して認証し、すべてのコンポーネントがセッションを共有するため、ユーザーに関する同じ情報を共有します。これと比較して、ゲートウェイで Cookie をアクセストークンに変換する必要があり、アクセストークンはすべてのバックエンドコンポーネントで個別にデコードする必要があるセクション V と比較してください。
セクション IV と同様に、ゲートウェイはクライアントとサーバー間の対話を簡素化し、セキュリティを処理するための小さく明確に定義された表面を提供します。例: クロスオリジンリソース共有 [Mozilla] について心配する必要はありません。これは間違いを犯しやすいため、歓迎されます。
ビルドするプロジェクト全体のソースコードはここの Github (英語) にあるため、必要に応じてプロジェクトを複製し、そこから直接作業することができます。このシステムの終了状態には追加のコンポーネント("double-admin")があるため、今は無視してください。
共有セッション認証の仕組み
コードの詳細に入る前に、重要なアーキテクチャ概念である共有セッション認証を理解しましょう。
ゲートウェイはユーザーを認証し (この例では HTTP Basic 経由)、認証された
SecurityContextを Redis ベースのセッションに保存します。セッション ID を含むセッションクッキーがブラウザーに送信されます。
ブラウザーがゲートウェイを介してバックエンドサービスにリクエストを送信すると、SESSION Cookie が転送されます。
バックエンドサービスは、同じ Redis インスタンスを持つ Spring Session を使用して共有セッションを検索し、すでに認証されているユーザーを見つけます。
資格情報はバックエンドに転送されず、共有セッションから
SecurityContextが読み取られるだけです。
このアプローチには、Spring Security 6 での特定の構成が必要です。これについては以下で説明します。
前提条件
セッションを保存するには、Redis をローカルで実行する必要があります。
docker run -p 6379:6379 redisゲートウェイ
ゲートウェイは、バックエンドサービスへのリクエストをプロキシし、認証を処理する、Spring Cloud Gateway MVC を備えた Spring Boot アプリケーションです。
依存関係
ゲートウェイには次の主要な依存関係が必要です。
spring-boot-starter-web- サーブレットベースの Web アプリケーション用spring-cloud-starter-gateway-server-webmvc- ルーティング / プロキシ用spring-boot-starter-security- 認証用spring-session-data-redis- 共有セッション用
ルート構成
ルートは、Spring Cloud Gateway MVC プロパティを使用して application.yml で構成されます。
spring:
session:
store-type: redis
cloud:
gateway:
mvc:
routes:
- id: ui
uri: http://localhost:8081
predicates:
- Path=/ui/**
filters:
- StripPrefix=1
- id: admin
uri: http://localhost:8082
predicates:
- Path=/admin/**
filters:
- StripPrefix=1
- id: resource
uri: http://localhost:9000
predicates:
- Path=/resource/**
filters:
- StripPrefix=1 プロキシには 3 つのルートがあり、UI、管理サーバー、リソースサーバーそれぞれに 1 つずつあります。StripPrefix=1 フィルターは最初のパスセグメントを削除します(例: /ui/user は UI バックエンドに転送されると /user になります)。
セキュリティ構成
ゲートウェイのセキュリティ構成は、Angular SPA の認証と CSRF を処理します。
@Configuration
protected static class SecurityConfiguration {
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
@Bean
public UserDetailsService userDetailsService(PasswordEncoder encoder) {
InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager();
manager.createUser(User.withUsername("user").password(encoder.encode("password")).roles("USER").build());
manager.createUser(User.withUsername("admin").password(encoder.encode("admin")).roles("USER", "ADMIN", "READER", "WRITER").build());
manager.createUser(User.withUsername("audit").password(encoder.encode("audit")).roles("USER", "ADMIN", "READER").build());
return manager;
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.httpBasic((basic) -> basic
.securityContextRepository(new HttpSessionSecurityContextRepository())
)
.logout(Customizer.withDefaults())
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/index.html", "/", "/*.js", "/*.css", "/*.ico", "/*.txt", "/*.json").permitAll()
.anyRequest().authenticated())
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler()))
.addFilterAfter(new CsrfCookieFilter(), CsrfFilter.class);
return http.build();
}
}要点:
HttpSessionSecurityContextRepository: Spring Security 6 では、SecurityContextはセッションに自動的に保存されなくなりました。認証されたユーザーが Redis ベースのセッションに保存されるように、明示的に設定する必要があります。CsrfTokenRequestAttributeHandler: CSRF トークンの BREACH 保護を無効にして、Angular アプリが Cookie から直接トークンを読み取ることができるようにします。CsrfCookieFilter: Spring Security 6 では、CSRF トークンは遅延読み込みされます。このフィルターはトークンを先行読み込みし、すべてのレスポンスで Cookie に書き込みます。
CsrfCookieFilter は、CSRF トークンを強制的にロードする単純なフィルターです。
/**
* Filter that eagerly loads the CSRF token, causing it to be written to the cookie.
* Required for SPAs in Spring Security 6 where the token is lazily loaded by default.
*/
final class CsrfCookieFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
CsrfToken csrfToken = (CsrfToken) request.getAttribute(CsrfToken.class.getName());
if (csrfToken != null) {
// Render the token value to a cookie by causing the deferred token to be loaded
csrfToken.getToken();
}
filterChain.doFilter(request, response);
}
}ユーザーアカウント
このサンプルでは、ユーザーアカウントはゲートウェイのメモリ内に定義されます。
public UserDetailsService userDetailsService(PasswordEncoder encoder) {
InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager();
manager.createUser(User.withUsername("user").password(encoder.encode("password")).roles("USER").build());
manager.createUser(User.withUsername("admin").password(encoder.encode("admin")).roles("USER", "ADMIN", "READER", "WRITER").build());
manager.createUser(User.withUsername("audit").password(encoder.encode("audit")).roles("USER", "ADMIN", "READER").build());
return manager;
}| 本番システムでは、ユーザーアカウントデータは、Spring 構成にハードコードされるのではなく、バックエンドデータベース (おそらくディレクトリサービス) で管理されます。 |
/user エンドポイント
ゲートウェイは、認証されたユーザーの名前とロールを返す /user エンドポイントを公開します。
@RequestMapping("/user")
@ResponseBody
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;
}UI バックエンド
UI バックエンドは、Angular SPA を提供し、/user エンドポイントを提供するシンプルな Spring Boot アプリケーションです。
セキュリティ構成
UI バックエンドはユーザー認証を行いません。代わりに、共有 Redis セッションから認証済みユーザーを読み取ります。
@Configuration
protected static class SecurityConfiguration {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.NEVER))
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/", "/index.html").permitAll()
.anyRequest().hasRole("USER")
);
return http.build();
}
}要点:
httpBasic()なし : UI は認証を行わず、ゲートウェイに依存します。SessionCreationPolicy.NEVER: UI はセッションを作成することはなく、Redis から既存のセッションを読み取るだけです。
アプリケーション構成
server:
port: 8081
spring:
session:
store-type: redis
web:
resources:
static-locations: classpath:/static/browser/
logging:
level:
org.springframework.security: DEBUGspring.session.store-type: redis は必須です。これは、Spring Session にゲートウェイと同じ Redis インスタンスを使用するように指示します。
Angular アプリケーション
UI の Angular アプリケーションは、ユーザーが認証されているかどうかを確認し、リソースサーバーから挨拶を取得します。
import { Component, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
@Component({
selector: 'app-root',
standalone: true,
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent {
private http = inject(HttpClient);
title = 'Demo';
greeting: { id?: string; content?: string } = {};
authenticated = false;
user = '';
constructor() {
this.http.get<{ name?: string }>('/user').subscribe({
next: (data) => {
if (data['name']) {
this.authenticated = true;
this.user = data['name'];
this.http.get<{ id?: string; content?: string }>('/resource').subscribe(response => this.greeting = response);
} else {
this.authenticated = false;
}
},
error: () => {
this.authenticated = false;
}
});
}
}リソースサーバー
リソースサーバーは API エンドポイントを提供し、共有セッションから認証を読み取ります。
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// No httpBasic - rely on shared session from Gateway
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.NEVER))
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler())
)
.authorizeHttpRequests(authorize -> authorize
.requestMatchers(HttpMethod.POST, "/**").hasRole("WRITER")
.anyRequest().authenticated());
return http.build();
} リソースサーバーは SessionCreationPolicy.NEVER を使用し、認証には共有セッションに依存します。
稼働
現在、3 つのポートで 3 つのコンポーネントが実行されています。
ゲートウェイ: http://localhost:8080
UI: http://localhost:8081 (/ui/ のゲートウェイ経由でアクセス)
リソース: http://localhost:9000 (/resource/ のゲートウェイ経由でアクセス)
Redis と 3 つのアプリケーションをすべて起動し、ブラウザーで http://localhost:8080 にアクセスしてください。ログインフォームが表示されます。"user/password" で認証すると、UI インターフェースへのリンクが表示されます。
| 動詞 | パス | ステータス | レスポンス |
|---|---|---|---|
GET | / | 200 | ログインフォームのあるゲートウェイホームページ |
POST | /login | 302 | 認証してリダイレクトする |
GET | /ui/ | 200 | UI Angular アプリ (ポート 8081 からプロキシ) |
GET | /ui/user | 200 | 認証されたユーザー情報 |
GET | /resource/ | 200 | JSON グリーティング (ポート 9000 からプロキシ) |
ゲートウェイ Angular アプリケーション
ゲートウェイには、ログインフォームとバックエンド UI へのナビゲーションを提供する独自の Angular アプリケーションがあります。
import { Component, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { HttpClient, HttpHeaders } from '@angular/common/http';
@Component({
selector: 'app-root',
standalone: true,
imports: [FormsModule],
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent {
private http = inject(HttpClient);
admin = false;
user = '';
title = 'Demo';
credentials = { username: '', password: '' };
authenticated = false;
error = false;
constructor() {
this.login();
}
login() {
const headers = this.credentials.username
? new HttpHeaders().set('authorization', 'Basic ' + btoa(this.credentials.username + ':' + this.credentials.password))
: new HttpHeaders();
this.http.get<{ name?: string; roles?: string[] }>('user', { headers }).subscribe({
next: (data) => {
this.authenticated = !!(data && data['name']);
this.user = this.authenticated ? data['name'] || '' : '';
this.admin = this.authenticated && !!data['roles'] && data['roles'].indexOf('ROLE_ADMIN') > -1;
this.error = false;
},
error: () => {
this.authenticated = false;
this.admin = false;
this.error = true;
}
});
return false;
}
logout() {
this.http.post('logout', {}).subscribe({
next: () => {
this.authenticated = false;
this.admin = false;
}
});
}
}<nav class="navbar navbar-light bg-light">
<div class="container">
<a class="navbar-brand" href='#/'>Home</a>
<div class="ml-auto">
<a class="btn btn-primary" href="#/login">login</a> <a
class="btn btn-primary" [hidden]="!authenticated"
(click)="logout(); $event.preventDefault()">logout</a>
<span [hidden]="!authenticated">
Signed in as <a href="#/">{{user}}</a>
</span>
</div>
</div>
</nav>
<div class="alert alert-danger" [hidden]="!error">There was a
problem logging in. Please try again.</div>
<div class="container" [hidden]="authenticated">
<form role="form" (submit)="login()">
<div class="form-group">
<label for="username">Username:</label> <input type="text"
class="form-control" id="username" name="username"
[(ngModel)]="credentials.username" />
</div>
<div class="form-group">
<label for="password">Password:</label> <input type="password"
class="form-control" id="password" name="password"
[(ngModel)]="credentials.password" />
</div>
<button type="submit" class="btn btn-primary">Submit</button>
</form>
</div>
<div class="container" [hidden]="!authenticated">
<a class="btn btn-primary" href="/ui/">Go To User Interface</a>
</div>
<br />
<div class="container" [hidden]="!authenticated || !admin">
<a class="btn btn-primary" href="/admin/">Go To Admin Interface</a>
</div>login() 関数は HTTP 基本認証を介して資格情報を送信します。成功すると、ゲートウェイはセッションを作成し、後続のリクエストでは SESSION Cookie を使用します。
バックエンドでのきめ細かいアクセス決定
次に、"ADMIN" ロールを必要とする管理アプリケーションを追加しましょう。

管理者セキュリティ構成
管理アプリケーションでは、次のように "ADMIN" ロールが必要です。
@Configuration
protected static class SecurityConfiguration {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.NEVER))
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/", "/index.html").permitAll()
.anyRequest().hasRole("ADMIN")
)
.csrf(csrf -> csrf.disable());
return http.build();
}
}ロールを持つ /user エンドポイント
管理アプリケーションの /user エンドポイントはロールを返すため、Angular アプリはクライアント側のアクセス決定を行うことができます。
@GetMapping("/user")
@ResponseBody
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;
}| ロール名は "/user" エンドポイントから "ROLE_" プレフィックス付きで返されるため、他の種類の権限と区別できます (これは Spring Security のものです)。 |
どうしてここに?
今では、2 つの独立したユーザーインターフェースとバックエンドのリソースサーバーを備えた、優れた小規模システムが実現しています。これらはすべて、ゲートウェイ内の同一の認証によって保護されています。ゲートウェイがマイクロプロキシとして機能するため、バックエンドのセキュリティ対策の実装は非常にシンプルになり、開発者は自身のビジネス課題に集中できます。Spring Session の使用により、(再び)膨大な手間と潜在的なエラーを回避できました。
強力な機能の一つは、バックエンドがそれぞれ独立して任意の認証方法を持つことができることです(たとえば、物理アドレスとローカル認証情報のセットが分かれば、UI に直接アクセスできます)。ゲートウェイは、ユーザーを認証し、バックエンドのアクセスルールを満たすメタデータを割り当てることができる限り、全く関係のない一連の制約を課します。これは、バックエンドコンポーネントを独立して開発およびテストできる優れた設計です。
このアーキテクチャ(認証を制御する単一のゲートウェイと、全コンポーネント間で共有されるセッショントークン)のボーナス機能として、「シングルログアウト」が無償で提供されます。これは、セクション V では実装が困難だと判断した機能です。より正確に言うと、シングルログアウトのユーザーエクスペリエンスに対する特定のアプローチが、完成したシステムで自動的に利用可能になります。つまり、ユーザーがいずれかの UI(ゲートウェイ、UI バックエンド、または管理バックエンド)からログアウトすると、他のすべての UI からもログアウトされます。ただし、各 UI に同じ方法で「ログアウト」機能が実装されている(セッションが無効化される)ことが前提となります。
Spring Security 6 移行ノート
このチュートリアルの以前のバージョンから移行する場合、Spring Security 6 の主な変更点は次のとおりです。
SecurityContext は自動的に保存されません : 認証メカニズムで
HttpSessionSecurityContextRepositoryを明示的に構成する必要があります。CSRF トークンは遅延ロードされる : SPA は、
CsrfCookieFilterなどのフィルターを介してトークンの読み込みをトリガーする必要があります。侵害保護はデフォルトで有効になっています : SPA が CSRF Cookie を直接読み取る場合は、
XorCsrfTokenRequestAttributeHandlerではなくCsrfTokenRequestAttributeHandlerを使用します。WebSecurityConfigurerAdapterは削除されました : 代わりにSecurityFilterChainBean を使用してください。security.sessionsプロパティは削除されました :SecurityFilterChain構成でSessionCreationPolicyを使用します。
Angular アプリケーションのテスト
このセクションでは、「シングルページアプリケーション」で Spring Security を Angular (英語) と使用する方法について引き続き説明します。ここでは、Angular テストフレームワークを使用して、クライアント側コードの単体テストを作成および実行する方法を示します。アプリケーションの基本的な構成要素に追いつくか、最初のセクションを読んでゼロからビルドするか、Github のソースコードに直接 (英語) 進むことができます(パート I と同じソースコードですが、テストが追加されています)。このセクションには、実際には Spring または Spring Security を使用したコードはほとんどありませんが、通常の Angular コミュニティリソースでは簡単に見つけられない方法でクライアント側のテストを扱います。Spring ユーザー。
リマインダー: このセクションでサンプルアプリケーションを使用している場合は、ブラウザーのキャッシュの Cookie と HTTP 基本認証情報を必ずクリアしてください。Chrome では、単一サーバーでこれを行う最良の方法は、新しいシークレットウィンドウを開くことです。
仕様を書く
「基本」アプリケーションの「アプリ」コンポーネントは非常にシンプルなので、徹底的にテストするのにそれほど時間はかかりません。コードのリマインダーは次のとおりです。
include::basic/src/app/app.component.ts 直面する主な課題は、テストで http オブジェクトを提供することです。そのため、コンポーネントでの http オブジェクトの使用メソッドについてアサーションを作成できます。実際、その課題に直面する前であっても、コンポーネントインスタンスを作成できる必要があります。そのため、ロード時に何が起こるかをテストできます。以下にそのメソッドを示します。
ng new から作成されたアプリの Angular ビルドには、すでに仕様とそれを実行するための構成があります。生成された仕様は "src/app" にあり、次のように始まります。
import { TestBed, async } from '@angular/core/testing';
import { AppComponent } from './app.component';
describe('AppComponent', () => {
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [],
declarations: [
AppComponent
]
}).compileComponents();
}));
it('should create the app', async(() => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.debugElement.componentInstance;
expect(app).toBeTruthy();
}));
...
}この非常に基本的なテストスイートには、次の重要な要素があります。
関数を使用して、テスト対象の
describe()(この場合は "AppComponent" )。その関数内で、Angular コンポーネントをロードする
beforeEach()コールバックを提供します。振る舞いは
it()の呼び出しを通じて表現されます。ここでは、期待が何であるかを言葉で表明し、アサーションを行う関数を提供します。テスト環境は、他の何かが発生する前に初期化されます。これは、ほとんどの Angular アプリの定型です。
ここでのテスト関数は非常に単純なため、実際にはコンポーネントが存在することをアサートするだけなので、それが失敗するとテストは失敗します。
単体テストの改善: HTTP バックエンドのモック
仕様を製品グレードに改善するには、コントローラーのロード時に何が起こるかについて実際にアサートする必要があります。http.get() を呼び出すため、単体テストのためだけにアプリケーション全体を実行する必要がないように、その呼び出しをモックする必要があります。そのためには、Angular HttpClientTestingModule を使用します。
Unresolved directive in testing.adoc - include::basic/src/app/app.component.spec[indent=0]ここでの新しい部分は次のとおりです。
beforeEach()のTestBedのインポートとしてのHttpClientTestingModuleの宣言。テスト関数では、コンポーネントを作成する前にバックエンドに期待値を設定し、"resource/" への呼び出しを期待するように伝え、レスポンスをどうするかを伝えます。
仕様の実行
「テストを実行する」コードは、プロジェクトのセットアップ時に作成された便利なスクリプトを使用して ./ng test (または ./ng build)を実行できます。これは Maven ライフサイクルの一部としても実行されるため、./mvnw install はテストを実行する良い方法でもあります。CI ビルドで何が起こるかです。
エンドツーエンドのテスト
Angular には、ブラウザーと生成された JavaScript を使用した「エンドツーエンドテスト」用にセットアップされた標準ビルドもあります。これらは、最上位の e2e ディレクトリに「仕様」として書き込まれます。このチュートリアルのすべてのサンプルには、Maven ライフサイクルで実行される非常に単純なエンドツーエンドテストが含まれています(したがって、"ui" アプリで mvn install を実行すると、ブラウザーウィンドウがポップアップ表示されます)。
結論
Javascript の単体テストを実行できることは、最新の Web アプリケーションでは重要であり、このシリーズではこれまで無視(または回避)したトピックです。今回の記事では、テストを記述する方法、開発時にテストを実行する方法、さらに重要なこととして、継続的インテグレーション設定の基本的な要素を紹介しました。取ったアプローチはすべての人に適しているわけではないため、別の方法でやるのを気にしないでください。ここで行った方法は、おそらく従来の Java エンタープライズ開発者にとって快適であり、既存のツールやプロセスとうまく統合できるため、そのカテゴリにいるなら、出発点として役立つと思います。Angular および Jasmine を使用したテストのその他の例は、インターネット上の多くの場所で見つけることができますが、最初のコールポイントはこのシリーズの「単一」サンプル [GitHub] (英語) である可能性があります。このチュートリアルの「基本」サンプル用に記述する必要があるコード。
OAuth2 クライアントアプリケーションからのログアウト
このセクションでは、「シングルページアプリケーション」で Spring Security を Angular (英語) と使用する方法について引き続き説明します。ここでは、OAuth2 サンプルを取得し、別のログアウトエクスペリエンスを追加する方法を示します。OAuth2 シングルサインオンを実装する多くの人々は、「きれいに」ログアウトする方法を解決するためのパズルがあることに気付いていますか? それがパズルである理由は、それを行うための単一の正しい方法がないことであり、選択する解決策は、探しているユーザーエクスペリエンスと、引き受けたい複雑さの量によって決定されます。複雑さの理由は、システム内に潜在的に複数のブラウザーセッションがあり、すべてが異なるバックエンドサーバーであるという事実に起因するため、ユーザーがそのうちの 1 つからログアウトすると、他のユーザーはどうなるでしょうか? これはチュートリアルの 9 番目のセクションであり、アプリケーションの基本的な構成要素に追いつくか、最初のセクションを読んでゼロからビルドするか、Github のソースコードに直接 (英語) 進むことができます。
ログアウトパターン
このチュートリアルの oauth2 サンプルからログアウトする際のユーザーエクスペリエンスは、UI アプリからはログアウトしますが、認証サーバーからはログアウトしないというものです。そのため、UI アプリに再度ログインしても、認証サーバーは資格情報の再入力を求めません。これは、認証サーバーが外部にある場合、完全に想定された正常な動作であり、望ましいものです。Google やその他の外部認証サーバープロバイダーは、信頼できないアプリケーションからサーバーからログアウトすることを望まず、許可もしません。しかし、認証サーバーが実際に UI と同じシステムの一部である場合、これは最良のユーザーエクスペリエンスとは言えません。
大まかに言えば、OAuth2 クライアントとして認証された UI アプリからログアウトするための 3 つのパターンがあります。
外部認証サーバー (EA、オリジナルのサンプル)。ユーザーは認証サーバーをサードパーティとして認識します (たとえば、認証に Facebook または Google を使用します)。アプリセッションの終了時に認証サーバーからログアウトしたくありません。すべての補助金の承認が必要です。このチュートリアルの
oauth2(およびoauth2-vanilla) サンプルは、このパターンを実装しています。ゲートウェイおよび内部認証サーバー(GIA)。ログアウトする必要があるのは 2 つのアプリのみであり、それらはユーザーが認識する同じシステムの一部です。通常、すべての権限付与を自動承認する必要があります。
シングルログアウト(SL)。1 つの認証サーバーと複数の UI アプリはすべて独自の認証を備えており、ユーザーが 1 つからログアウトすると、すべてのアプリがそれに追従する必要があります。ネットワークパーティションとサーバーの障害のために、単純な実装で失敗する可能性が高い - 基本的には、グローバルに一貫したストレージが必要です。
外部認証サーバーを使用している場合でも、認証を制御し、アクセス制御の内部レイヤー(認証サーバーがサポートしていないスコープやロールなど)を追加したい場合があります。次に、認証に EA を使用することをお勧めしますが、必要な追加の詳細をトークンに追加できる内部認証サーバーを用意します。この他の OAuth2 チュートリアル [GitHub] (英語) の auth-server サンプルは、非常に簡単な方法でそれを行う方法を示しています。その後、内部認証サーバーを含むシステムに GIA または SL パターンを適用できます。
EA が必要ない場合のオプションは次のとおりです。
認証サーバーとブラウザークライアントの UI アプリからログアウトします。シンプルなアプローチで、CSRF と CORS の設定を慎重に行うことで動作します。SL は不要です。
トークンが利用可能になったらすぐに認証サーバーからログアウトします。トークンを取得する UI に実装するのは困難です。認証サーバーのセッション Cookie がそこに存在しないためです。興味深いアプローチとしては、認証コードが生成されたらすぐに認証サーバーでセッションを無効にするというものがあります。SL は不要です。
UI と同じゲートウェイを介して認証サーバーをプロキシし、1 つの Cookie でシステム全体の状態を管理するのに十分であることを望みます。共有セッションが存在しない限り機能しません。共有セッションは、オブジェクトをある程度無効にします(それ以外の場合、authserver のセッションストレージはありません)。セッションがすべてのアプリ間で共有される場合にのみ SL。
ゲートウェイの Cookie リレー。認証の真のソースとしてゲートウェイを使用しています。認証サーバーは、ブラウザーではなく Cookie を管理するため、必要なすべての状態を保持しています。ブラウザーに複数のサーバーからの Cookie が含まれることはありません。SL なし
トークンをグローバル認証として使用し、ユーザーが UI アプリからログアウトするときにトークンを無効にします。欠点: クライアントアプリによってトークンを無効にする必要がありますが、これは実際には意図されたものではありません。SL は可能ですが、通常の制約が適用されます。
authserver で(ユーザートークンに加えて)グローバルセッショントークンを作成および管理します。これは OpenId 接続 (英語) が採用したアプローチであり、SL にいくつかのオプションを提供しますが、いくつかの追加の機械が必要になります。通常の分散システムの制限から影響を受けるオプションはありません。ネットワークとアプリケーションノードが安定していない場合、必要に応じてすべての参加者間でログアウトシグナルが共有されるという保証はありません。ログアウト仕様はすべてドラフト形式のままであり、仕様へのリンクは次のとおりです: セッション管理 (英語) 、フロントチャンネルログアウト (英語) 、バックチャンネルログアウト (英語) 。
SL が困難または不可能な場合、すべての UI を単一のゲートウェイの背後に配置する方がよい場合があることに注意してください。次に、より簡単な GIA を使用して、不動産全体からのログアウトを制御できます。
GIA パターンにうまく当てはまる最も簡単な 2 つのオプションは、チュートリアルサンプルで次のように実装できます(oauth2 サンプルを取得して、そこから作業します)。
ブラウザーからの両方のサーバーのログアウト
UI アプリがログアウトするとすぐに認証サーバーからログアウトするコードをブラウザークライアントに数行追加するのは非常に簡単です。
import { Component, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { finalize } from 'rxjs/operators';
@Component({
selector: 'app-root',
standalone: true,
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent {
private http = inject(HttpClient);
title = 'Demo';
authenticated = false;
greeting: { id?: string; content?: string } = {};
constructor() {
this.authenticate();
}
authenticate() {
this.http.get<{ name?: string }>('user').subscribe({
next: (response) => {
if (response['name']) {
this.authenticated = true;
this.http.get<{ id?: string; content?: string }>('resource').subscribe(data => this.greeting = data);
} else {
this.authenticated = false;
}
},
error: () => {
this.authenticated = false;
}
});
}
logout() {
this.http.post('logout', {}).pipe(
finalize(() => {
this.authenticated = false;
this.http.post('http://localhost:9999/logout', {}, {})
.subscribe(() => {
console.log('Logged out');
});
})
).subscribe();
}
} このサンプルでは、authserver ログアウトエンドポイント URL を TypeScript にハードコードしましたが、必要に応じて外部化するのは簡単です。セッション Cookie も一緒に送信するため、authserver に直接 POST する必要があります。XHR リクエストは、withCredentials:true を明確にリクエストした場合にのみ、Cookie が添付されたブラウザーから送信されます。
一方、サーバー側では、リクエストが異なるドメインから送信されるため、CORS 設定が必要になります。SecurityFilterChain では、次のようになります。
)
*/;
http.formLogin(Customizer.withDefaults());
return http.build();
}
@Bean
@Order(2)
public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
http
.cors(cors -> cors.configurationSource(configurationSource()))
.authorizeHttpRequests(authorize -> authorize.anyRequest().authenticated())
.formLogin(Customizer.withDefaults())
.oauth2ResourceServer((oauth2) -> oauth2.jwt(Customizer.withDefaults()))
.csrf((csrf) -> csrf.ignoringRequestMatchers("/logout/**"));
return http.build();
}
private CorsConfigurationSource configurationSource() {
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
CorsConfiguration config = new CorsConfiguration();
config.addAllowedOrigin("*");"/logout" エンドポイントには特別な処理が施されています。任意のオリジンからの呼び出しが許可され、資格情報(Cookie など)の送信が明示的に許可されています。許可されるヘッダーは、サンプルアプリで Angular が送信するヘッダーのみです。
CORS 設定に加えて、ログアウトエンドポイントの CSRF も無効化する必要があります。これは、Angular がクロスドメインリクエストで X-XSRF-TOKEN ヘッダーを送信しないためです。これは、上記に示したセキュリティフィルターチェーン設定の .csrfcsrf) → csrf.ignoringRequestMatchers("/logout/**" を使用して行われます。
| CSRF 保護をドロップすることは実際にはお勧めできませんが、この制限されたユースケースでは許容する準備ができているかもしれません。 |
UI アプリクライアントと authserver の 2 つの簡単な変更により、UI アプリからログアウトすると、再度ログインすると、常にパスワードの入力が求められることがわかります。
もう一つの便利な変更は、OAuth2 クライアントを自動承認に設定することです。これにより、ユーザーはトークン付与を承認する必要がなくなります。これは、ユーザーが別のシステムとして認識していない内部認証サーバーでよく使用されます。RegisteredClientRepository では、クライアントの初期化時にフラグを設定するだけで済みます。
RegisteredClient client = RegisteredClient.withId(UUID.randomUUID().toString())
.clientId("acme")
...
.clientSettings(ClientSettings.builder().requireAuthorizationConsent(false).build())
.build();認証サーバーのセッションを無効化
ログアウトエンドポイントでの CSRF 対策を諦めたくない場合は、別の簡単な方法を試すことができます。トークンが付与された直後(実際には認証コードが生成された直後)に、認証サーバーでユーザーセッションを無効化する方法です。これも Spring Authorization Server を使えば比較的簡単に実装できます。認可エンドポイントの成功ハンドラーをカスタマイズするだけです。サンプルコードにはこの実装がコメントとして含まれていますが、コメントを解除することでこの動作を有効化できます。
// uncomment to invalidate session after issuing authorization code
/*
.authorizationEndpoint((authorize) -> authorize
.authorizationResponseHandler(new SessionInvalidatingSuccessHandler())
)
*/;// uncomment to invalidate session after issuing authorization code
/*
private static class SessionInvalidatingSuccessHandler implements AuthenticationSuccessHandler {
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {
OAuth2AuthorizationCodeRequestAuthenticationToken authCodeRequestAuth =
(OAuth2AuthorizationCodeRequestAuthenticationToken) authentication;
// Build the redirect URI with the authorization code
UriComponentsBuilder uriBuilder = UriComponentsBuilder
.fromUriString(authCodeRequestAuth.getRedirectUri())
.queryParam("code", authCodeRequestAuth.getAuthorizationCode().getTokenValue());
if (authCodeRequestAuth.getState() != null) {
uriBuilder.queryParam("state", authCodeRequestAuth.getState());
}
String redirectUri = uriBuilder.build().toUriString();
// Send the redirect
response.sendRedirect(redirectUri);
// Invalidate the session after the redirect is sent
HttpSession session = request.getSession(false);
if (session != null) {
session.invalidate();
}
}
}
*/この成功ハンドラーは、認可コードが発行され、ユーザーがクライアントにリダイレクトされる際に起動します。リダイレクト URI を構築し、レスポンスを送信し、セッションを無効化します。
このアプローチでは、認証が完了すると認証サーバーのセッションはすでに切断されているため、クライアントからセッションを管理する必要はありません。UI アプリからログアウトして再度ログインすると、認証サーバーはユーザーを認識できず、認証情報の入力を求められます。このアプローチの欠点は、真のシングルサインオンが実現できなくなることです。システム内の他のアプリは認証サーバーのセッションが切断されていることを認識し、再度認証を求められます。複数のアプリがある場合、ユーザーエクスペリエンスは向上しません。
結論
このセクションでは、OAuth2 クライアントアプリケーションからログアウトするためのいくつかの異なるパターンを実装する方法を見てきました(開始点としてチュートリアルのセクション V のアプリケーションを使用)。他のパターンのオプションについても説明しました。これらのオプションはすべてを網羅しているわけではありませんが、トレードオフの適切なアイデアと、ユースケースに最適なソリューションを検討するためのいくつかのツールを提供する必要があります。このセクションには TypeScript の行が 2、3 行しかなく、Angular に固有のものではなかったため(XHR リクエストにフラグを追加します)、このガイドのサンプルアプリの狭い範囲を超えてすべてのレッスンとパターンを適用できます。繰り返し発生するテーマは、複数の UI アプリがあり、単一の認証サーバーに何らかの欠陥がある傾向があるシングルログアウト(SL)へのすべてのアプローチです: できることは、ユーザーの不快感を最小限に抑えるアプローチを選択することです。内部 authserver と多くのコンポーネントで構成されるシステムがある場合、単一のシステムのようにユーザーに感じる唯一のアーキテクチャは、すべてのユーザーインタラクションのゲートウェイである可能性があります。
新しいガイドを作成したり、既存のガイドに貢献したいですか? 投稿ガイドラインを参照してください [GitHub] (英語) 。
| すべてのガイドは、コード用の ASLv2 ライセンス、およびドキュメント用の帰属、NoDerivatives クリエイティブコモンズライセンス (英語) でリリースされています。 |