Chapter 40

security-authentication

kisihara.c
kisihara.c
2021.07.13に更新

認証

認証はほとんどのアプリで不可欠だ。認証を扱うアプローチ・戦略は幅広く、要件による。この章では様々な要件に対応できる認証のアプローチをいくつか紹介する。

Passportは最も人気のあるnode.jsの認証ライブラリで、多くの製品で使われている。このライブラリをNestアプリケーションに統合するには、@nestjs/passportモジュールを使うと簡単だ。抽象的には(At a high level)、Passportは次のようなステップを実行する。

  • ユーザの「認証情報」(ユーザ名/パスワード、JSON Web Token(JWT)、IDプロバイダからのIDトーク等)を確認してユーザを認証する。
  • 認証された状態を管理する(JWTなどのポータブルトークンを発行したり、Expressセッションを作成したりする)
  • 認証されたユーザに関する情報をRequestオブジェクトに添付し、ルートハンドラでさらに使用する。

Passportには、認証メカニズムを実装する様々なストラテジーの豊富なエコシステムがある。コンセプトはシンプルだが、選択できるPassportストラテジーのセットは多様だ。Passportはこれらの多様なステップを標準的なパターンに抽象化し、@nestjs/passportモジュールはこのパターンをお馴染みのNestコードに纏めて標準化している。

本章ではこれらの強力で柔軟なモジュールを使用して、RESTful APIサーバーの完全なエンドツーエンドの認証ソリューションを実装する。ここで説明する概念を使って、任意のPassportストラテジを実装し、認証スキームをカスタマイズする事ができる。この章の手順に沿えば完全なサンプルを構築する事ができる。サンプルアプリのあるリポジトリはこちら

認証の要件

要件を具体化しよう。今回のユースケースでは、クライアントはまずユーザー名とパスワードで認証を行う。認証されると、サーバーはJWTを発行する。このJWTは認証を証明する為に、後続のリクエストの認証ヘッダ内のBearerトークンとして送信する事ができる。また、有効なJWTを含むRequestのみにアクセス可能な保護されたルートを作成する。

まず、最初の要件であるユーザの認証から始める。次に、JWTを発行して、それを拡張する。最後に、リクエストに有効なJWTが含まれているかどうかをチェックする保護されたルートを作成する。

必要なパッケージをインストールしよう。Passportにはユーザー名とパスワードによる認証メカニズムを実装したpassport-localというストラテジがあり、今回使えそうだ。

$ npm install --save @nestjs/passport passport passport-local
$ npm install --save-dev @types/passport-local

NOTICE
どのようなPassportストラテジを選択しても、@nestjs/passport及びpassportパッケージが必要となる。次に構築しようとしている特定の認証ストラテジを実装する固有のパッケージ(passport-jwtpassport-local等)インストールする必要がある。また、上記の@types/passport-localのように、任意のPassportストラテジの型定義をインストールする事で、TypeScriptコードを書く際の補助となる。

Passportストラテジの実装

これで、認証機能を実装する準備が整った。まず使用するプロセスの概要を説明する。Passportはそれ自体が小さなフレームワークと考えると通りが良い。このフレームワークの優れた点は、認証プロセスをいくつかの基本的なステップに抽象化し、実装するストラテジに応じてカスタマイズできる事だ。フレームワークは拡張パラメータ(プレーンJSON)とコールバック関数の形でカスタムコードを提供する事で構成される。Passportは適切なタイミングでそれを呼び出す事ができる。@nestjs/passportモジュールは、このフレームワークをNestスタイルのパッケージで包み、Nestアプリケーションに簡単に統合できるようにしている。以下では@nestjs/passportを使用するが、まずは生のPassportの仕組みを考えてみよう。

生のPassportでは、以下の2つを提供する事でストラテジを設定する。

  • ストラテジに固有のオプションのセット。例えば、JWTストラテジでトークンに署名する為のシークレットを提供できる。
  • 「検証コールバック」:ユーザストア(ユーザアカウントを管理する場所)とどのようにやり取りするかをPassportに伝える場所だ。ここではユーザが存在するかどうか(または新しいユーザを作成するかどうか)、及びその資格情報が有効であるかどうかを検証する。Passportライブラリは、このコールバックが、検証が成功した場合は完全なユーザを返し、失敗した場合はnullを返すと仮定して動く。失敗とは、ユーザが見つからないか、passport-localの場合はパスワードが一致しないことと定義される。

@nestjs/passportでは、PassportStrategyクラスを拡張してPassportストラテジを構成する。サブクラスのsuper()メソッドを呼び出しオプションオブジェクトを渡すことで、ストラテジのオプション(上記項目1)を渡せる。validate()メソッドをサブクラスに実装する事で、検証コールバック(上記項目2)を提供できる。

まずはAuthMobileを生成し、その中にAuthServiceを生成する事から始めよう。

$ nest g module auth
$ nest g service auth

これらの生成されたファイルの内容を以下のように置き換える。今回のサンプルアプリでは、UsersServiceはシンプルにする。メモリ上にハードコードされたユーザのリストと、ユーザ名でユーザを検索するfindメソッドを保持させる。実際にアプリではここでユーザモデルと永続化レイヤーを構築し、好みのライブラリを使う(TypeORM、Sequelize、Mongoose等)。

users/users.service.ts
import { Injectable } from '@nestjs/common';

// ここはユーザーエンティティを表す実際のクラス/インターフェイスにしてほしい
export type User = any;

@Injectable()
export class UsersService {
  private readonly users = [
    {
      userId: 1,
      username: 'john',
      password: 'changeme',
    },
    {
      userId: 2,
      username: 'maria',
      password: 'guess',
    },
  ];

  async findOne(username: string): Promise<User | undefined> {
    return this.users.find(user => user.username === username);
  }
}

UserModuleで必要な処理は、@Moduleデコレータのexports配列にUsersServiceを追加して、このモジュールの外から見えるようにする事だけだ(すぐにAuthServiceで使う)。

users/users.module.ts
import { Module } from '@nestjs/common';
import { UsersService } from './users.service';

@Module({
  providers: [UsersService],
  exports: [UsersService],
})
export class UsersModule {}

AuthServiceはユーザを取得してパスワードを検証する役割を持っている。この目的のためにvalidateUser()メソッドを作成している。以下のコードでは、ES6の便利なspread演算子を使って、userオブジェクトからpasswordプロパティを取り除いて返している。後でpassport localストラテジからvalidateUser()メソッドを呼び出してみよう。

auth/auth.service.ts
import { Injectable } from '@nestjs/common';
import { UsersService } from '../users/users.service';

@Injectable()
export class AuthService {
  constructor(private usersService: UsersService) {}

  async validateUser(username: string, pass: string): Promise<any> {
    const user = await this.usersService.findOne(username);
    if (user && user.password === pass) {
      const { password, ...result } = user;
      return result;
    }
    return null;
  }
}

WARNING
もちろん実際のアプリケーションではパスワードを平文で保存する事はない。bcryptなどのライブラリを使い、ソルト化済の一方向性ハッシュアルゴリズムを使用する。この方法ではハッシュ化されたパスワードのみを保存し、保存されたパスワードとハッシュ化された入力パスワードを比較する為、ユーザのパスワードを平文で保存・公開する事はない。サンプルアプリでは、シンプル性の為にこの絶対的義務に違反している。実際のアプリではしないでほしい!

次に、AuthModuleを更新してUsersModuleをインポートする。

auth/auth.module.ts
import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { UsersModule } from '../users/users.module';

@Module({
  imports: [UsersModule],
  providers: [AuthService],
})
export class AuthModule {}

Passport localを実装する

ではPassportのローカル認証ストラテジを実装しよう。local.strategy.tsファイルをauthフォルダに入れ、以下のコードを追加する。

auth/local.strategy.ts
import { Strategy } from 'passport-local';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { AuthService } from './auth.service';

@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
  constructor(private authService: AuthService) {
    super();
  }

  async validate(username: string, password: string): Promise<any> {
    const user = await this.authService.validateUser(username, password);
    if (!user) {
      throw new UnauthorizedException();
    }
    return user;
  }
}

全てのPassportストラテジは前述のレシピに従っている。passport-localの仕様例では設定オプションがないため、コンストラクタでは単にsuper()を呼び出し、オプションオブジェクトを指定しない。

HINT
パスポートストラテジの動作をカスタマイズする為に、super()への呼び出しでオプションオブジェクトを渡す事ができる。この例ではpassport-localストラテジはデフォルトでrequestの本文にusernamepasswordプロパティの存在を予想している。異なるプロパティ名を指定するには、オプションオブジェクトを渡す必要がある(例:super({ usernameField: 'email' }))。詳細はPassportのドキュメントにて。

validate()メソッドも実装済みだ。各ストラテジについて、Passportはストラテジ固有の適切なパラメータセットを使用して、バリデーション関数(@nestjs/passportvalidate()メソッドで実装)を呼び出す。local-strategyの場合、Passportはvalidate(username: string, password:string): anyのシグネチャを持つvalidate()メソッドを待機している。

ほとんどの検証作業はAuthServiceで(UsersServiceの助けを借りて)行われるので、このメソッドは非常にシンプルだ。どのPassportストラテジのvalidate()メソッドも似たようなパターンで、資格の表現方法の詳細が異なるだけだ。ユーザが見つかり、認証情報が有効であればユーザが返され、Passportがタスク(例:Requestオブジェクトのuserプロパティの作成)を完了できるようになり、リクエスト処理のパイプラインが続くようになる。見つからない場合は例外を投げて例外レイヤーに処理させる。

一般的には、各ストラテジのvalidate()メソッドの唯一の大きな違いは、ユーザの存在・有効性の判断の方法だ。例えばJWTストラテジでは、要件に応じて、デコードされたトークンに含まれるuserIdが、ユーザデータベースのレコードや無効化されたトークンのリストと一致するかどうかを評価する。こういった事で、サブクラス化してストラテジ固有の検証を実装する仕組みは、一貫性があってエレガントで拡張性が高い。

さっき定義したPassportの機能を使用するために、AuthModuleを設定する必要がある。auth.module.tsを以下のようにアップデートする。

auth/auth.module.ts
import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { UsersModule } from '../users/users.module';
import { PassportModule } from '@nestjs/passport';
import { LocalStrategy } from './local.strategy';

@Module({
  imports: [UsersModule, PassportModule],
  providers: [AuthService, LocalStrategy],
})
export class AuthModule {}

組み込みパスポートガード

ガードの章では、リクエストがルートハンドラで処理されるかどうか、ガードの主な機能として判断できる事を説明した。勿論そのまま使えるが、@nestjs/passportモジュールを使うにあたり、最初は少し戸惑うような新機能を紹介する。認証の観点から、アプリに2つの状態があるとしよう。

  • ユーザ/クライアントがログインしていない状態(認証なし)
  • ユーザ/クライアントがログインしている状態(認証された)

前者のケースでは2つの異なる機能を実行する必要がある。

  • 認証されていないユーザがアクセスできるルートを制限する(すなわち、制限されたルートへのアクセスを拒否する)。その為にお馴染みのガードを使う。保護されたルートにガードを配置する。想像通り、このガードを使って有効なJWTの存在をチェックするので、JWTの発行に成功した後、このガードについて後で確認する。
  • 認証されていないユーザがログインしようとしたときに認証ステップを開始する。有効なユーザにJWTを発行するステップだ。少し考えると、認証の為にユーザ名/パスワードの認証情報をPOSTする必要がある事がわかるので、POST/auth/loginルートを設定する。ここで疑問が生じる。このルートでは、具体的にどのようにしてpassport-localストラテジを呼び出すんだろう?

答えはシンプル、別の少し異なるタイプのガードを使う事だ。@nestjs/passportモジュールは、これを行う組み込みのガードを提供している。このガードはPassportストラテジを起動し、上述のステップ(認証情報の取得、verify関数の実行、ユーザプロパティの作成等)を開始する。

なお後者のケース(ログイン済ユーザ)については、シンプルにログインユーザの為の保護されたルートにアクセスできるガードに頼ればいい。説明済の標準タイプのガードだ。(訳出困難につき原文:The second case enumerated above (logged in user) simply relies on the standard type of Guard we already discussed to enable access to protected routes for logged in users.)

ログインルート

ストラテジができたので、今度はシンプルな/auth/loginルートを実装し、組み込みのガードを適用してpassport-localフローを準備してみよう。

app.controller.tsファイルを開き、内容を以下のように置き換える。

import { Controller, Request, Post, UseGuards } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Controller()
export class AppController {
  @UseGuards(AuthGuard('local'))
  @Post('auth/login')
  async login(@Request() req) {
    return req.user;
  }
}

@UseGuards(AuthGuard('local'))では、passport-localストラテジを拡張した際に@nestjs/passport自動で用意してくれるAuthGuardを利用している。精査してみよう。Passport localストラテジには、デフォルトでは'local'という名前がついている。その名前を@UseGurads()デコレータで参照し、passport-localパッケージで提供されるコードと関連付ける。これは、アプリ内に複数のPassportストラテジがある場合に、どのストラテジを呼び出すかを明白にする為のものだ(それぞれのストラテジがそれぞれのAuthGuardを提供する可能性がある)。今の所ストラテジは1つしかないが、まもなく2つ目を追加する予定なので必要となる。

ルートをテストする為に、今回は/auth/loginルートが単にユーザを帰すようにする。これはPassportのもう一つの機能で、Passportはvalidate()メソッドから返された値に基づいてuserオブジェクトを自動的に作成し、それをreq.userとしてRequestオブジェクトに割り当てる。後でこれを、代わりにJWTを作成して返すコードに置き換えてみる。

これらはAPIルートだから、例のcURLを使ってテストしよう。UsersSerivceにハードコードされているuserオブジェクトを使ってテストできる。

$ # POST to /auth/login
$ curl -X POST http://localhost:3000/auth/login -d '{"username": "john", "password": "changeme"}' -H "Content-Type: application/json"
$ # result -> {"userId":1,"username":"john"}

問題なく動くが、AuthGuard()にストラテジ名を渡すとソースコードに魔法の文字列が混入してしまう。代わりに独自のクラスを作成しよう。

auth/local-auth.guard.ts
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class LocalAuthGuard extends AuthGuard('local') {}

これで、/auth/loginルートハンドラを更新して、代わりにLocalAuthGuardを使える。

@UseGuards(LocalAuthGuard)
@Post('auth/login')
async login(@Request() req) {
  return req.user;
}

JWTの機能

認証システムのJWT関係に進む準備ができた。要件を確認し、改善していこう。

  • ユーザがユーザ名/パスワードで認証を行う事と、続けて保護済APIエンドポイントを呼び出す際使うJWTの返却を許可しよう。この要件は順調に実装できており、残り作業としてJWTを発行するコードを書く必要がある。
  • bearerトークンとして有効なJWTの存在に基づいて保護されたAPIルートを作成しよう。

JWTの要件をサポートするために、さらにいくつかのパッケージをインストールする必要がある。

$ npm install --save @nestjs/jwt passport-jwt
$ npm install --save-dev @types/passport-jwt

nestjs/jwtパッケージ(詳細)は、JWTの操作を支援するユーティリティパッケージだ。passport-jwtパッケージはJWTストラテジを実装するPassportパッケージで、@types/passport-jwtパッケージはTypeScriptの型定義を提供する。

POST/auth/loginリクエストがどのように処理されるかを詳しく見てみよう。我々はpassport-localストラテジで提供される組み込みのAuthGuardを使ってルートを装飾しているところだ。これは次を意味する。

  • ルートハンドラはユーザが認証された場合にのみ呼び出される
  • reqパラメータにはuserのプロパティが含まれる(passportがpassport-local認証フローで生成する)

これを念頭に置いて最終的に本物のJWTを生成し、このルートで返す事ができる。サービスをきれいにモジュール化する為、authSerrviceでJWTの生成を処理する。authフォルダにあるauth.service.tsファイルを開き、login()メソッドを追加し、図のようにJwtServiceをインポートする。

auth/auth.service.ts
import { Injectable } from '@nestjs/common';
import { UsersService } from '../users/users.service';
import { JwtService } from '@nestjs/jwt';

@Injectable()
export class AuthService {
  constructor(
    private usersService: UsersService,
    private jwtService: JwtService
  ) {}

  async validateUser(username: string, pass: string): Promise<any> {
    const user = await this.usersService.findOne(username);
    if (user && user.password === pass) {
      const { password, ...result } = user;
      return result;
    }
    return null;
  }

  async login(user: any) {
    const payload = { username: user.username, sub: user.userId };
    return {
      access_token: this.jwtService.sign(payload),
    };
  }
}

ここでは、@nestjs/jwtライブラリを使用している。このライブラリにはuserオブジェクトのプロパティのサブセットからJWTを生成するsign()関数が用意されており、使うと単一のaccess_tokenを持つ単純なオブジェクトを返す。注意:JWTの標準に合わせるため、userIdの値を保持するプロパティ名としてsubを選択している。JwtServiceプロバイダのAuthServiceへのインジェクションを忘れないこと。

次に、AuthModuleを更新して新しい依存関係をインポートして、JwtModuleを設定する。

まず、authフォルダにconstants.tsを作成し、以下のコードを追加する。

auth/constants.ts
export const jwtConstants = {
  secret: 'secretKey',
};

これを使って、JWTの署名と検証のステップの間で鍵を共有する。

WARNING
この鍵を公開するべきではない。ここではコードが何をしているかを明確にする為公開しているが、実運用システムではsecrets valut、環境変数、設定サービスなどの適切な手段を用いて鍵を保護しなければならない

次に、authフォルダにあるauth.module.tsを開き、次のように更新する。

auth/auth.module.ts
import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { LocalStrategy } from './local.strategy';
import { UsersModule } from '../users/users.module';
import { PassportModule } from '@nestjs/passport';
import { JwtModule } from '@nestjs/jwt';
import { jwtConstants } from './constants';

@Module({
  imports: [
    UsersModule,
    PassportModule,
    JwtModule.register({
      secret: jwtConstants.secret,
      signOptions: { expiresIn: '60s' },
    }),
  ],
  providers: [AuthService, LocalStrategy],
  exports: [AuthService, JwtModule],
})
export class AuthModule {}

register()に設定オブジェクトを渡してJwtModuleを設定する。NestのJwtModuleの詳細はこちら、使用可能な設定オプションの詳細はこちら

これで/auth/loginルートがJWTを返すように更新できた。

app.controller.ts
import { Controller, Request, Post, UseGuards } from '@nestjs/common';
import { LocalAuthGuard } from './auth/local-auth.guard';
import { AuthService } from './auth/auth.service';

@Controller()
export class AppController {
  constructor(private authService: AuthService) {}

  @UseGuards(LocalAuthGuard)
  @Post('auth/login')
  async login(@Request() req) {
    return this.authService.login(req.user);
  }
}

進めよう。再びcURLを使ってルートをテストする。UsersServiceにハードコードされているユーザーオブジェクトを使ってテストできる。

$ # POST to /auth/login
$ curl -X POST http://localhost:3000/auth/login -d '{"username": "john", "password": "changeme"}' -H "Content-Type: application/json"
$ # result -> {"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."}
$ # Note: above JWT truncated

Passport JWTの実装

これで最後の要件である、リクエストに有効なJWTを要求する機能(エンドポイントの保護)に取り組める。Passportはここでも役に立つ。PassportはJSON Web TokensでRESTFulなエンドポイントを保護するためのpassport-jwtストラテジを提供する。まずauthフォルダ内にjwt.strategy.tsというファイルを作成し、以下のコードを追加する。

auth/jwt.strategy.ts
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable } from '@nestjs/common';
import { jwtConstants } from './constants';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor() {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      secretOrKey: jwtConstants.secret,
    });
  }

  async validate(payload: any) {
    return { userId: payload.sub, username: payload.username };
  }
}

JwtStrategyは、すべてのPassportストラテジについての規格に従っている。このストラテジは初期化が必要なので、super()にオプションオブジェクトを渡し初期化する。利用可能なオプションの詳細についてはこちら。今回は以下を使う。

  • jwtFromRequestRequestからJWTを抽出する方法を指定する。ここではAPIリクエストのAuthorizationヘッダにbearerトークンを供給する標準的な方法を使用する。他のオプションについてはこちら
  • ignoreExpiration:デフォルトのfalseを明示的に選択しておく。これはJWTが期限切れになっていないことを確認する責任をPassportモジュールに与えるものだ。つまり、我々のルートに期限切れのJWTが提供された場合、リクエストは拒否され、401 Unauthorizedレスポンスが送信される事になる。Passportはこれを自動的に処理してくれて便利だ。
  • secretOrKey:トークンに署名するためのシンメトリックなsecretを提供する、便利なオプションを使用している。本番アプリケーションではPEMエンコードされた公開鍵など、他の選択肢のほうが適切な場合がある(詳しくはこちら)。いずれにせよ、先述の通りこのsecretを公開しないでほしい

validate()メソッドについては少し議論に値する。jwt-strategyにおいて、PassportはまずJWTの署名を検証し、JSONをデコードする。次にデコードされたJSONを単一のパラメータとして渡してvalidate()メソッドを呼び出す。JWTの署名の仕組に基づき、有効なユーザに発行した署名済みの有効なトークンを受け取っている事が保証される

最終的な結果として、validate()コールバックへの応答は簡単なものとなる。userIdusernameプロパティを含むオブジェクトを返すだけだ。また思い出してほしいのは、Passportはvalidate()メソッドの戻り値に基づいてuserオブジェクトを構築して、Requestオブジェクトのプロパティとして添付する事だ。

このアプローチでは他のビジネスロジックをプロセスにインジェクションする余地(いわゆる「フック」)がある事も言えるだろう。例えばvalidate()メソッドの中でデータベースの検索を行い、ユーザに関するより多くの情報を抽出する事で、より充実したユーザオブジェクトをRequestで利用できる。また、このメソッドではトークンのさらなる検証も行える。たとえば失効したトークンのリストからuserIdを検索して、トークンを失効させられる。ここでサンプルコードに実装したモデルは高速な「ステートレスJWT」モデルで、各APIコールは有効なJWTの存在に基づいて即座に認証される。要求者に関する僅かな情報(そのuserIdおよびusername)はRequestパイプラインで利用可能だ。

新しいJwtStrategyAuthModuleのプロバイダとして追加する。

auth/auth.module.ts
import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { LocalStrategy } from './local.strategy';
import { JwtStrategy } from './jwt.strategy';
import { UsersModule } from '../users/users.module';
import { PassportModule } from '@nestjs/passport';
import { JwtModule } from '@nestjs/jwt';
import { jwtConstants } from './constants';

@Module({
  imports: [
    UsersModule,
    PassportModule,
    JwtModule.register({
      secret: jwtConstants.secret,
      signOptions: { expiresIn: '60s' },
    }),
  ],
  providers: [AuthService, LocalStrategy, JwtStrategy],
  exports: [AuthService],
})
export class AuthModule {}

JWTへの署名時使ったものと同じsecretをインポートする事で、Passportによって実行される検証フェーズと、AuthServiceで実行される署名フェーズとが共通のsecretを使用する事を保証する。

最後に、組み込みのAuthGuardを拡張したJwtAuthGuardクラスを定義する。

auth/jwt-auth.guard.ts
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}

保護されたルートとJWTストラテジガードの実装

これで、保護されたルートとその関連ガードを実装できる。

app.controller.tsファイルを開いて以下のように更新する。

app.controller.ts
import { Controller, Get, Request, Post, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from './auth/jwt-auth.guard';
import { LocalAuthGuard } from './auth/local-auth.guard';
import { AuthService } from './auth/auth.service';

@Controller()
export class AppController {
  constructor(private authService: AuthService) {}

  @UseGuards(LocalAuthGuard)
  @Post('auth/login')
  async login(@Request() req) {
    return this.authService.login(req.user);
  }

  @UseGuards(JwtAuthGuard)
  @Get('profile')
  getProfile(@Request() req) {
    return req.user;
  }
}

今回も、@nestjs/passportモジュールがpassport-jwtモジュールの設定時に自動で用意したAuthGuardを適用している。このガードはデフォルトの名前jwtで参照される。GET/profileルートがヒットすると、Guardは自動的にpassport-jwtカスタム構成ロジックを起動し、JWTを検証して、userプロパティをRequestオブジェクトに割り当てる。

アプリの実行を確認し、cURLを使用してルートをテストしよう。

$ # GET /profile
$ curl http://localhost:3000/profile
$ # result -> {"statusCode":401,"error":"Unauthorized"}

$ # POST /auth/login
$ curl -X POST http://localhost:3000/auth/login -d '{"username": "john", "password": "changeme"}' -H "Content-Type: application/json"
$ # result -> {"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2Vybm... }

$ # GET /profile using access_token returned from previous step as bearer code
$ curl http://localhost:3000/profile -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2Vybm..."
$ # result -> {"userId":1,"username":"john"}

AuthModuleでは、JWTの有効期限を60秒と設定したことに注意。この文章ではトークンの有効期限やリフレッシュの詳細を扱っていないが、JWTとpassport-jwtストラテジの重要な特性を示すためにこう設定した。認証後60秒待ってからGET/profileリクエストを試みると、401 Unauthorizedレスポンスが返ってくる。これはPassportがJWTの有効期限を自動的にチェックする為で、アプリケーションで有効期限をチェックする手間の削減となる。

これで、JWT認証の実装が完了した。JavaScriptクライアント(Angular/React/Vue等)やその他のJavaScriptアプリは、我々のAPIサーバーと安全に認証・通信できるようになった。

サンプル

以上のコードの完全版はこちら

ガードの拡張

ほとんどの場合デフォルトのAuthGuardを使えば十分だが、デフォルトのエラー処理や認証ロジックを単純に拡張したい場合もあるだろう。組み込みクラスを拡張し、サブクラスでメソッドをオーバーライドする事ができる。

import {
  ExecutionContext,
  Injectable,
  UnauthorizedException,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
  canActivate(context: ExecutionContext) {
    // カスタム認証ロジックを追加しよう
    // 例えば、セッションを作る為にsuper.logIn(request) を呼ぶ等
    return super.canActivate(context);
  }

  handleRequest(err, user, info) {
    // "info"引数か"err"引数を元に例外を投げる事ができる
    if (err || !user) {
      throw err || new UnauthorizedException();
    }
    return user;
  }
}

デフォルトのエラー処理と認証ロジックの拡張に加えて、認証がストラテジのチェーンを経るようにできる。最初に成功、リダイレクト、またはエラーになったストラテジがチェーンを止める。認証に失敗した場合は、各ストラテジを実行し、全てのストラテジが失敗した場合に最終的に失敗となる。

export class JwtAuthGuard extends AuthGuard(['strategy_jwt_1', 'strategy_jwt_2', '...']) { ... }

グローバルで認証を有効にする

大半のエンドポイントをデフォルトで保護する必要がある場合は、認証ガードをグローバルガードとして登録し、各コントローラで@UserGuards()デコレータを使用する代わりに、どのルートをパブリックにするかを単純に指定する事ができる。

まずJwtAuthGuardをグローバルガードとして登録しよう。どんなモジュールでも以下の手順は可能だ。

providers: [
  {
    provide: APP_GUARD,
    useClass: JwtAuthGuard,
  },
],

これで、Nestは自動的にJwtAuthGuardを全てのエンドポイントにバインドする。

次に、ルートをパブリックとして宣言する為のメカニズムを提供する必要がある。そのためには、SetMetadataデコレータのファクトリー関数を使って、カスタムデコレータを作成する。

import { SetMetadata } from '@nestjs/common';

export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

上のファイルでは、2つの定数をエクスポートした。ひとつはメタデータキーIS_PUBLIC_KEYで、もうひとつは新しいデコレータPublicだ(SkipAuthAllowAnon等好きな名前に変えられる)。

これでカスタムの@Public()デコレータができたので、次のように任意のメソッドをデコレーションできる。

@Public()
@Get()
findAll() {
  return [];
}

最後に"isPublic"メタデータが見つかったときにJwtAuthGuardtrueを返すようにする必要がある。そのためにはReflectorクラスを使う(詳細はこちら)。

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
  constructor(private reflector: Reflector) {
    super();
  }

  canActivate(context: ExecutionContext) {
    const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
      context.getHandler(),
      context.getClass(),
    ]);
    if (isPublic) {
      return true;
    }
    return super.canActivate(context);
  }
}

リクエストスコープストラテジ

PassportのAPIは、ライブラリのグローバルなインスタンスにストラテジを登録する事を基本としている。その為、ストラテジはリクエストに依存するオプションを持ったり、リクエストごとに動的にインスタンス化されるようには設計されていない(リクエストスコーププロバイダについてはこちら)。リクエストスコープストラテジを設定した場合、ストラテジは特定のルートに結びついていないので、Nestはストラテジをインスタンス化しない。リクエストごとにどのリクエストスコープストラテジを実行すべきか決定する物理的な方法はない。

しかし、ストラテジ内でリクエストスコーププロバイダを動的に解決する方法はある。モジュール参照機能を使う。

まずlocal.strategy.tsファイルを開き、通常の方法でModuleRefをインジェクションする。

constructor(private moduleRef: ModuleRef) {
  super({
    passReqToCallback: true,
  });
}

HINT
ModuleRefクラスは@nestjs/coreパッケージからインポートされている。

上記のようにpassreqToCallback設定プロパティを必ずtrueにする事。

次のステップでは、新しいコンテキスト識別子を生成するのではなく、現在のコンテキスト識別子を取得する為にリクエストインスタンスを使用する(リクエストコンテキストの詳細)。

ここで、LocalStrategyクラスのvalidate()メソッド内で、ContextIdFactroyクラスのgetByRequest()メソッドを使用して、リクエストオブジェクトに基づいてコンテキストIDを作成し、これをresolove()に渡す。

async validate(
  request: Request,
  username: string,
  password: string,
) {
  const contextId = ContextIdFactory.getByRequest(request);
  // "AuthService"はリクエストスコーププロバイダ
  const authService = await this.moduleRef.resolve(AuthService, contextId);
  ...
}

上記の例では、resolove()メソッドはAuthServiceプロバイダのリクエストスコープインスタンスを非同期的に返す(AuthServiceがリクエストスコープ化されている事を想定している)。

パスポートのカスタマイズ

register()メソッドを使えば、標準的なPassportカスタマイズオプションを同じ様に渡す事ができる。利用可能なオプションは実装されているストラテジによって異なる。例:

PassportModule.register({ session: true });

また、ストラテジのコンストラクタでオプションオブジェクトを渡して、ストラテジを設定する事もできる。ローカルストラテジに対して、例えば以下のように渡せる。

constructor(private authService: AuthService) {
  super({
    usernameField: 'email',
    passwordField: 'password',
  });
}

プロパティ名についてはPassport公式サイトを参照の事。

名前付きストラテジ

ストラテジを実装する際、PassportStrategy関数に第2引数を渡す事でストラテジの名前を指定できる。渡さなければ各ストラテジの名前はデフォルトのままとなる(例:jwt-strategy→'jwt')。

export class JwtStrategy extends PassportStrategy(Strategy, 'myjwt')

そして@UseGuards(AuthGuard('myjwt'))のようなデコレータでこの名前を参照する。

GraphQL

GraphQLでAuthGuardを使う為には、組み込みのAuthGuardクラスを拡張し、getRequest()メソッドをオーバーライドする。

@Injectable()
export class GqlAuthGuard extends AuthGuard('jwt') {
  getRequest(context: ExecutionContext) {
    const ctx = GqlExecutionContext.create(context);
    return ctx.getContext().req;
  }
}

graphqlリゾルバで現在の認証済みユーザを取得するには、@CurrentUser()デコレータを定義する。

import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';

export const CurrentUser = createParamDecorator(
  (data: unknown, context: ExecutionContext) => {
    const ctx = GqlExecutionContext.create(context);
    return ctx.getContext().req.user;
  },
);

上記のデコレータをリゾルバで使う為には、必ずクエリやミューテーションのパラメータとして含めるようにする。

@Query(returns => User)
@UseGuards(GqlAuthGuard)
whoAmI(@CurrentUser() user: User) {
  return this.usersService.findById(user.id);
}