$ 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:run
http://localhost:8080 のブラウザーに移動します。ホームページをロードすると、ユーザー名とパスワードを要求するブラウザーダイアログが表示されます(ユーザー名は "user" であり、パスワードは起動時にコンソールログに出力されます)。実際にはまだコンテンツがありません(または ng
CLI のデフォルトの「ヒーロー」チュートリアルコンテンツ)。本質的に空白のページを取得する必要があります。
パスワードのコンソールログをスクレイピングしたくない場合は、これを "application.properties" ("src/main/resources" 内)に追加します: security.user.password=password (そして独自のパスワードを選択します)。これは、"application.yml" を使用してサンプルコードで行いました。 |
IDE では、アプリケーションクラスで main()
メソッドを実行するだけです(クラスは 1 つだけで、上記の "curl" コマンドを使用した場合は UiApplication
と呼ばれます)。
スタンドアロン JAR としてパッケージ化して実行するには、次のようにします。
$ mvn package
$ java -jar target/*.jar
Angular アプリケーションのカスタマイズ
"app-root" コンポーネント( "src/app/app.component.ts" 内)をカスタマイズしましょう。
最小限の Angular アプリケーションは次のようになります。
import { Component } from '@angular/core';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent {
title = 'Demo';
greeting = {'id': 'XXX', 'content': 'Hello World'};
}
この TypeScript のコードのほとんどはボイラープレートです。興味深いものはすべて AppComponent
にあり、ここで「セレクター」(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;
}
public static void main(String[] args) {
SpringApplication.run(UiApplication.class, args);
}
}
新しいプロジェクトの作成方法によっては、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 } 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
サービス (英語) を注入し、それを使用してリソースを取得しました。Angular がレスポンスを渡し、JSON をプルしてグリーティングに割り当てます。
カスタムコンポーネントへの http
サービスの依存性注入を有効にするには、コンポーネントを含む AppModule
で宣言する必要があります(最初のドラフトと比較して、imports
ではもう 1 行だけです)。
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 することはできませんが、ブラウザーはコンテンツにアクセスできました。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><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 によって提供され、メインモジュールのコンポーネントに接続する必要があります。ルートごと(メニューリンクごと)に 1 つのコンポーネントがあり、1 つにくっつけて状態を共有するヘルパーサービス(AppService
)があります。すべての要素をまとめたモジュールの実装は次のとおりです。
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { HttpClientModule } from '@angular/common/http';
import { RouterModule, Routes } from '@angular/router';
import { AppService } from './app.service';
import { HomeComponent } from './home.component';
import { LoginComponent } from './login.component';
import { AppComponent } from './app.component';
const routes: Routes = [
{ path: '', pathMatch: 'full', redirectTo: 'home'},
{ path: 'home', component: HomeComponent},
{ path: 'login', component: LoginComponent}
];
@NgModule({
declarations: [
AppComponent,
HomeComponent,
LoginComponent
],
imports: [
RouterModule.forRoot(routes),
BrowserModule,
HttpClientModule,
FormsModule
],
providers: [AppService]
bootstrap: [AppComponent]
})
export class AppModule { }
"RouterModule" (英語) と呼ばれる Angular モジュールへの依存関係を追加しました。これにより、魔法の router
を AppComponent
のコンストラクターに注入することができました。routes
は、AppModule
のインポート内で使用され、"/" (「ホーム」コントローラー)および "/login"(「ログイン」コントローラー)へのリンクを設定します。
また、そこに FormsModule
を忍び込ませました。これは、ユーザーがログインしたときに送信したいフォームにデータをバインドするために後で必要になるためです。
UI コンポーネントはすべて「宣言」であり、サービスグルーは「プロバイダー」です。AppComponent
は実際にはあまり機能しません。アプリのルートに付属する TypeScript コンポーネントは次のとおりです。
import { Component } from '@angular/core';
import { AppService } from './app.service';
import { HttpClient } from '@angular/common/http';
import { Router } from '@angular/router';
import 'rxjs/add/operator/finally';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent {
constructor(private app: AppService, private http: HttpClient, private router: Router) {
this.app.authenticate(undefined, undefined);
}
logout() {
this.http.post('logout', {}).finally(() => {
this.app.authenticated = false;
this.router.navigateByUrl('/login');
}).subscribe();
}
}
顕著な特徴:
今度は
AppService
の依存性注入がいくつかありますコンポーネントのプロパティとして公開されたログアウト関数があります。これは後でログアウトリクエストをバックエンドに送信するために使用できます。
app
サービスにフラグを設定し、ユーザーをログイン画面に送り返します(これをfinally()
コールバックを介して無条件に行います)。templateUrl
を使用して、テンプレート HTML を別のファイルに外部化します。authenticate()
関数は、コントローラーが読み込まれたときに呼び出され、ユーザーが実際にすでに認証されているかどうかを確認します(たとえば、ユーザーがセッションの途中でブラウザーをリフレッシュしたかどうか)。実際の認証はサーバーによって行われるため、リモート呼び出しを行うにはauthenticate()
関数が必要です。ブラウザーがそれを追跡することを信頼したくないためです。
上記で注入した app
サービスには、ユーザーが現在認証されているかどうかを確認できるブール値フラグと、バックエンドサーバーでの認証に使用できる、または単にユーザーの詳細を照会するために使用できる関数 authenticate()
が必要です:
import { Injectable } from '@angular/core';
import { HttpClient, HttpHeaders } from '@angular/common/http';
@Injectable()
export class AppService {
authenticated = false;
constructor(private http: HttpClient) {
}
authenticate(credentials, callback) {
const headers = new HttpHeaders(credentials ? {
authorization : 'Basic ' + btoa(credentials.username + ':' + credentials.password)
} : {});
this.http.get('user', {headers: headers}).subscribe(response => {
if (response['name']) {
this.authenticated = true;
} else {
this.authenticated = false;
}
return callback && callback();
});
}
}
authenticated
フラグは単純です。authenticate()
関数は、提供されている場合は HTTP 基本認証資格情報を送信し、提供されていない場合は送信しません。また、オプションの 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, OnInit } from '@angular/core';
import { AppService } from './app.service';
import { HttpClient } from '@angular/common/http';
@Component({
templateUrl: './home.component.html'
})
export class HomeComponent {
title = 'Demo';
greeting = {};
constructor(private app: AppService, private http: HttpClient) {
http.get('resource').subscribe(data => this.greeting = data);
}
authenticated() { return this.app.authenticated; }
}
ログインフォーム
ログインフォームには、独自のコンポーネントも取得されます。
<div class="alert alert-danger" [hidden]="!error">
There was a problem logging in. Please try again.
</div>
<form role="form" (submit)="login()">
<div class="form-group">
<label for="username">Username:</label> <input type="text"
class="form-control" id="username" name="username" [(ngModel)]="credentials.username"/>
</div>
<div class="form-group">
<label for="password">Password:</label> <input type="password"
class="form-control" id="password" name="password" [(ngModel)]="credentials.password"/>
</div>
<button type="submit" class="btn btn-primary">Submit</button>
</form>
これは非常に標準的なログインフォームで、ユーザー名とパスワードの 2 つの入力と、Angular イベントハンドラー (submit)
を介してフォームを送信するためのボタンがあります。form タグでアクションを実行する必要はありません。そのため、アクションをまったく挿入しない方がよいでしょう。Angular モデルに error
が含まれる場合にのみ表示されるエラーメッセージもあります。フォームコントロールは Angular フォーム (英語) の ngModel
を使用して HTML と Angular コントローラー間でデータを渡します。この場合、credentials
オブジェクトを使用してユーザー名とパスワードを保持します。
認証プロセス
追加したログインフォームをサポートするには、さらに機能を追加する必要があります。クライアント側ではこれらは LoginComponent
に実装され、サーバーでは Spring Security 構成になります。
ログインフォームの送信
フォームを送信するには、すでに ng-submit
を介してフォームで参照した login()
関数と、ng-model
を介して参照した credentials
オブジェクトを定義する必要があります。「ログイン」コンポーネントを具体化します。
import { Component, OnInit } from '@angular/core';
import { AppService } from './app.service';
import { HttpClient } from '@angular/common/http';
import { Router } from '@angular/router';
@Component({
templateUrl: './login.component.html'
})
export class LoginComponent {
credentials = {username: '', password: ''};
constructor(private app: AppService, private http: HttpClient, private router: Router) {
}
login() {
this.app.authenticate(this.credentials, () => {
this.router.navigateByUrl('/');
});
return false;
}
}
credentials
オブジェクトの初期化に加えて、フォームで必要な login()
を定義します。
authenticate()
は、相対リソース(アプリケーションのデプロイルートに対して)"/user" に対して GET を実行します。login()
関数から呼び出されると、Base64 でエンコードされたクレデンシャルがヘッダーに追加されるため、サーバー上で認証が行われ、代わりに Cookie が受け入れられます。login()
関数は、認証の結果を取得すると、それに応じてローカル $scope.error
フラグも設定します。これは、ログインフォームの上のエラーメッセージの表示を制御するために使用されます。
現在認証されているユーザー
authenticate()
関数を処理するには、新しいエンドポイントをバックエンドに追加する必要があります。
@SpringBootApplication
@RestController
public class UiApplication {
@RequestMapping("/user")
public Principal user(Principal user) {
return user;
}
...
}
これは、Spring Security アプリケーションで役立つトリックです。"/user" リソースが到達可能である場合、現在認証されているユーザー(Authentication
[GitHub] (英語) )を返します。そうでない場合、Spring Security はリクエストをインターセプトし、AuthenticationEntryPoint
[GitHub] (英語) を介して 401 レスポンスを送信します。
サーバーでのログインリクエストの処理
Spring Security を使用すると、ログインリクエストを簡単に処理できます。メインアプリケーションクラス [GitHub] (英語) にいくつかの構成を追加するだけです(たとえば、内部クラスとして)。
@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 Security をカスタマイズした標準 Spring Boot アプリケーションであり、静的(HTML)リソースへの匿名アクセスのみを許可します。HTML リソースは、明確になる理由のため、Spring Security によって無視されるだけでなく、匿名ユーザーが利用できる必要があります。
覚えておく必要がある最後のことは、Angular が提供する JavaScript コンポーネントをアプリケーションで匿名で利用できるようにすることです。上記の HttpSecurity
構成でそれを行うことができますが、静的コンテンツであるため、単純に無視する方が良いです:
security:
ignored:
- "*.bundle.*"
デフォルトの HTTP リクエストヘッダーの追加
この時点でアプリを実行すると、ブラウザーが(ユーザーとパスワードの)基本認証ダイアログをポップアップすることがわかります。これは、"WWW-Authenticate" ヘッダーを持つ /user
および /resource
への XHR リクエストからの 401 応答を確認するためです。このポップアップを抑制する方法は、Spring Security から来るヘッダーを抑制することです。また、応答ヘッダーを抑制する方法は、特別な従来のリクエストヘッダー "X-Requested-With = XMLHttpRequest" を送信することです。以前は Angular のデフォルトでしたが、1.3.0 では削除されました [GitHub] (英語) 。Angular XHR リクエストでデフォルトのヘッダーを設定する方法は次のとおりです。
最初に、Angular HTTP モジュールによって提供されるデフォルト RequestOptions
を継承します。
@Injectable()
export class XhrInterceptor implements HttpInterceptor {
intercept(req: HttpRequest<any>, next: HttpHandler) {
const xhr = req.clone({
headers: req.headers.set('X-Requested-With', 'XMLHttpRequest')
});
return next.handle(xhr);
}
}
ここの構文は定型です。Class
の implements
プロパティはその基本クラスであり、コンストラクターに加えて、本当に必要なことは、Angular によって常に呼び出され、ヘッダーを追加するために使用できる intercept()
関数をオーバーライドすることだけです。
この新しい RequestOptions
ファクトリをインストールするには、AppModule
の providers
で宣言する必要があります。
@NgModule({
...
providers: [AppService, { provide: HTTP_INTERCEPTORS, useClass: XhrInterceptor, multi: true }],
...
})
export class AppModule { }
ログアウト
アプリケーションはほぼ関数に終了しています。最後に行う必要があるのは、ホームページでスケッチしたログアウト機能を実装することです。ユーザーが認証されている場合は、「ログアウト」リンクを表示し、AppComponent
の logout()
関数にフックします。"/logout" に HTTP POST を送信することを忘れないでください。これは、サーバーに実装する必要があります。これは、Spring Security によってすでに追加されているため簡単です(つまり、この単純なユースケースでは何もする必要はありません)。ログアウトの動作をより細かく制御するには、WebSecurityAdapter
で HttpSecurity
コールバックを使用して、たとえば、ログアウト後にビジネスロジックを実行できます。
CSRF の保護
アプリケーションはほとんど使用する準備ができており、実際に実行すると、ログアウトリンクを除いて、これまでに作成したすべてが実際に機能することがわかります。それを使用してみて、ブラウザーでレスポンスを参照してください。その理由がわかります。
POST /logout HTTP/1.1
...
Content-Type: application/x-www-form-urlencoded
username=user&password=password
HTTP/1.1 403 Forbidden
Set-Cookie: JSESSIONID=3941352C51ABB941781E1DF312DA474E; Path=/; HttpOnly
Content-Type: application/json;charset=UTF-8
Transfer-Encoding: chunked
...
{"timestamp":1420467113764,"status":403,"error":"Forbidden","message":"Expected CSRF token not found. Has your session expired?","path":"/login"}
それは、Spring Security に組み込まれている CSRF 保護が、足元で自分自身を撃つことを防ぐために開始されたことを意味するため、良いことです。必要なのは、"X-CSRF" というヘッダーで送信されるトークンだけです。CSRF トークンの値は、ホームページをロードした最初のリクエストからの HttpRequest
属性でサーバー側で利用可能でした。クライアントに渡すには、サーバー上の動的な HTML ページを使用してレンダリングするか、カスタムエンドポイントを介して公開するか、Cookie として送信します。Angular には Cookie に基づいた CSRF( "XSRF" と呼ばれる)のサポートが組み込まれ (英語) ているため、最後の選択が最適です。
そのため、サーバーには、Cookie を送信するカスタムフィルターが必要です。Angular は Cookie 名を "XSRF-TOKEN" にすることを望んでおり、Spring Security はデフォルトでそれをリクエスト属性として提供するため、リクエスト属性から Cookie に値を転送するだけです。幸い、Spring Security(4.1.0 以降)は、これを正確に行う特別な CsrfTokenRepository
を提供します。
@Configuration
@Order(SecurityProperties.ACCESS_OVERRIDE_ORDER)
protected static class SecurityConfiguration extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
...
.and().csrf()
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse());
}
}
これらの変更が行われると、クライアント側で何もする必要がなくなり、ログインフォームが機能するようになります。
仕組みは?
一部の開発者ツールを使用すると、ブラウザーとバックエンド間の相互作用をブラウザーで確認できます(通常、F12 はこれを開き、デフォルトで Chrome で動作し、Firefox でプラグインが必要になる場合があります)。概要は次のとおりです。
動詞 | パス | ステータス | レスポンス |
---|---|---|---|
GET | / | 200 | index.html |
GET | /*.js | 200 | 角度からのアセット |
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] (英語) の「ホーム」コンポーネントは次のとおりです。
@Component({
templateUrl: './home.component.html'
})
export class HomeComponent {
title = 'Demo';
greeting = {};
constructor(private app: AppService, private http: HttpClient) {
http.get('resource').subscribe(data => this.greeting = data);
}
authenticated() { return this.app.authenticated; }
}
これに必要なのは、URL を変更することだけです。例: localhost で新しいリソースを実行する場合、次のようになります。
http.get('http://localhost:9000').subscribe(data => this.greeting = data);
サーバー側の変更
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] (英語) から実装をコピーするだけです:
@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
http://localhost:9000 のブラウザーに移動すると、挨拶付きの JSON が表示されます。application.properties
( "src/main/resources" 内)でポートの変更をベイクできます。
server.port: 9000
ブラウザーの UI(ポート 8080)からそのリソースをロードしようとすると、ブラウザーが XHR リクエストを許可しないため、機能しないことがわかります。
CORS ネゴシエーション
ブラウザーは、クロスオリジンリソース共有 [Mozilla] プロトコルに従ってリソースサーバーへのアクセスが許可されているかどうかを確認するために、リソースサーバーとネゴシエートしようとします。Angular の責任ではないため、Cookie 契約と同様に、ブラウザー内のすべての JavaScript でこのように機能します。2 つのサーバーは共通の発信元を宣言していないため、ブラウザーはリクエストの送信を拒否し、UI は壊れています。
これを修正するには、「プリフライト」OPTIONS リクエストと、呼び出し元の許可された動作をリストするいくつかのヘッダーを含む CORS プロトコルをサポートする必要があります。Spring 4.2 には、きめの細かい 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</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
Spring Boot と Spring Session は連携して Redis に接続し、セッションデータを一元的に保存します。
その 1 行のコードと localhost で実行されている 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 リクエストの一部としてヘッダーを送信するように変更する必要があります。例:
constructor(private app: AppService, private http: HttpClient) {
http.get('token').subscribe(data => {
const token = data['token'];
http.get('http://localhost:9000', {headers : new HttpHeaders().set('X-Auth-Token', token)})
.subscribe(response => this.greeting = response);
}, () => {});
}
(よりエレガントなソリューションは、必要に応じてトークンを取得し、RequestOptionsService
を使用して、リソースサーバーへのすべてのリクエストにヘッダーを追加することです)
"http://localhost:9000" に直接移動する代わりに、"/token" の UI サーバー上の新しいカスタムエンドポイントへの呼び出しの成功コールバックでその呼び出しをラップしました。その実装は簡単です。
@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 が含まれます。
リソースサーバーでの認証
リソースサーバーがカスタムヘッダーを受け入れることができるように、リソースサーバーに小さな変更が 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 に通過を許可することを伝える必要があります。
public class ResourceApplication extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.cors().and().authorizeRequests()
.anyRequest().authenticated();
}
...
すべてのリソースに permitAll() アクセスする必要はありません。また、リクエストがプリフライトであることを認識していないため、機密データを誤って送信するハンドラーが存在する場合があります。cors() 構成ユーティリティは、フィルター層ですべてのプリフライトリクエストを処理することにより、これを軽減します。 |
あとは、リソースサーバーでカスタムトークンを取得し、それを使用してユーザーを認証するだけです。必要なのは、Spring Security にセッションリポジトリの場所と、受信リクエストでトークン(セッション ID)を探す場所を伝えるだけなので、これは非常に簡単です。最初に Spring Session と Redis の依存関係を追加する必要があり、次に Filter
をセットアップできます。
@SpringBootApplication
@RestController
class ResourceApplication {
...
@Bean
HeaderHttpSessionStrategy sessionStrategy() {
return new HeaderHttpSessionStrategy();
}
}
作成されたこの Filter
は、UI サーバーにあるもののミラーイメージであるため、Redis をセッションストアとして確立します。唯一の違いは、デフォルト( "JSESSIONID" という名前の Cookie)ではなく、ヘッダー(デフォルトでは "X-Auth-Token" )を調べるカスタム HttpSessionStrategy
を使用することです。また、ブラウザーが認証されていないクライアントでダイアログをポップアップしないようにする必要があります。アプリは安全ですが、デフォルトで WWW-Authenticate: Basic
で 401 を送信するため、ブラウザーはユーザー名とパスワードのダイアログで応答します。これを実現する方法は複数ありますが、すでに Angular が "X-Requested-With" ヘッダーを送信するようにしているため、Spring Security はデフォルトでそれを処理します。
リソースサーバーには、新しい認証スキームで動作するように、最後に 1 つの変更があります。Spring Boot のデフォルトセキュリティはステートレスであり、セッションに認証を保存するために、application.yml
(または application.properties
)で明示的にする必要があります。
security:
sessions: NEVER
これは、Spring Security に対して「セッションを作成することはありませんが、セッションが存在する場合はセッションを使用する」と言います(UI での認証のためにすでに存在します)。
リソースサーバーを再起動し、新しいブラウザーウィンドウで UI を開きます。
なぜすべてが Cookie で機能しないのですか?
カスタムヘッダーを使用し、クライアントにヘッダーを入力するコードを記述する必要がありました。これはそれほど複雑ではありませんが、可能な限り Cookie とセッションを使用するというパート II のアドバイスと矛盾するようです。そうしないと、不必要な複雑さが追加されるという議論がありましたが、確かに、現在の実装はこれまで見てきた中で最も複雑です: ソリューションの技術的な部分は、ビジネスロジック(明らかに小さい)をはるかに上回っています。これは間違いなく公正な批判です(そして、このシリーズの次のセクションで説明する予定です)が、すべての目的で Cookie とセッションを使用するほど単純ではない理由を簡単に見てみましょう。
少なくともまだセッションを使用していますが、これは理にかなっています。なぜなら Spring Security と Servlet コンテナーは私たちの努力なしにそれを行う方法を知っているからです。しかし、Cookie を使用して認証トークンを転送し続けることができなかったでしょうか? それは素晴らしかったでしょうが、それが機能しない理由があり、ブラウザーが私たちを認可しないということです。JavaScript クライアントからブラウザーの Cookie ストアをいじってみることができますが、いくつかの制限があり、十分な理由があります。特に、サーバーから "HttpOnly" として送信された Cookie にはアクセスできません(セッション Cookie の場合、デフォルトで表示されます)。また、送信リクエストに Cookie を設定できないため、"SESSION" Cookie(Spring Session のデフォルト Cookie 名)を設定できなかったため、カスタムの "X-Session" ヘッダーを使用する必要がありました。これらの制限は両方ともユーザー自身の保護のためのものであるため、悪意のあるスクリプトは適切な認可なしにリソースにアクセスできません。
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 Gateway に変換するには、UI サーバーに 1 つの小さな調整が必要です。Spring 構成のどこかで、たとえばメイン(のみ)アプリケーションクラス [GitHub] (英語) に @EnableZuulProxy
アノテーションを追加する必要があります。
@SpringBootApplication
@RestController
@EnableZuulProxy
public class UiApplication {
...
}
また、外部構成ファイルで、UI サーバーのローカルリソースを外部構成 [GitHub] (英語) ("application.yml" )のリモートリソースにマップする必要があります。
security:
...
zuul:
routes:
resource:
path: /resource/**
url: http://localhost:9000
これは、「このサーバーのパターン /resource/** のパスを、リモートサーバーの localhost:9000 にある同じパスにマップする」という意味です。シンプルですが効果的です (YAML を含めて 6 行ですが、必ずしも必要なわけではありません)。
この作業を行うために必要なのは、クラスパス上の適切なものだけです。そのために、Maven POM にいくつかの新しい行があります。
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>Dalston.SR4</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-zuul</artifactId>
</dependency>
...
</dependencies>
"spring-cloud-starter-zuul" の使用に注意してください。これは Spring Boot のものと同じスターター POM ですが、この Zuul プロキシに必要な依存関係を管理します。また、推移的な依存関係のすべてのバージョンが正しいことに依存できるようにするために、<dependencyManagement>
を使用しています。
クライアントでプロキシを使用する
これらの変更が適切に適用されていても、アプリケーションは引き続き機能しますが、クライアントを変更するまで、実際には新しいプロキシを使用していません。幸いなことにそれは簡単です。最後のセクションで 「単一」から「バニラ」サンプルに行った変更を元に戻す必要があります。
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</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-redis</artifactId>
</dependency>
ただし、同じ Filter
宣言を両方に追加できるため、今回は構成がはるかに簡単になります。最初に UI サーバー。すべてのヘッダーを転送することを明示的に宣言します(つまり、「機密」はありません)。
zuul:
routes:
resource:
sensitive-headers:
その後、リソースサーバーに移動できます。2 つの小さな変更が必要です。1 つは、リソースサーバーで HTTP Basic を明示的に無効にすることです(ブラウザーが認証ダイアログをポップアップするのを防ぐため)。
@SpringBootApplication
@RestController
class ResourceApplication extends WebSecurityConfigurerAdapter {
...
@Override
protected void configure(HttpSecurity http) throws Exception {
http.httpBasic().disable();
http.authorizeRequests().anyRequest().authenticated();
}
}
余談: 認証ダイアログを防ぐ別の方法は、HTTP Basic を保持したまま、401 チャレンジを "Basic" 以外に変更することです。
HttpSecurity
構成コールバックのAuthenticationEntryPoint
の 1 行の実装でそれを行うことができます。
もう 1 つは、application.properties
で非ステートレスセッション作成ポリシーを明示的に要求することです。
security.sessions: NEVER
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" への最後のリクエストは、リソースサーバーにプロキシされているため、特別です。
UI サーバーの "/trace" エンドポイント(Spring Cloud の依存関係で追加した Spring Boot Actuator から)を確認することで、リバースプロキシの動作を確認できます。新しいブラウザーで http://localhost:8080/trace に移動します(ブラウザー用の JSON プラグインをまだ入手していない場合は、ブラウザーを読みやすくします)。HTTP Basic(ブラウザーポップアップ)で認証する必要がありますが、ログインフォームと同じ資格情報が有効です。開始時または開始近くに、次のようなリクエストのペアが表示されます。
認証のクロスオーバーの可能性がないように別のブラウザーを使用してみてください(たとえば、UI のテストに Chrome を使用しない場合は Firefox を使用します)- アプリの動作を停止しませんが、含まれている場合はトレースを読みにくくします。同じブラウザーからの認証の混合。 |
{
"timestamp": 1420558194546,
"info": {
"method": "GET",
"path": "/",
"query": ""
"remote": true,
"proxy": "resource",
"headers": {
"request": {
"accept": "application/json, text/plain, */*",
"x-xsrf-token": "542c7005-309c-4f50-8a1d-d6c74afe8260",
"cookie": "SESSION=c18846b5-f805-4679-9820-cd13bd83be67; XSRF-TOKEN=542c7005-309c-4f50-8a1d-d6c74afe8260",
"x-forwarded-prefix": "/resource",
"x-forwarded-host": "localhost:8080"
},
"response": {
"Content-Type": "application/json;charset=UTF-8",
"status": "200"
}
},
}
},
{
"timestamp": 1420558200232,
"info": {
"method": "GET",
"path": "/resource/",
"headers": {
"request": {
"host": "localhost:8080",
"accept": "application/json, text/plain, */*",
"x-xsrf-token": "542c7005-309c-4f50-8a1d-d6c74afe8260",
"cookie": "SESSION=c18846b5-f805-4679-9820-cd13bd83be67; XSRF-TOKEN=542c7005-309c-4f50-8a1d-d6c74afe8260"
},
"response": {
"Content-Type": "application/json;charset=UTF-8",
"status": "200"
}
}
}
},
2 番目のエントリは "/resource" のクライアントからゲートウェイへのリクエストであり、Cookie(ブラウザーによって追加された)と CSRF ヘッダー(パート II に従って Angular によって追加された)を確認できます。最初のエントリには remote: true
があり、これはリソースサーバーへの呼び出しをトレースしていることを意味します。これが URI パス "/" に送信されたことがわかります。また、(重要なことに)Cookie と CSRF ヘッダーも送信されていることがわかります。Spring Session がないと、これらのヘッダーはリソースサーバーにとって意味がありませんが、セットアップ方法では、これらのヘッダーを使用して、認証と CSRF トークンデータを使用してセッションを再構成できます。リクエストは許可され、ビジネスを行っています!
結論
このセクションではかなり多くのことを説明しましたが、2 台のサーバーに最小限のボイラープレートコードがあり、どちらも安全であり、ユーザーエクスペリエンスが損なわれない、本当に素晴らしい場所に到達しました。それだけが API Gateway パターンを使用する理由になりますが、実際には、使用される可能性のある表面のほんの一部に過ぎません(Netflix は多くのこ [GitHub] (英語) とに使用しています)。Spring Cloud を読んで、より多くの機能をゲートウェイに簡単に追加する方法を確認してください。このシリーズの次のセクションでは、認証の責任を別のサーバーに抽出することで、アプリケーションアーキテクチャを少し拡張します(シングルサインオンパターン)。
OAuth2 を使用したシングルサインオン
このセクションでは、「シングルページアプリケーション」で Spring Security を Angular (英語) と使用する方法について引き続き説明します。ここでは、Spring Security OAuth (英語) と Spring Cloud を使用して API Gateway を継承し、シングルサインオンと OAuth2 トークン認証をバックエンドリソースに実行する方法を示します。これは一連のセクションの 5 番目であり、アプリケーションの基本的な構成要素に追いつくか、最初のセクションを読んでゼロからビルドするか、Github のソースコードに直接 (英語) 進むことができます。最後のセクションでは、Spring Session [GitHub] (英語) を使用してバックエンドリソースを認証し、Spring Cloud を使用して UI サーバーに組み込み API ゲートウェイを実装する小さな分散アプリケーションを構築しました。このセクションでは、認証サーバーへの多くのシングルサインオンアプリケーションの最初の UI サーバーを作成するために、認証の責任を別のサーバーに抽出します。これは、企業およびソーシャルスタートアップの両方で、最近の多くのアプリケーションで一般的なパターンです。OAuth2 サーバーをオーセンティケーターとして使用するため、OAuth2 サーバーを使用してバックエンドリソースサーバーのトークンを付与することもできます。Spring Cloud はアクセストークンをバックエンドに自動的に中継し、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 OAuth (英語) 依存関係を追加する必要があるため、POM [GitHub] (英語) に以下を追加します。
<dependency>
<groupId>org.springframework.security.oauth</groupId>
<artifactId>spring-security-oauth2</artifactId>
</dependency>
認可サーバーの実装は非常に簡単です。最小バージョンは次のようになります。
@SpringBootApplication
@EnableAuthorizationServer
public class AuthserverApplication extends WebMvcConfigurerAdapter {
public static void main(String[] args) {
SpringApplication.run(AuthserverApplication.class, args);
}
}
(@EnableAuthorizationServer
を追加した後)あと 1 つだけ行う必要があります。
---
...
security.oauth2.client.clientId: acme
security.oauth2.client.clientSecret: acmesecret
security.oauth2.client.authorized-grant-types: authorization_code,refresh_token,password
security.oauth2.client.scope: openid
---
これにより、クライアント "acme" がシークレットと "authorization_code" を含むいくつかの認可された認可型で登録されます。
次に、テスト用の予測可能なパスワードを使用して、ポート 9999 で実行します。
server.port=9999
security.user.password=password
server.contextPath=/uaa
...
また、デフォルト("/")を使用しないようにコンテキストパスを設定します。そうしないと、ローカルホスト上の他のサーバーの Cookie が間違ったサーバーに送信される可能性があります。サーバーを実行すると、サーバーが機能していることを確認できます。
$ mvn spring-boot:run
または、IDE で main()
メソッドを開始します。
認可サーバーのテスト
サーバーは Spring Boot のデフォルトのセキュリティ設定を使用しているため、パート I のサーバーと同様に、HTTP Basic 認証によって保護されます。認可コードトークンの付与 [IETF] (英語) を開始するには、認可エンドポイントにアクセスします。http://localhost:9999/uaa/oauth/authorize?response_type=code&client_id=acme&redirect_uri=http://example.com で認証されると、認証コードが添付された example.com へのリダイレクトが取得されます。例: http://example.com/?code=jYWioI (英語)
このサンプルアプリケーションの目的のために、リダイレクトが登録されていないクライアント "acme" を作成しました。これにより、example.com へのリダイレクトを取得できます。本番アプリケーションでは、常にリダイレクトを登録する(および HTTPS を使用する)必要があります。 |
トークンエンドポイントの "acme" クライアント資格情報を使用して、コードをアクセストークンに交換できます。
$ curl acme:acmesecret@localhost:9999/uaa/oauth/token \
-d grant_type=authorization_code -d client_id=acme \
-d redirect_uri=http://example.com -d code=jYWioI
{"access_token":"2219199c-966e-4466-8b7e-12bb9038c9bb","token_type":"bearer","refresh_token":"d193caf4-5643-4988-9a4a-1c03c9d657aa","expires_in":43199,"scope":"openid"}
アクセストークンは UUID("2219199c … ")であり、サーバー内のメモリ内トークンストアによってバックアップされます。また、現在のトークンが期限切れになったときに新しいアクセストークンを取得するために使用できるリフレッシュトークンも取得しました。
"acme" クライアントに "password" 付与を認可したため、認証コードの代わりに curl とユーザー資格情報を使用して、トークンエンドポイントからトークンを直接取得することもできます。これはブラウザーベースのクライアントには適していませんが、テストには役立ちます。 |
上記のリンクをたどると、Spring OAuth が提供するホワイトラベル UI が表示されます。まず、これを使用します。後から戻って、パート II の自己完結型サーバーの場合と同じように強化することができます。
リソースサーバーの変更
パート IV から続けると、リソースサーバーは認証に Spring Session [GitHub] (英語) を使用しているため、それを取り出して Spring OAuth に置き換えることができます。Spring Session と Redis の依存関係も削除する必要があるため、これを置き換えます。
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-redis</artifactId>
</dependency>
これとともに:
<dependency>
<groupId>org.springframework.security.oauth</groupId>
<artifactId>spring-security-oauth2</artifactId>
</dependency>
次に、セッション Filter
をメインアプリケーションクラス [GitHub] (英語) から削除し、便利な @EnableResourceServer
アノテーション(Spring Security OAuth2 から)に置き換えます。
@SpringBootApplication
@RestController
@EnableResourceServer
class ResourceApplication {
@RequestMapping("/")
public Message home() {
return new Message("Hello World");
}
public static void main(String[] args) {
SpringApplication.run(ResourceApplication.class, args);
}
}
その 1 つの変更で、アプリは HTTP Basic ではなくアクセストークンを要求する準備ができていますが、実際にプロセスを完了するには、構成を変更する必要があります。リソースサーバーが与えられたトークンをデコードしてユーザーを認証できるように、少量の外部構成("application.properties" )を追加します。
...
security.oauth2.resource.userInfoUri: http://localhost: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"}
また、ベアラートークンが必要であることを示す "WWW-Authenticate" ヘッダーを持つ 401 が表示されます。
userInfoUri は、トークンをデコードする方法でリソースサーバーを接続する唯一の方法ではありません。実際、これは最も一般的な分母のようなもので(仕様の一部ではありません)、OAuth2 プロバイダー(Facebook、Cloud Foundry、Github など)から非常に頻繁に利用でき、他の選択肢も利用できます。たとえば、トークン自体でユーザー認証をエンコードする(例: JWT (英語) を使用)か、共有バックエンドストアを使用します。CloudFoundry には /token_info エンドポイントもあり、ユーザー情報エンドポイントよりも詳細な情報を提供しますが、より完全な認証が必要です。さまざまなオプションが(当然)さまざまな利点とトレードオフを提供しますが、それらの詳細な説明はこのセクションの範囲外です。 |
ユーザーエンドポイントの実装
認可サーバーでは、そのエンドポイントを簡単に追加できます
@SpringBootApplication
@RestController
@EnableAuthorizationServer
@EnableResourceServer
public class AuthserverApplication {
@RequestMapping("/user")
public Principal user(Principal user) {
return user;
}
...
}
パート II の UI サーバーと同じ @RequestMapping
を追加し、Spring OAuth からの @EnableResourceServer
アノテーションも追加しました。これは、デフォルトで "/oauth/*" エンドポイントを除く認可サーバー内のすべてを保護します。
エンドポイントを配置したら、認可サーバーによって作成されたベアラートークンを受け入れるようになったため、それとグリーティングリソースをテストできます。
$ 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-security-oauth2
ではなく spring-cloud-starter-oauth2
を直接使用します(これにより、プロキシを介してトークンを中継するための自動構成が設定されます)。
それが完了したら、セッションフィルターと "/user" エンドポイントも削除し、(@EnableOAuth2Sso
アノテーションを使用して)認可サーバーにリダイレクトするようにアプリケーションを設定できます。
@SpringBootApplication
@EnableZuulProxy
@EnableOAuth2Sso
public class UiApplication {
public static void main(String[] args) {
SpringApplication.run(UiApplication.class, args);
}
...
}
UI サーバーが @EnableZuulProxy
のおかげで API ゲートウェイとして機能し、YAML でルートマッピングを宣言できることをパート IV から思い出してください。"/user" エンドポイントを認証サーバーにプロキシできます。
zuul:
routes:
resource:
path: /resource/**
url: http://localhost:9000
user:
path: /user/**
url: http://localhost:9999/uaa/user
最後に、@EnableOAuth2Sso
によって設定された SSO フィルターチェーンのデフォルトを変更するために使用されるため、アプリケーションを WebSecurityConfigurerAdapter
に変更する必要があります。
@SpringBootApplication
@EnableZuulProxy
@EnableOAuth2Sso
public class UiApplication extends WebSecurityConfigurerAdapter {
@Override
public void configure(HttpSecurity http) throws Exception {
http
.logout().logoutSuccessUrl("/").and()
.authorizeRequests().antMatchers("/index.html", "/app.html", "/")
.permitAll().anyRequest().authenticated().and()
.csrf()
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse());
}
}
主な変更点(基本クラス名を除く)は、マッチャーが独自のメソッドに入ることであり、formLogin()
はもう必要ありません。明示的な logout()
構成は、保護されていない成功 URL を明示的に追加するため、/logout
への XHR リクエストは正常に戻ります。
@EnableOAuth2Sso
アノテーションには、適切な認可サーバーと通信して認証できるようにするためのいくつかの必須の外部構成プロパティもあります。application.yml
でこれが必要です:
security:
...
oauth2:
client:
accessTokenUri: http://localhost:9999/uaa/oauth/token
userAuthorizationUri: http://localhost:9999/uaa/oauth/authorize
clientId: acme
clientSecret: acmesecret
resource:
userInfoUri: http://localhost:9999/uaa/user
その大部分は、OAuth2 クライアント( "acme" )と認可サーバーの場所に関するものです。ユーザーが UI アプリ自体で認証できるように、userInfoUri
もあります(リソースサーバーの場合と同様)。
UI アプリケーションで期限切れのアクセストークンを自動的にリフレッシュできるようにするには、中継を行う Zuul フィルターに OAuth2RestOperations を挿入する必要があります。これを行うには、その型の Bean を作成するだけです(詳細については OAuth2TokenRelayFilter を確認してください)。 |
@Bean
protected OAuth2RestTemplate OAuth2RestTemplate(
OAuth2ProtectedResourceDetails resource, OAuth2ClientContext context) {
return new OAuth2RestTemplate(resource, context);
}
クライアント
フロントエンドの UI アプリケーションには、認可サーバーへのリダイレクトをトリガーするためにまだ行う必要がある調整がいくつかあります。この簡単なデモでは、Angular アプリを必要最低限の要素にまとめて、何が起こっているかをより明確に確認できます。そのため、今のところ、フォームやルートの使用を控え、単一の Angular コンポーネントに戻ります。
import { Component } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import 'rxjs/add/operator/finally';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent {
title = 'Demo';
authenticated = false;
greeting = {};
constructor(private http: HttpClient) {
this.authenticate();
}
authenticate() {
this.http.get('user').subscribe(response => {
if (response['name']) {
this.authenticated = true;
this.http.get('resource').subscribe(data => this.greeting = data);
} else {
this.authenticated = false;
}
}, () => { this.authenticated = false; });
}
logout() {
this.http.post('logout', {}).finally(() => {
this.authenticated = false;
}).subscribe();
}
}
AppComponent
はすべてを処理し、ユーザーの詳細と、成功した場合は挨拶を取得します。また、logout
機能も提供します。
次に、この新しいコンポーネントのテンプレートを作成する必要があります。
app.component.html
<div class="container">
<ul class="nav nav-pills">
<li><a>Home</a></li>
<li><a href="login">Login</a></li>
<li><a (click)="logout()">Logout</a></li>
</ul>
</div>
<div class="container">
<h1>Greeting</h1>
<div [hidden]="!authenticated">
<p>The ID is {{greeting.id}}</p>
<p>The content is {{greeting.content}}</p>
</div>
<div [hidden]="authenticated">
<p>Login to see your greeting</p>
</div>
それをホームページに <app-root/>
として含めます。
「ログイン」のナビゲーションリンクは、href
(Angular ルートではない)との通常のリンクであることに注意してください。これが送信される "/login" エンドポイントは Spring Security によって処理され、ユーザーが認証されていない場合は、認証サーバーにリダイレクトされます。
仕組みは?
すべてのサーバーを一緒に実行し、http://localhost:8080 のブラウザーで UI にアクセスします。「ログイン」リンクをクリックすると、OAuth2 からフェッチされたグリーティングを使用して UI のホームページにリダイレクトされる前に、認証サーバー(HTTP Basic ポップアップ)にリダイレクトされ、トークン付与(ホワイトラベル HTML)が認可されます。UI を認証したのと同じトークンを使用するリソースサーバー。
一部の開発者ツールを使用すると、ブラウザーとバックエンド間の相互作用をブラウザーで確認できます(通常、F12 はこれを開き、デフォルトで Chrome で動作し、Firefox でプラグインが必要になる場合があります)。概要は次のとおりです。
動詞 | パス | ステータス | レスポンス |
---|---|---|---|
GET | / | 200 | index.html |
GET | /*.js | 200 | 角度からのアセット |
GET | /user | 302 | ログインページにリダイレクト |
GET | /login | 302 | 認証サーバーにリダイレクトする |
GET | (uaa)/oauth/authorize | 401 | (無視) |
GET | /login | 302 | 認証サーバーにリダイレクトする |
GET | (uaa)/oauth/authorize | 200 | HTTP 基本認証はここで発生します |
POST | (uaa)/oauth/authorize | 302 | ユーザーは許可を承認し、/login にリダイレクトします |
GET | /login | 302 | ホームページにリダイレクト |
GET | /user | 200 | (プロキシ)JSON 認証済みユーザー |
GET | /app.html | 200 | ホームページの HTML パーシャル |
GET | /resource | 200 | (プロキシ)JSON グリーティング |
(uaa)で始まるリクエストは、認可サーバーに対するものです。「無視」とマークされたレスポンスは、XHR 呼び出しで Angular によって受信されたレスポンスであり、そのデータを処理していないため、フロアにドロップされます。"/user" リソースの場合、認証されたユーザーを探しますが、最初の呼び出しには存在しないため、そのレスポンスはドロップされます。
UI の "/trace" エンドポイント(下にスクロール)に、"/user" および "/resource" へのプロキシされたバックエンドリクエストが表示されます。認証には、Cookie の代わりに remote:true
とベアラートークンが使用されます(パート IV の場合と同様)。Spring Cloud Security がこれを処理してくれました。@EnableOAuth2Sso
と @EnableZuulProxy
があることを認識することで、(デフォルトで)トークンをプロキシされたバックエンドに中継したいことがわかりました。
前のセクションと同様に、認証のクロスオーバーの可能性がないように、"/trace" に別のブラウザーを使用してみてください(たとえば、UI のテストに Chrome を使用した場合は Firefox を使用してください)。 |
ログアウトエクスペリエンス
「ログアウト」リンクをクリックすると、ユーザーが UI サーバーで認証されなくなるため、ホームページが変更される(グリーティングが表示されなくなる)ことがわかります。「ログイン」をクリックしますが、実際には認証サーバーで認証と認可のサイクルに戻る必要はありません(ログアウトしていないため)。それが望ましいユーザーエクスペリエンスであるかどうかについて意見は分かれますが、悪名高いトリッキーな問題です(シングルサインアウト: Science Direct の記事 (英語) および Shibboleth のドキュメント (英語) )。理想的なユーザーエクスペリエンスは技術的に実現可能ではないかもしれません。また、ユーザーが望むことを本当に望んでいることを疑わなければならないこともあります。「「ログアウト」してログアウトしてほしい」というのは簡単ですが、「何からログアウトしますか? この SSO サーバーによって制御されているすべてのシステムからログアウトしますか、それともあなただけのシステムからログアウトしますか?」 「ログアウト」リンクをクリックしましたか?」興味がある場合は、このチュートリアルの後のセクションで詳細に説明されています。
結論
これで、Spring Security および Angular スタックの浅いツアーはほぼ終了です。現在、UI/API ゲートウェイ、リソースサーバー、認可サーバー / トークン付与者の 3 つの個別のコンポーネントで明確な責任を持つ優れたアーキテクチャがあります。すべてのレイヤーの非ビジネスコードの量が最小限になり、より多くのビジネスロジックを使用して実装を継承および改善する場所を簡単に確認できます。次のステップは、認証サーバーの UI を整理し、JavaScript クライアントでのテストを含むいくつかのテストを追加することです。もう 1 つの興味深いタスクは、すべてのボイラープレートコードを抽出し、Spring Security と Spring Session の自動構成、および Angular ピースのナビゲーションコントローラー用のいくつかの webjar リソースを含むライブラリ( "spring-security-angular" など)に配置することです。このシリーズのセクションを読んだら、Angular または Spring Security の内部動作を学びたいと思っていた人はおそらくがっかりするでしょうが、それらがどのようにうまく連携し、少しの構成がどのように長くなるかを知りたい場合は方法、うまくいけば、良い経験をしたでしょう。Spring Cloud は新しく、これらのサンプルは作成時にスナップショットが必要でしたが、リリース候補があり、GA リリースが間もなくリリースされるため、それをチェックして、Github (英語) または gitter.im (英語) を介し [GitHub] (英語) てフィードバックを送信してください。
補遺: 認可サーバーのブートストラップ UI および JWT トークン
このアプリケーションの別のバージョンは、Github のソースコードにあります。Github のソースコードには、パート II (英語) のログインページと同じように、きれいなログインページとユーザー承認ページが実装されています。また、JWT (英語) を使用してトークンをエンコードするため、"/user" エンドポイントを使用する代わりに、リソースサーバーはトークン自体から十分な情報をプルして単純な認証を行うことができます。ブラウザークライアントは引き続き UI サーバーを介してプロキシされてそれを使用するため、ユーザーが認証されているかどうかを判断できます(実際のアプリケーションでのリソースサーバーへの呼び出しの可能性と比較して、それほど頻繁に行う必要はありません))。
複数の UI アプリケーションとゲートウェイ
このセクションでは、「シングルページアプリケーション」で Spring Security を Angular (英語) と使用する方法について引き続き説明します。ここでは、Spring Session (英語) と Spring Cloud を使用して、パート 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")があるため、今は無視してください。
バックエンドの構築
このアーキテクチャでは、バックエンドはセクション III で構築した "spring-session" [GitHub] (英語) サンプルに非常に似ていますが、実際にはログインページは必要ありません。ここで必要なものに到達する最も簡単な方法は、おそらくセクション III から「リソース」サーバーをコピーし、セクション I の「基本」 [GitHub] (英語) サンプルから UI を取得することです。「基本」UI からここで必要な UI に到達するには、いくつかの依存関係を追加するだけです(セクション III で Spring Session [GitHub] (英語) を最初に使用したときのように)。
<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 アプリケーション(「基本」サンプルと同じ)が作成され、その動作のテストと推論が大幅に簡素化されます。
最後に、このサーバーをバックエンドとして実行したいため、(application.properties
で)リッスンするデフォルト以外のポートを指定します。
server.port: 8081
security.sessions: NEVER
それがコンテンツ application.properties
全体である場合、アプリケーションは安全であり、"user" と呼ばれるユーザーがランダムなパスワードでアクセスできますが、起動時にコンソールに(ログレベル INFO で)出力されます。"security.sessions" 設定は、Spring Security が認証トークンとして Cookie を受け入れるが、Cookie がすでに存在しない限り Cookie を作成しないことを意味します。
リソースサーバー
リソースサーバーは、既存のサンプルの 1 つから簡単に生成できます。これは、セクション III の "spring-session" リソースサーバーと同じです。分散セッションデータを取得するための "/resource" エンドポイント Spring Session だけです。このサーバーにリッスンするデフォルト以外のポートを持たせ、セッションで認証を検索できるようにしたいため、これが必要です(application.properties
で)。
server.port: 9000
security.sessions: NEVER
このチュートリアルの新しい機能であるメッセージリソースへの変更を POST する予定です。これは、バックエンドで CSRF 保護が必要になることを意味し、Spring Security を Angular とうまく動作させるために通常のトリックを行う必要があります。
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf()
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse());
}
完成したサンプルは、こちらの github (英語) にあります。
ゲートウェイ
ゲートウェイの初期実装(おそらく動作する可能性のある最も単純なもの)では、空の Spring Boot Web アプリケーションを取得して @EnableZuulProxy
アノテーションを追加するだけです。セクション I で見たように、それを行うにはいくつかの方法があり、その 1 つは Spring Initializr を使用してスケルトンプロジェクトを生成することです。さらに簡単なのは、Spring Cloud Initializr (英語) を使用することですが、これは同じことですが、Spring Cloud (英語) アプリケーション用です。セクション I と同じ一連のコマンドライン操作を使用します。
$ mkdir gateway && cd gateway
$ curl https://cloud-start.spring.io/starter.tgz -d style=web \
-d style=security -d style=cloud-zuul -d name=gateway \
-d style=redis | tar -xzvf -
次に、そのプロジェクト(デフォルトでは通常の Maven Java プロジェクト)をお気に入りの IDE にインポートするか、コマンドラインでファイルと "mvn" を操作するだけです。そこから行きたい場合は github (英語) にバージョンがありますが、まだ必要のない追加機能がいくつかあります。
空白の Initializr アプリケーションから始めて、Spring Session 依存関係を追加します(上記の UI のように)。ゲートウェイを実行する準備はできていますが、バックエンドサービスについてはまだ知らないため、application.yml
でセットアップしてみましょう(上記の curl を行った場合は application.properties
から名前を変更します)。
zuul:
sensitive-headers:
routes:
ui:
url: http://localhost:8081
resource:
url: http://localhost:9000
security:
user:
password:
password
sessions: ALWAYS
プロキシには 2 つのルートがあり、どちらも sensitive-headers
プロパティを使用して Cookie をダウンストリームで渡します。どちらも UI とリソースサーバーに 1 つずつあり、デフォルトのパスワードとセッション永続性戦略を設定しています(Spring Security に常にセッションを作成するように伝えます)認証)。認証とセッションをゲートウェイで管理する必要があるため、この最後のビットは重要です。
稼働
現在、3 つのポートで実行される 3 つのコンポーネントがあります。ブラウザーを http://localhost:8080/ui/ に向けると、HTTP Basic チャレンジを取得する必要があり、「ユーザー / パスワード」(ゲートウェイの資格情報)として認証できます。これを行うと、バックエンド経由で UI に挨拶が表示されます。プロキシを介してリソースサーバーに呼び出します。
一部の開発者ツールを使用すると、ブラウザーとバックエンド間の相互作用をブラウザーで確認できます(通常、F12 はこれを開き、デフォルトで Chrome で動作し、Firefox でプラグインが必要になる場合があります)。概要は次のとおりです。
動詞 | パス | ステータス | レスポンス |
---|---|---|---|
GET | /ui/ | 401 | 認証のためのブラウザープロンプト |
GET | /ui/ | 200 | index.html |
GET | /ui/*.js | 200 | Angular アセット |
GET | /ui/js/hello.js | 200 | アプリケーションロジック |
GET | /ui/user | 200 | 認証 |
GET | /resource/ | 200 | JSON グリーティング |
ブラウザーはホームページの読み込みを単一の対話として扱うため、401 が表示されない場合があります。すべてのリクエストはプロキシされます(管理用のアクチュエーターエンドポイントを超えて、ゲートウェイにはまだコンテンツがありません)。
うまくいきました! 2 つのバックエンドサーバーがあり、その 1 つは UI であり、それぞれが独立した機能を持ち、独立してテストすることができ、それらは制御し、認証を構成したセキュアゲートウェイと一緒に接続されます。ブラウザーがバックエンドにアクセスできない場合は重要ではありません(実際、物理的なセキュリティをさらに制御できるため、おそらく利点です)。
ログインフォームの追加
セクション I の「基本」サンプルのように、ログインフォームをゲートウェイに追加できます。セクション II からコードをコピーします。それを行うとき、ゲートウェイにいくつかの基本的なナビゲーション要素を追加することもできるため、ユーザーはプロキシの UI バックエンドへのパスを知る必要がありません。それでは、最初に静的アセットを「単一」UI からゲートウェイにコピーし、メッセージレンダリングを削除して、ログインフォームをホームページ(<app/>
のどこか)に挿入します。
<div class="container" [hidden]="authenticated">
<form role="form" (submit)="login()">
<div class="form-group">
<label for="username">Username:</label> <input type="text"
class="form-control" id="username" name="username"
[(ngModel)]="credentials.username" />
</div>
<div class="form-group">
<label for="password">Password:</label> <input type="password"
class="form-control" id="password" name="password"
[(ngModel)]="credentials.password" />
</div>
<button type="submit" class="btn btn-primary">Submit</button>
</form>
</div>
メッセージのレンダリングの代わりに、素敵な大きなナビゲーションボタンがあります。
<div class="container" [hidden]="!authenticated">
<a class="btn btn-primary" href="/ui/">Go To User Interface</a>
</div>
github でサンプルを見ている場合、「ログアウト」ボタンのある最小限のナビゲーションバーもあります。スクリーンショットのログインフォームは次のとおりです。
ログインフォームをサポートするには、<form/>
で宣言した login()
関数を実装するコンポーネントを含む TypeScript が必要です。また、authenticated
フラグを設定して、ユーザーが認証されているかどうかに応じてホームページのレンダリングが異なるようにする必要があります。例:
include::src/app/app.component.ts
login()
関数の実装はセクション II の実装に似ています。
この単純なアプリケーションにはコンポーネントが 1 つしかないため、self
を使用して authenticated
フラグを格納できます。
この強化されたゲートウェイを実行する場合、UI の URL を覚える必要はなく、ホームページを読み込んでリンクをたどることができます。認証済みユーザーのホームページは次のとおりです。
バックエンドでのきめ細かいアクセス決定
これまでのアプリケーションは、関数にはセクション III またはセクション IV のものと非常によく似ていますが、専用のゲートウェイが追加されています。余分なレイヤーの利点はまだ明らかではないかもしれませんが、システムを少し拡張することで強調できます。ユーザーがメイン UI でコンテンツを「管理」するために、そのゲートウェイを使用して別のバックエンド UI を公開し、この機能へのアクセスを特別なロールを持つユーザーに制限するとします。プロキシの背後に「管理」アプリケーションを追加すると、システムは次のようになります。
application.yml
のゲートウェイには、新しいコンポーネント(Admin)と新しいルートがあります。
zuul:
sensitive-headers:
routes:
ui:
url: http://localhost:8081
admin:
url: http://localhost:8082
resource:
url: http://localhost:9000
"USER" ロールのユーザーが既存の UI を使用できることは、管理アプリケーションにアクセスするために "ADMIN" ロールが必要であるという事実と同様に、ゲートウェイボックス(緑色の文字)の上のブロック図に示されています。"ADMIN" ロールのアクセス決定は、ゲートウェイで適用できます。その場合、WebSecurityConfigurerAdapter
に表示されるか、管理アプリケーション自体に適用できます(これを行う方法については後述します)。
最初に、新しい Spring Boot アプリケーションを作成するか、UI をコピーして編集します。UI アプリで変更する必要はありませんが、最初に名前を変更する必要があります。完成したアプリはここの Github (英語) にあります。
管理アプリケーション内で、"READER" ロールと "WRITER" ロールを区別し、監査者であるユーザーに、メイン管理者ユーザーによる変更の表示を許可できるようにするとします。これはきめ細かいアクセス決定であり、ルールはバックエンドアプリケーションでのみ知られ、知られるべきです。ゲートウェイでは、ユーザーアカウントに必要なロールがあることを確認するだけでよく、この情報は利用可能ですが、ゲートウェイはそれを解釈する方法を知る必要はありません。ゲートウェイでは、ユーザーアカウントを作成して、サンプルアプリケーションを自己完結型に保ちます。
@Configuration
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
@Autowired
public void globalUserDetails(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication()
.withUser("user").password("password").roles("USER")
.and()
.withUser("admin").password("admin").roles("USER", "ADMIN", "READER", "WRITER")
.and()
.withUser("audit").password("audit").roles("USER", "ADMIN", "READER");
}
}
"admin" ユーザーは 3 つの新しいロール( "ADMIN"、"READER"、"WRITER" )で拡張され、"ADMIN" アクセス権を持つ "audit" ユーザーも追加されましたが、"WRITER" アクセス権はありません。
本番システムでは、ユーザーアカウントデータは、Spring 構成でハードコードされていないバックエンドデータベース(ほとんどの場合、ディレクトリサービス)で管理されます。このようなデータベースに接続するサンプルアプリケーションは、Spring Security サンプル [GitHub] (英語) などのインターネットで簡単に見つけることができます。 |
アクセスの決定は、管理アプリケーションで行います。"ADMIN" ロール(このバックエンドにグローバルに必要)については、Spring Security でそれを行います。
@Configuration
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
...
.authorizeRequests()
.antMatchers("/index.html", "/").permitAll()
.antMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
...
}
}
"READER" ロールと "WRITER" ロールの場合、アプリケーション自体が分割されます。アプリケーションは JavaScript に実装されているため、ここでアクセスを決定する必要があります。これを行う 1 つの方法は、計算されたビューがルーターを介して埋め込まれたホームページを持つことです。
<div class="container">
<h1>Admin</h1>
<router-outlet></router-outlet>
</div>
ルートは、コンポーネントがロードされるときに計算されます。
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent {
user: {};
constructor(private app: AppService, private http: HttpClient, private router: Router) {
app.authenticate(response => {
this.user = response;
this.message();
});
}
logout() {
this.http.post('logout', {}).subscribe(function() {
this.app.authenticated = false;
this.router.navigateByUrl('/login');
});
}
message() {
if (!this.app.authenticated) {
this.router.navigate(['/unauthenticated']);
} else {
if (this.app.writer) {
this.router.navigate(['/write']);
} else {
this.router.navigate(['/read']);
}
}
}
...
}
アプリケーションが最初に行うことは、ユーザーが認証されているかどうかを確認し、ユーザーデータを見てルートを計算することです。ルートはメインモジュールで宣言されます。
const routes: Routes = [
{ path: '', pathMatch: 'full', redirectTo: 'read'},
{ path: 'read', component: ReadComponent},
{ path: 'write', component: WriteComponent},
{ path: 'unauthenticated', component: UnauthenticatedComponent},
{ path: 'changes', component: ChangesComponent}
];
これらの各コンポーネント(各ルートに 1 つ)は個別に実装する必要があります。例として ReadComponent
を次に示します。
import { Component } from '@angular/core';
import { HttpClient } from '@angular/common/http';
@Component({
templateUrl: './read.component.html'
})
export class ReadComponent {
greeting = {};
constructor(private http: HttpClient) {
http.get('/resource').subscribe(data => this.greeting = data);
}
}
<h1>Greeting</h1>
<div>
<p>The ID is {{greeting.id}}</p>
<p>The content is {{greeting.content}}</p>
</div>
WriteComponent
も同様ですが、バックエンドでメッセージを変更する形式があります。
import { Component } from '@angular/core';
import { HttpClient } from '@angular/common/http';
@Component({
templateUrl: './write.component.html'
})
export class WriteComponent {
greeting = {};
constructor(private http: HttpClient) {
this.http.get('/resource').subscribe(data => this.greeting = data);
}
update() {
this.http.post('/resource', {content: this.greeting['content']}).subscribe(response => {
this.greeting = response;
});
}
}
<form (submit)="update()">
<p>The ID is {{greeting.id}}</p>
<div class="form-group">
<label for="username">Content:</label> <input type="text"
class="form-control" id="content" name="content" [(ngModel)]="greeting.content"/>
</div>
<button type="submit" class="btn btn-primary">Submit</button>
</form>
AppService
は、ルートを計算するためのデータも提供する必要があるため、authenticate()
関数では次のように表示されます。
http.get('/user').subscribe(function(response) {
var user = response.json();
if (user.name) {
self.authenticated = true;
self.writer = user.roles && user.roles.indexOf("ROLE_WRITER")>0;
} else {
self.authenticated = false;
self.writer = false;
}
callback && callback(response);
})
バックエンドでこの機能をサポートするには、/user
エンドポイントが必要です。メインアプリケーションクラスで:
@SpringBootApplication
@RestController
public class AdminApplication {
@RequestMapping("/user")
public Map<String, Object> user(Principal user) {
Map<String, Object> map = new LinkedHashMap<String, Object>();
map.put("name", user.getName());
map.put("roles", AuthorityUtils.authorityListToSet(((Authentication) user)
.getAuthorities()));
return map;
}
public static void main(String[] args) {
SpringApplication.run(AdminApplication.class, args);
}
}
ロール名は、"ROLE_" プレフィックスが付いた "/user" エンドポイントから返されるため、他の種類の権限と区別できます(これは Spring Security のものです)。"ROLE_" プレフィックスは JavaScript で必要ですが、「ロール」が操作の焦点であることがメソッド名から明らかな Spring Security 構成では必要ありません。 |
管理 UI をサポートするためのゲートウェイの変更
ゲートウェイでもロールを使用してアクセスを決定するため(管理 UI へのリンクを条件付きで表示できるようにするため)、ゲートウェイの "/user" エンドポイントにも「ロール」を追加する必要があります。それが整ったら、JavaScript を追加して、現在のユーザーが "ADMIN" であることを示すフラグを設定できます。authenticated()
関数の場合:
this.http.get('user', {headers: headers}).subscribe(data => {
this.authenticated = data && data['name'];
this.user = this.authenticated ? data['name'] : '';
this.admin = this.authenticated && data['roles'] && data['roles'].indexOf('ROLE_ADMIN') > -1;
});
また、ユーザーがログアウトしたときに admin
フラグを false
にリセットする必要があります。
this.logout = function() {
http.post('logout', {}).subscribe(function() {
self.authenticated = false;
self.admin = false;
});
}
その後、HTML で条件付きで新しいリンクを表示できます。
<div class="container" [hidden]="!authenticated">
<a class="btn btn-primary" href="/ui/">Go To User Interface</a>
</div>
<br />
<div class="container" [hidden]="!authenticated || !admin">
<a class="btn btn-primary" href="/admin/">Go To Admin Interface</a>
</div>
すべてのアプリを実行し、http://localhost:8080 に移動して結果を確認します。すべてが正常に機能し、現在認証されているユーザーに応じて UI が変更されます。
どうしてここに?
これで、2 つの独立したユーザーインターフェースとバックエンドリソースサーバーを備えたすてきな小さなシステムができました。これらはすべて、ゲートウェイで同じ認証によって保護されています。ゲートウェイがマイクロプロキシとして機能するという事実により、バックエンドセキュリティの関心事の実装が非常に簡単になり、ビジネス上の関心事に自由に集中できます。Spring Session の使用により、(再び)膨大な量の手間と潜在的なエラーが回避されました。
強力な機能は、バックエンドが任意の種類の認証を独自に持つことができることです(たとえば、物理アドレスと一連のローカル認証情報がわかっている場合は、UI に直接アクセスできます)。ゲートウェイは、ユーザーを認証し、バックエンドのアクセスルールを満たすメタデータをユーザーに割り当てることができる限り、完全に無関係な一連の制約を課します。これは、バックエンドコンポーネントを個別に開発およびテストできる優れた設計です。必要に応じて、ゲートウェイでの認証のために外部 OAuth2 サーバー(セクション V など、またはまったく異なるもの)に戻ることができ、バックエンドに触れる必要はありません。
このアーキテクチャのボーナス機能(認証を制御する単一のゲートウェイ、およびすべてのコンポーネントにわたる共有セッショントークン)は、セクション V で実装が困難であると判断した機能である「シングルログアウト」がフリーで提供されることです。より正確には、シングルログアウトのユーザーエクスペリエンスに対する特定のアプローチが完成したシステムで自動的に使用可能になります。ユーザーが UI(ゲートウェイ、UI バックエンド、管理バックエンド)からログアウトすると、すべてのログアウトその他、個々の UI が「ログアウト」機能を同じ方法で実装したと仮定します(セッションを無効にします)。
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 が必要ない場合のオプションは次のとおりです。
ブラウザークライアントの authserver および UI アプリからログアウトします。シンプルなアプローチであり、いくつかの注意深い CRSF および CORS 構成で動作します。SL なし
トークンが利用可能になり次第、authserver からログアウトします。authserver のセッション Cookie がないため、トークンを取得する UI での実装は困難です。Spring OAuth [GitHub] (英語) には、興味深いアプローチを示す機能リクエスト [GitHub] (英語) があります。認証コードが生成されるとすぐに、認証サーバーのセッションを無効にします。Github の課題には、セッションの無効化を実装する側面が含まれていますが、
HandlerInterceptor
として行う方が簡単です。SL なしUI と同じゲートウェイを介して認証サーバーをプロキシし、1 つの Cookie でシステム全体の状態を管理するのに十分であることを望みます。共有セッションが存在しない限り機能しません。共有セッションは、オブジェクトをある程度無効にします(それ以外の場合、authserver のセッションストレージはありません)。セッションがすべてのアプリ間で共有される場合にのみ SL。
ゲートウェイの Cookie リレー。認証の真のソースとしてゲートウェイを使用しています。認証サーバーは、ブラウザーではなく Cookie を管理するため、必要なすべての状態を保持しています。ブラウザーに複数のサーバーからの Cookie が含まれることはありません。SL なし
トークンをグローバル認証として使用し、ユーザーが UI アプリからログアウトするときにトークンを無効にします。欠点: クライアントアプリによってトークンを無効にする必要がありますが、これは実際には意図されたものではありません。SL は可能ですが、通常の制約が適用されます。
authserver で(ユーザートークンに加えて)グローバルセッショントークンを作成および管理します。これは OpenId 接続 (英語) が採用したアプローチであり、SL にいくつかのオプションを提供しますが、いくつかの追加の機械が必要になります。通常の分散システムの制限から影響を受けるオプションはありません。ネットワークとアプリケーションノードが安定していない場合、必要に応じてすべての参加者間でログアウトシグナルが共有されるという保証はありません。ログアウト仕様はすべてドラフト形式のままであり、仕様へのリンクは次のとおりです: セッション管理 (英語) 、フロントチャンネルログアウト (英語) 、バックチャンネルログアウト (英語) 。
SL が困難または不可能な場合、すべての UI を単一のゲートウェイの背後に配置する方がよい場合があることに注意してください。次に、より簡単な GIA を使用して、不動産全体からのログアウトを制御できます。
GIA パターンにうまく当てはまる最も簡単な 2 つのオプションは、チュートリアルサンプルで次のように実装できます(oauth2
サンプルを取得して、そこから作業します)。
ブラウザーからの両方のサーバーのログアウト
UI アプリがログアウトされるとすぐに authserver からログアウトするコードをブラウザークライアントに追加するのは非常に簡単です。例:
logout() {
this.http.post('logout', {}).finally(() => {
self.authenticated = false;
this.http.post('http://localhost:9999/uaa/logout', {}, {withCredentials:true})
.subscribe(() => {
console.log('Logged out');
});
}).subscribe();
};
このサンプルでは、authserver ログアウトエンドポイント URL を JavaScript にハードコードしましたが、必要に応じて外部化するのは簡単です。セッション Cookie も一緒に送信するため、authserver に直接 POST する必要があります。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 がサンプルアプリで送信するヘッダーのみです。
Angular はクロスドメインリクエストで X-XSRF-TOKEN
ヘッダーを送信しないため、CORS 設定に加えて、ログアウトエンドポイントの CSRF を無効にする必要があります。authserver はこれまで CSRF 設定を必要としませんでしたが、ログアウトエンドポイントの無視を簡単に追加できます。
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.csrf()
.ignoringAntMatchers("/logout/**")
...
}
CSRF 保護をドロップすることは実際にはお勧めできませんが、この制限されたユースケースでは許容する準備ができているかもしれません。 |
UI アプリクライアントと authserver の 2 つの簡単な変更により、UI アプリからログアウトすると、再度ログインすると、常にパスワードの入力が求められることがわかります。
もう 1 つの便利な変更点は、OAuth2 クライアントを自動承認するように設定することです。これにより、ユーザーはトークンの付与を承認する必要がなくなります。これは、ユーザーが別のシステムとして認識しない内部認証サーバーでは一般的です。AuthorizationServerConfigurerAdapter
では、クライアントの初期化時にフラグが必要です。
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.inMemory().withClient("acme")
...
.autoApprove(true);
}
認証サーバーのセッションを無効化
ログアウトエンドポイントで CSRF 保護を放棄したくない場合は、他の簡単なアプローチを試すことができます。これは、トークンが許可されるとすぐに(実際は認証コードとなるとすぐに、認証サーバーのユーザーセッションを無効にする生成されます)。これも非常に簡単に実装できます。oauth2
サンプルから始めて、HandlerInterceptor
を OAuth2 エンドポイントに追加するだけです。
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints)
throws Exception {
...
endpoints.addInterceptor(new HandlerInterceptorAdapter() {
@Override
public void postHandle(HttpServletRequest request,
HttpServletResponse response, Object handler,
ModelAndView modelAndView) throws Exception {
if (modelAndView != null
&& modelAndView.getView() instanceof RedirectView) {
RedirectView redirect = (RedirectView) modelAndView.getView();
String url = redirect.getUrl();
if (url.contains("code=") || url.contains("error=")) {
HttpSession session = request.getSession(false);
if (session != null) {
session.invalidate();
}
}
}
}
});
}
このインターセプターは RedirectView
を探します。これは、ユーザーがクライアントアプリにリダイレクトされていることを示すシグナルであり、場所に認証コードまたはエラーが含まれているかどうかを確認します。暗黙的な許可も使用している場合は、"token =" を追加できます。
この簡単な変更により、認証するとすぐに、authserver のセッションはすでに停止しているため、クライアントからセッションを試行および管理する必要はありません。UI アプリからログアウトしてから再度ログインすると、認証サーバーはユーザーを認識せず、資格情報の入力を求めます。このパターンは、このチュートリアルのソースコード [GitHub] (英語) の oauth2-logout
サンプルによって実装されたものです。このアプローチの欠点は、本当のシングルサインオンがもはやないことです。システムの一部である他のアプリは、authserver セッションが停止していることを発見し、再度認証を要求する必要があります。複数のアプリがある場合の優れたユーザーエクスペリエンス。
結論
このセクションでは、OAuth2 クライアントアプリケーションからログアウトするためのいくつかの異なるパターンを実装する方法を見てきました(開始点としてチュートリアルのセクション V のアプリケーションを使用)。他のパターンのオプションについても説明しました。これらのオプションはすべてを網羅しているわけではありませんが、トレードオフの適切なアイデアと、ユースケースに最適なソリューションを検討するためのいくつかのツールを提供する必要があります。このセクションには JavaScript の行が 2、3 行しかなく、Angular に固有のものではなかったため(XHR リクエストにフラグを追加します)、このガイドのサンプルアプリの狭い範囲を超えてすべてのレッスンとパターンを適用できます。繰り返し発生するテーマは、複数の UI アプリがあり、単一の認証サーバーに何らかの欠陥がある傾向があるシングルログアウト(SL)へのすべてのアプローチです: できることは、ユーザーの不快感を最小限に抑えるアプローチを選択することです。内部 authserver と多くのコンポーネントで構成されるシステムがある場合、単一のシステムのようにユーザーに感じる唯一のアーキテクチャは、すべてのユーザーインタラクションのゲートウェイである可能性があります。
新しいガイドを作成したり、既存のガイドに貢献したいですか? 投稿ガイドラインを参照してください [GitHub] (英語) 。
すべてのガイドは、コード用の ASLv2 ライセンス、およびドキュメント用の Attribution、NoDerivatives creative commons ライセンス (英語) でリリースされています。 |