Angular実践 #2 認証と HTTP Interceptor
#1 プロジェクトの骨格 で、ルーティングとレイアウト、空のダッシュボード画面まで敷きました。とはいえログインのないダッシュボードは事実上、静的ページに過ぎません。今回はその上に 認証フロー を載せていきます — ログイン画面、トークンの保存、ルート保護、そしてすべてのリクエストにトークンを自動で付与する HTTP Interceptor まで。
バックエンドの前提 #
今回のコードは、次の 2 つのエンドポイントがあると仮定します。
POST /api/auth/login { email, password } → { accessToken: string }
GET /api/auth/me Authorization: Bearer ... → { id, email, name }モダン Python バックエンド (FastAPI) シリーズで作ったバックエンドでも、Django REST Framework で作ったものでも、形さえ同じなら問題なく繋がります。JWT (Access Token) をヘッダに乗せて送る、よくあるパターンですね。Refresh Token は最後に短い段落で触れるだけにします。environment.apiBase は #1 ですでに敷いてある前提です。
Login 画面 — Reactive Forms #
まずはログインコンポーネントを作ります。中級 #1 Reactive Forms で扱ったパターンそのまま、FormGroup 2 フィールド + 検証 + (ngSubmit) です。
import { Component, inject, signal } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { Router, ActivatedRoute } from '@angular/router';
import { AuthService } from '../../auth/auth.service';
@Component({
selector: 'app-login',
standalone: true,
imports: [ReactiveFormsModule],
templateUrl: './login.component.html',
})
export class LoginComponent {
private fb = inject(FormBuilder);
private auth = inject(AuthService);
private router = inject(Router);
private route = inject(ActivatedRoute);
protected readonly loading = signal(false);
protected readonly errorMsg = signal<string | null>(null);
protected readonly form = this.fb.nonNullable.group({
email: ['', [Validators.required, Validators.email]],
password: ['', [Validators.required, Validators.minLength(8)]],
});
submit() {
if (this.form.invalid) {
this.form.markAllAsTouched();
return;
}
this.loading.set(true);
this.errorMsg.set(null);
const { email, password } = this.form.getRawValue();
this.auth.login(email, password).subscribe({
next: () => {
const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl') ?? '/dashboard';
this.router.navigateByUrl(returnUrl);
},
error: (err) => {
this.errorMsg.set(err?.error?.message ?? 'ログインに失敗しました。');
this.loading.set(false);
},
});
}
}テンプレートは平凡です — 入力 2 つ、検証メッセージ、サーバーエラー、submit ボタンくらい。肝心なのは、このコンポーネントが自分の責任を正確に把握している点です。入力を受け取って AuthService.login() を呼び、成功したらルーターで次の画面に送る。トークンを保存したりヘッダを触ったりはしません。それは次の段階の責任です。
AuthService — トークンを持つ唯一の場所 #
認証状態はアプリ全体で 1 カ所に集めます。基礎 #5 Service と DI のパターンそのまま、providedIn: 'root' でシングルトンを作り、そこにトークンとユーザー情報をシグナルで保持します。
import { Injectable, computed, inject, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, tap } from 'rxjs';
import { environment } from '../../environments/environment';
export interface User {
id: number;
email: string;
name: string;
}
const TOKEN_KEY = 'auth_token';
@Injectable({ providedIn: 'root' })
export class AuthService {
private http = inject(HttpClient);
// アプリ起動時に localStorage からトークンを復元
private readonly _token = signal<string | null>(localStorage.getItem(TOKEN_KEY));
private readonly _user = signal<User | null>(null);
readonly token = this._token.asReadonly();
readonly user = this._user.asReadonly();
readonly isLoggedIn = computed(() => this._token() !== null);
login(email: string, password: string): Observable<{ accessToken: string }> {
return this.http
.post<{ accessToken: string }>(`${environment.apiBase}/auth/login`, { email, password })
.pipe(
tap((res) => this.setToken(res.accessToken)),
tap(() => this.fetchMe().subscribe()),
);
}
fetchMe(): Observable<User> {
return this.http
.get<User>(`${environment.apiBase}/auth/me`)
.pipe(tap((user) => this._user.set(user)));
}
logout(): void {
this._token.set(null);
this._user.set(null);
localStorage.removeItem(TOKEN_KEY);
}
private setToken(token: string): void {
this._token.set(token);
localStorage.setItem(TOKEN_KEY, token);
}
}3 点だけ押さえます。
_tokenはlocalStorageから アプリ起動時に一度だけ 復元します。リロードしてもログイン状態が維持される理由はこれです。- トークンを保存する経路は
setToken()ひとつだけです。シグナルと localStorage を 常に一緒に 更新するための小さなガードレールです。外部からシグナルを直接触れないように_tokenは private にし、外部にはasReadonly()だけを公開しています。 isLoggedInはcomputedで派生させます。トークンが変わると自動で更新されるので、ガードもヘッダコンポーネントも、同じ 1 つの真実を見ます。
localStorage に置くのは XSS に弱い という指摘が頻繁に出ます。より安全な選択肢はバックエンド側から HttpOnly; Secure; SameSite=Strict クッキーで返してもらう方法で、この場合クライアントはトークンを直接触る必要すらありません。ただし SPA + 別ドメインのバックエンド + モバイルアプリ共有のようなシナリオでは、ヘッダ + localStorage が依然としてよく使われるので、両者のトレードオフを意識して選んでください。本記事は最も一般的なヘッダ方式で進めます。ログインフロー — 1 行でおさらい #
ここまでのパーツを並べて流れをまとめると — /login のフォーム送信 → AuthService.login() → POST 成功時に tap がトークンをシグナル + localStorage に保存 → 続いて fetchMe() がユーザー情報を埋める → コンポーネントは returnUrl (またはデフォルトの /dashboard) に遷移。コンポーネントはトークンを直接触りません。 保存・復元の責任はすべて AuthService の中にあり、コンポーネントは単なる呼び出し側です。
Auth Guard — 保護されたルート #
トークンがなければ /dashboard に入れないようにブロックする必要がありますね。中級 #6 Guards で扱った関数型ガードのパターンをそのまま持ってきます。
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { AuthService } from './auth.service';
export const authGuard: CanActivateFn = (_route, state) => {
const auth = inject(AuthService);
const router = inject(Router);
if (auth.isLoggedIn()) {
return true;
}
// 元々向かっていた URL を returnUrl として保存
return router.createUrlTree(['/login'], {
queryParams: { returnUrl: state.url },
});
};ルートにはこう付けます。
import { Routes } from '@angular/router';
import { authGuard } from './auth/auth.guard';
export const routes: Routes = [
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
{
path: 'login',
loadComponent: () => import('./pages/login/login.component').then(m => m.LoginComponent),
},
{
path: 'dashboard',
canActivate: [authGuard],
loadComponent: () => import('./pages/dashboard/dashboard.component').then(m => m.DashboardComponent),
},
{ path: '**', redirectTo: 'dashboard' },
];未認証の状態で /dashboard/orders にアクセスすると、ガードが /login?returnUrl=%2Fdashboard%2Forders に送り、ログイン後は LoginComponent がその returnUrl を読み取って正確にその場所に戻します。ユーザー視点では 「お、また同じ画面に戻ってきた」 が自然ですね。
HTTP Interceptor — ヘッダの自動付与 #
次は認証付きリクエストの番です。呼び出しごとに headers: { Authorization: 'Bearer ...' } を手で付けるのは答えではありません。基礎 #7 で 1 行で紹介した 関数型 Interceptor が、まさにこの場面で輝きます。
import { inject } from '@angular/core';
import { HttpInterceptorFn } from '@angular/common/http';
import { AuthService } from './auth.service';
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const token = inject(AuthService).token();
// ログインリクエスト自体にはトークンを付けない
if (!token || req.url.includes('/auth/login')) {
return next(req);
}
const cloned = req.clone({
setHeaders: { Authorization: `Bearer ${token}` },
});
return next(cloned);
};HttpRequest は 不変 (immutable) なので、直接修正せずに req.clone() で新しいリクエストを作ったうえで next() に流します。登録は app.config.ts 1 行。
import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { routes } from './app.routes';
import { authInterceptor } from './auth/auth.interceptor';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
provideHttpClient(withInterceptors([authInterceptor])),
],
};この 1 行でアプリの すべての HTTP リクエストにトークンが自動で付きます。UserService、OrderService、これから作るすべてのサービスは、この事実を意識する必要なく単に http.get() を呼ぶだけで済みます。
401 の自動処理 — 期限切れトークンを掃除する #
トークンは期限切れになることもあれば、バックエンド側で無効化されることもあります。その結果は通常 401 Unauthorized で返ってきます。Interceptor はレスポンスも横取りできるので、その場で一括処理しましょう。
import { inject } from '@angular/core';
import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http';
import { Router } from '@angular/router';
import { catchError, throwError } from 'rxjs';
import { AuthService } from './auth.service';
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const auth = inject(AuthService);
const router = inject(Router);
const token = auth.token();
const outgoing =
!token || req.url.includes('/auth/login')
? req
: req.clone({ setHeaders: { Authorization: `Bearer ${token}` } });
return next(outgoing).pipe(
catchError((err: HttpErrorResponse) => {
if (err.status === 401 && !req.url.includes('/auth/login')) {
auth.logout();
router.navigateByUrl('/login');
}
return throwError(() => err);
}),
);
};期限切れトークンで何かの画面を見ているときに API が 401 を返してきたら、すべてのコンポーネントはその事実を知らないまま きれいにログイン画面に戻ります。401 処理のコードをサービスごとに繰り返す必要がなくなります。これが Interceptor の本質です — 横断的関心事 (cross-cutting concern) を 1 カ所にまとめる。
ユーザー情報の表示 — ヘッダの名前とログアウト #
先ほど AuthService.user() にユーザー情報を入れたので、ヘッダコンポーネントはそれをそのまま使えばよいです。
import { Component, inject } from '@angular/core';
import { Router } from '@angular/router';
import { AuthService } from '../auth/auth.service';
@Component({
selector: 'app-header',
standalone: true,
template: `
<header class="app-header">
<h1>Dashboard</h1>
@if (auth.user(); as user) {
<div class="user-area">
<span>{{ user.name }}</span>
<button type="button" (click)="logout()">ログアウト</button>
</div>
}
</header>
`,
})
export class HeaderComponent {
protected auth = inject(AuthService);
private router = inject(Router);
logout() {
this.auth.logout();
this.router.navigateByUrl('/login');
}
}@if (auth.user(); as user) 1 行で — ユーザー情報が埋まっているときだけ、名前とログアウトボタンが表示されます。シグナルが変わるとテンプレートが自動で更新されるので、subscribe も OnInit も不要です。
ただしリロード直後はトークンは復元されているけれど、user はまだ空という瞬間があります。通常は AppComponent で アプリ起動時に一度だけ fetchMe() を呼びます。
export class AppComponent {
private auth = inject(AuthService);
constructor() {
if (this.auth.isLoggedIn()) {
this.auth.fetchMe().subscribe({
error: () => this.auth.logout(), // トークンが死んでいたら片付ける
});
}
}
}トークンだけ生きていてバックエンドで無効化されたケースも、ここで一気に拾えます。
Refresh Token — パターン紹介 #
Access Token の寿命は短いほどセキュリティ上は有利ですが、ユーザーから見ると頻繁にログアウトされて不便ですね。そこでよく Refresh Token をペアで持ち、Access Token が切れたら Refresh で静かに新しい Access を受け取って続行します。
Interceptor の中で 401 を受け取ったとき、すぐにログアウトする代わりに /api/auth/refresh を一度試して、成功したら 元々失敗したリクエストを新しいトークンで再送する パターンが標準です。並行して飛んでくるリクエストに備えて、RxJS の Subject で更新リクエストを 1 度だけ流して残りは結果を待たせる、というディテールも付いてきます。コードがそこそこ長くなるので本記事では分量の都合で扱いませんが、「401 を見たら無条件ログアウト」が第 1 段階 → 「一度はリフレッシュを試す」が次の段階 という流れだけ頭に入れておいてください。
まとめ #
今回は #1 の空の骨格に、認証フローを埋め込みました。
- Login 画面: Reactive Forms +
AuthService.login()の呼び出し役だけ - AuthService: トークン・ユーザーをシグナルで持つ唯一の場所、localStorage で永続化
- Auth Guard: 関数型
CanActivateFn、未認証ならreturnUrlを保存して/loginへ - HTTP Interceptor: すべてのリクエストに
Bearerヘッダを自動付与 + 401 で自動ログアウト - ヘッダ:
auth.user()シグナルをそのままバインド、リロード時にはfetchMe()で復元
ルーティング・レイアウト・認証まで敷けたので、次の段階は本物のデータです。次回 「Angular実践 #3 フォームと API CRUD」 では、保護された領域の中でリソース 1 つ (例: posts または tasks) を CRUD する流れを最後まで追います — 一覧取得、詳細 + 編集フォーム、作成 / 修正 / 削除、そして楽観的更新 (optimistic update) まで。認証付きリクエストが自動で通る環境で、ドメインロジックに集中する番です。