Express + Passport と Angular でセッション管理するアプリを作ってみる

サーバサイドは Express で、クライアントサイドは Angular で作り、クライアントでログイン処理をしたユーザのみが、ある API にアクセスできるようにしたいと思った。よくあるログイン処理とセッション管理をサーバサイドで行いたい、ということだ。

Express でそのようなログイン処理やセッション管理を行うには、Passport というライブラリを使うのがよくあるやり方みたいなので、試してみた。色々と詰まるところがあったので、困ったところのまとめを中心にメモ。

目次

使用するパッケージのインストールと設定

色々試行錯誤した結果、Express + Passport な環境を作るには、以下のモジュール群をインストールする必要があった。

$ npm install --save express body-parser passport passport-local express-session cookie-parser

で、まずはセッション管理するための準備をするために、以下までを Express のメイン処理として実装した。

const express = require('express');
const bodyParser = require('body-parser');
const passport = require('passport');
const LocalStrategy = require('passport-local').Strategy;
const session = require('express-session');
const cookieParser = require('cookie-parser');

// サーバをインスタンス化する
const app = express();

// クッキー設定
app.use(cookieParser());

// セッション設定
app.use(session({
  secret: 'SessionKey',      // クッキーの暗号化に使用するキー
  resave: false,             // セッションチェックする領域にリクエストするたびにセッションを作り直してしまうので false
  saveUninitialized: false,  // 未認証時のセッションを保存しないようにする
  cookie: {
    maxAge: 1000 * 60 * 60 * 24 * 7,  // クッキーの有効期限をミリ秒指定 (1週間)
    secure: false                     // HTTP 利用時は false にする
  }
}));

// Passport の初期設定
app.use(passport.initialize());
app.use(passport.session());

// Angular と POST データを受け取るための設定を行う
app.use(bodyParser.urlencoded({
  extended: false
}));
app.use(bodyParser.json());

// CORS を許可する
app.use((_req, res, next) => {
  res.header('Access-Control-Allow-Origin', 'http://localhost:4200');  // 開発環境で CORS を許可するために入れておいた
  res.header('Access-Control-Allow-Credentials', true);
  res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept');
  res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
  next();
});

// TODO : 1. Passport 認証処理を定義する
// TODO : 2. ログイン用のルーティングを定義する
// TODO : 3. 事前にログイン認証が必要な API のルーティングを定義する
// TODO : 4. ログアウト用のルーティングを定義する

// サーバ起動
const port = process.env.PORT || 8080;
const server = app.listen(port, () => {
  console.log(`Listening at http://localhost:${port}`);
});

既に Passport の初期設定とやらが入っているが、initialize()session() は呼び出しておくだけみたい。正直よく分からん。w

以降、コード中に書いた4つの TODO 事項を埋めていく形で実装していく。

Passport の認証処理を実装する

まずは、// TODO : 1. Passport 認証処理を定義する コメントの部分に該当する、Passport による認証ロジックを実装する。

// 認証ロジック
passport.use('local', new LocalStrategy({
  usernameField: 'userName',  // POST の body から参照するフィールド名を指定する
  passwordField: 'password',  // POST の body から参照するフィールド名を指定する
  session: true,              // セッションを有効にする
  passReqToCallback: true     // 次のコールバック関数の第1引数に request を渡す
}, (_req, userName, password, done) => {
  // ココでは userName と password が固定値と合致すれば、ログインして良いユーザと見なす
  // (実際は DB のデータと照合したりして実装する)
  
  if(userName === 'ExampleUser' && password === 'MyPassword') {
    // セッションに保存したい情報を用意する
    const userInfo = {
      id: 1,
      userName: userName
    };
    // 認証成功・第2引数で渡す内容がシリアライズされる
    return done(null, userInfo);
  }
  else {
    console.error('認証処理 : 失敗');
    return done(null, false);
  }
}));

// シリアライズ処理
passport.serializeUser((userInfo, done) => {
  done(null, userInfo);
});
  
// デシリアライズ処理
passport.deserializeUser((userInfo, done) => {
  done(null, userInfo);
});

今回はサンプルなので、POST 送信された userNamepassword が、指定の固定値と合致すれば認証成功と見なす。認証の成否の決め方もザックリと、done() の第2引数に false を渡せば認証失敗、それ以外の情報を渡すと認証成功とする (実際は失敗時のハンドリングがもう少しできるが)。

done() の第2引数に渡した変数 userInfo がセッションに保存されるのだが、セッションへの書き出しを serializeUser()、セッションからの情報取得を deserializeUser() 関数で定義しておく必要がある。それぞれの関数はお決まりの書き方なのでこのまま。

ログイン用のルーティングを実装する

次に、// TODO : 2. ログイン用のルーティングを定義する コメント部分に該当する、ログイン用のルーティングを定義する。

ログインするためのルーティング自体には、認証せずともアクセスできなくてはならない。そのルーティングにアクセスすることで、先程実装した認証ロジックを呼び出すように実装する。

// ログイン
app.post('/login', passport.authenticate('local', { session: true }), (req, res) => {
  // passport.use('local') で定義した認証処理が成功したらこの関数が実行される
  res.json({ result: 'Login Success' });
});

/login というパスに POST 通信してきた時のルーティングとして実装する。第2引数は passport.authenticate() で、先程実装した認証ロジックを利用するよう、passport.use('local') と第1引数で指定した 'local' を指定する。第2引数で session: true にしているように、セッションを有効にしている。この辺はなんか受け売りで実装している。

第3引数が、通常の Express のルーティングでよく見る関数と同じモノになるのだが、この関数は、passport.use('local') で定義した認証ロジックで認証が成功した場合のみ辿り着く。認証に失敗した場合は呼ばれないことに留意。

事前にログイン認証が必要な API を実装する

続いて、// TODO : 3. 事前にログイン認証が必要な API のルーティングを定義する コメント部分に該当するルーティングを実装する。アプリ側で事前に /login と POST 通信して認証に成功していないと呼び出せない URL を定義する、ということだ。

// 遷移時に認証チェックを行う関数
function isLogined(req, res, next) {
  if(req.isAuthenticated()) {
    // 既に認証済みなら対象の URL へのアクセスを許可する
    next();
  }
  else {
    console.error('認証未済', error);
    // Angular の HttpClient でエラーコールバックに反応させるため 401 を返す
    res.status(401);
    // HttpClient のエラー時に取得できるエラーメッセージを返す
    res.send({
      error: '認証してください'
    });
  }
}

// 事前に認証しておかないとデータを取得できない API を作る
router.get('/products', isLogined, (req, res) => {
  res.status(200);
  // 今回はダミーで固定値を返す。実際は DB から取得した値などを返すイメージ
  res.json({
    products: [
      { id: 1, name: '製品 1', price: 500 },
      { id: 2, name: '製品 2', price: 800 },
      { id: 3, name: '製品 3', price: 720 }
    ]
  });
});

isLogined という自前の関数を用意した。中では req.isAuthenticated() という関数によって、認証済みかどうかを検証している。この req.isAuthenticated() という関数は passport-local が提供しているようで、シリアライズされたセッションデータと合致するクライアントからのリクエストかどうかを検証しているようだ。このあたりは各関数の先頭に console.log() を仕込んで、実際にアクセスした時にどのような順番で関数が動いているか確認した方が分かりやすい。

ログアウト用のルーティングを用意する

最後に // TODO : 4. ログアウト用のルーティングを定義する コメント部分に相当する、ログアウト用の URL を定義しておく。

// ログアウト
router.get('/logout', (req, res) => {
  req.logout();
  res.json({ result: 'Logout Success' });
});

ログアウト時も、ログイン時と同じように、isLogined のような認証用の関数は仕込まない。ログアウトさせたい時は req.logout() を実行する。コレでサーバ側のセッションでシリアライズされていたデータが消去できる。

以上で、サーバサイドの実装は完了だ。

Angular 側でログイン画面の実装

さて、サーバサイドは

という仕様で実装が完了した。

このようなサーバに対し、Angular アプリからどうやってアクセスするかをまとめる。Angular は v7.0.2、HttpClient を使用する。Angular CLI の ng new でプロジェクト雛形をサクッと作った直後の状態から説明する。

今回は簡単のため画面遷移を行わず、ログインフォームからログイン認証ができたらメッセージを表示する作りにしようと思う。/products と GET 通信するボタンはずっと表示されているが、先にログインできていないと押しても動作しない作りだ。

import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { HttpClientModule } from '@angular/common/http';
import { AppComponent } from './app.component';

@NgModule({
  imports: [
    BrowserModule,
    CommonModule,
    FormsModule,
    HttpClientModule
  ],
  declarations: [AppComponent],
  bootstrap   : [AppComponent]
})
export class AppModule { }
<!-- ログインフォーム -->
<dl>
  <dt>ユーザ名</dt>
  <dd><input type="text" [(ngModel)]="userName"></dd>
  <dt>パスワード</dt>
  <dd><input type="password" [(ngModel)]="password"></dd>
</dl>
<p><button type="button" (click)="onLogin()">ログイン</button></p>

<p *ngIf="loginedMessage">   {{ loginedMessage }}   </p>  <!-- ログイン成功時のメッセージ -->
<p *ngIf="loginErrorMessage">{{ loginErrorMessage }}</p>  <!-- ログイン失敗時のメッセージ -->

<!-- /products へアクセスするボタン -->
<p><button type="button" (click)="findProducts()">製品一覧を取得する</button></p>
<!-- 製品一覧を取得したら表示する -->
<ul *ngIf="products && products.length">
  <li *ngFor="let product of products">{{ product.id }} : {{ product.name }} : {{ product.price }}</li>
</ul>
<!-- 製品一覧の取得に失敗した時のメッセージ -->
<p *ngIf="findProductsErrorMessage">{{ findProductsErrorMessage }}</p>

<!-- ログアウトボタン -->
<p><button type="button" (click)="onLogout()">ログアウト</button></p>
import { Component } from '@angular/core';
import { HttpClient } from '@angular/common/http';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss']
})
export class AppComponent {
  public userName: string = '';
  public password: string = '';
  
  public loginedMessage: string = '';
  public loginErrorMessage: string = '';
  
  public products: any[] = [];
  public findProductsErrorMessage: string = '';
  
  constructor(private httpClient: HttpClient) { }
  
  public onLogin(): void {
    // フィードバックメッセージのリセット
    this.loginedMessage = this.loginErrorMessage = '';
    // Express サーバに POST 通信する。リクエストボディのプロパティ名は passport.use('local') で定めたモノに合わせる
    // 第3引数の withCredentials はログイン時から全ての通信で必須
    this.httpClient.post('http://localhost:8080/login', {
      userName: this.userName,
      password: this.password
    }, { withCredentials: true }).toPromise()
      .then((result) => {
        this.loginedMessage = 'ログインしました';
      })
      .catch((error) => {
        this.loginErrorMessage = `ログイン失敗 : ${JSON.stringify(error)}`;
      });
  }
  
  public findProducts(): void {
    this.products = [];
    this.findProductsErrorMessage = '';
    
    this.httpClient.get('http://localhost:8080/products', { withCredentials: true }).toPromise()
      .then((results) => {
        this.products = results;
      })
      .catch((error) => {
        this.findProductsErrorMessage = `製品一覧取得に失敗 : ${JSON.stringify(error)}`;
      });
  }
  
  public onLogout(): void {
    this.httpClient.get('http://localhost:8080/logout', { withCredentials: true }).toPromise()
      .then((_result) => {
        this.loginedMessage = '';
      });
  });
}

少々コードが長くなったが、ココで重要なのは HttpClient#get()HttpClient#post() で通信する際にオプションで指定している、withCredentials: true。コレをログイン時から全ての通信で有効にしておかないと、クライアントでクッキーによるセッション管理ができない。Angular は HTML ファイルが切り替わるような画面遷移が発生しないこともあり、このような特殊な設定を入れないとセッション管理ができないようだ。

withCredentials 指定を HttpInterceptor に任せる

サーバとの通信時にセッション管理を有効にするため、withCredentials: true の指定が必要なのは分かったが、いちいち書くのは面倒くさい。

そこで、HttpInterceptor というモノを作って、デフォルトで全ての通信に withCredentials: true 指定をしてやる。こうすれば、HttpClient#get() などを記述する際はこのオプションの記述が不要になる。

import { Injectable } from '@angular/core';
import { HttpInterceptor, HttpHandler, HttpEvent, HttpRequest } from '@angular/common/http';

import { Observable } from 'rxjs';

@Injectable({
  providedIn: 'root'
})
export class CustomInterceptor implements HttpInterceptor {
  public intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
    // withCredentials 指定を全てのリクエストに設定する
    request = request.clone({
      withCredentials: true
    });
    return next.handle(request);
  }
}
import { HTTP_INTERCEPTORS } from '@angular/common/http';

import { CustomInterceptor } from './custom-interceptor';

@NgModule({
  imports: [
    // 中略
  ],
  providers: [
    // インターセプタを組み込む
    {
      provide: HTTP_INTERCEPTORS,
      useClass: CustomInterceptor,
      multi: true
    }
  ]
})
// 以下略

このとおり。

以上。しかし…

以上で、サーバサイド、クライアントサイドともに実装が完了した。ローカル開発環境で試した限り、ng serve で起動した開発サーバの Angular アプリから、Express サーバへと通信し、ログインしたユーザ情報がセッション管理されることが確認できた。

しかし、このようなアプリを Heroku にデプロイしてみたところ、うまくいかないことがあった。

Heroku アプリの URL は https://example.herokuapp.com/ と HTTPS でアクセスできるため、Express サーバの初期設定時に行っていたセッション設定で、secure: true にしないといけないかと思っていた。

// セッション設定
app.use(session({
  secret: 'SessionKey',
  resave: false,
  saveUninitialized: false,
  cookie: {
    maxAge: 1000 * 60 * 60 * 24 * 7,
    secure: true  // ← ココ! HTTP 利用時は false にするモノなので、Heroku デプロイ時は true にしていた
  }
}));

しかし、この cookie.securetrue にすると、Heorku 上で上手くセッションが保持されなかった。

また、Heroku にデプロイした時に $ heroku logs を見ていると、Express から次のようなワーニングが出力されていた。

Warning: connection.session() MemoryStore is not
designed for a production environment, as it will leak
memory, and will not scale past a single process.

どうも、express-session と passport-local を併用した、メモリ上でのセッション管理 (= データストア) は、本番運用向きではなく、メモリリークの危険もあるらしい。

調べたところ、本番環境でこうしたセッション管理を行うには、MongoDB や Redis などの DB を使用することが多く、メモリキャッシュするにしても Memcached といったツールを使ったりするようだ。

正直このあたりのサーバサイドの知識に疎い。フロントエンドオンリーでやってきたツケが回ってきた感じ…。

ただ、今のところ、僕一人が個人で動かしている限りは、先程の cookie.securefalse にしたままであれば、Heroku 上でも上手くセッション管理できているようなので、とりあえずはこのまま運用しようかなと思う。

この辺ちゃんと勉強しておかないと、本格的に Web サービスを運営したりはできないよなぁ…。焦る。

参考文献

Express も Passport も、それなりに長く使われているパッケージなので、文献で採用するバージョンによって書き方がちょっとずつ違ったりで困った…。