NestJS + Passport.jsを使って普通のCookieベースセッションの認証を実装する

NestJS公式でサポートされるアカウント認証フレームワークにPassport.jsがある。 Passport.jsを使った認証の実装は@nestjs/passportというプラグインを使って容易に実装することができ、 公式ガイドのAuthentication | NestJSにはこのプラグインを使って認証を組み込む方法が解説されている。

しかしながら、この公式ガイドで解説されているのはあくまでクライアント側でJWTを使用する方法であり、 いわゆる普通のCookieセッションを使った認証の実装方法は解説されていない。 そこで、本記事ではNestJSとPassport、およびexpress-sessionを使ってCookieセッションを実装する方法を取り上げる。

TL;DR

セッションを実装するには以下の点に気をつける必要がある。

  • セッションを有効化するにはAuthGuard.logInを呼び出す必要があるため、AuthGuardを継承しcanActivate内でsuper.logInを呼び出す
  • セッションデータ(文字列)とユーザーオブジェクトを変換するためにSerializerを実装する(PassportSerializerを継承)

実装手順

セッションベース認証の実装方法は概ね以下のようになる。

  1. 自サービスのユーザー認証を呼び出すStrategyを実装(@nestjs/passport/PassportStrategyを継承)
  2. 1のStrategyを使って、ログイン認証用のGuardを実装(@nestjs/passport/AuthGuardを継承)
  3. express-sessionを設定するModuleを作成
  4. ユーザー情報をセッションデータから復元するSerializerを実装(@nestjs/passport/PassportSerializerを継承)

色々と新しい用語が出てきているが、追って説明する。

前準備: authモジュールの作成

各種実装に先立って、ログイン処理を実装するモジュールとしてauthモジュールを作成する。

1$ nest g mo auth

さらに、auth.module.tsを編集してPassportModuleをインポートする。

1import { Module } from '@nestjs/common';
2import { PassportModule } from '@nestjs/passport';
3
4@Module({
5  imports: [PassportModule.register({ session: true })],
6})
7export class AuthModule {}

1. Strategyの実装

StrategyはPassport.js内で使われる用語で、様々なユーザーの認証手段(ID/PASS認証やOAuth等)を抽象化した概念である。 また、Strategyを実装するための様々なパッケージが公式に提供されている。

今回は典型的なID/PASS認証を提供するStrategyであるpassport-localを使って実装を行う。

まずは必要なパッケージをインストールする。

1$ npm install @nestjs/passport passport passport-local

次に、実際のユーザー・パスワードの照合を行うAuthServiceを作成する。

まずは以下のコマンドでファイルを生成する。

1$ nest g s auth

そして、auth.service.tsを以下のように編集する。

 1import { Injectable } from '@nestjs/common';
 2
 3@Injectable()
 4export class AuthService {
 5  async validateUser(email: string, password: string): Promise<User | null> {
 6    const user = // DBからユーザー情報を取得
 7    if (user?.checkPassword(password)) {
 8      return user;
 9    }
10    return null;
11  }
12}

さらに、これを呼び出す形でauth/local.strategy.tsを以下の内容で作成する。

 1import { Injectable, UnauthorizedException } from '@nestjs/common';
 2import { PassportStrategy } from '@nestjs/passport';
 3import { Strategy } from 'passport-local';
 4import { AuthService } from './auth.service';
 5
 6@Injectable()
 7export class LocalStrategy extends PassportStrategy(Strategy) {
 8  constructor(private authService: AuthService) {
 9    super();
10  }
11
12  async validate(email: string, password: string): Promise<User> {
13    const user = await this.authService.validateUser(email, password);
14    if (!user) {
15      throw new UnauthorizedException();
16    }
17    return user;
18  }
19}

validateはStrategyから呼び出されるメソッドで、passport-localの場合、第一引数にID、第二引数にパスワードが渡される。 認証に成功した場合はユーザーを表すオブジェクトを返し、失敗した場合はUnauthorizedExceptionを投げて401 Unauthorizedレスポンスを返している。

最後に、このStrategyをauthモジュールにProviderとして登録する。

 1import { Module } from '@nestjs/common';
 2import { AuthService } from './auth.service';
 3import { LocalStrategy } from './local.strategy';
 4import { PassportModule } from '@nestjs/passport';
 5
 6@Module({
 7  imports: [PassportModule.register({ session: true })],
 8  providers: [AuthService, LocalStrategy],
 9})
10export class AuthModule {}

2. ログイン処理用Guardの実装

次に、ログイン処理そのものを実行するGuardを実装する。

auth/local.guard.tsを以下の内容で作成する。

 1import { ExecutionContext } from '@nestjs/common';
 2import { AuthGuard } from '@nestjs/passport';
 3
 4export class LocalGuard extends AuthGuard('local') {
 5  async canActivate(context: ExecutionContext): Promise<boolean> {
 6    const result = (await super.canActivate(context)) as boolean;
 7    await super.logIn(context.switchToHttp().getRequest());
 8    return result;
 9  }
10}

セッションを使わない場合はAuthGuard('local')を直接使えばいいのだが、 今回はAuthGuard.logInを呼び出してセッションを有効化する必要があるため継承している。 super.logInメソッドは、NestApplicationの実体(通常はexpress)のセッション機能を用いて新規セッションを作成するメソッドなのだが、 デフォルトではこのメソッドが呼び出されない。express-sessionをuseしただけではセッションが有効にならない(Cookieがセットされない)のはこのためである。

Guardが完成したら、ログイン用のエンドポイントに結びつける。

まずはControllerを作成する。

1$ nest g co auth

さらに、以下のように編集する。

 1import { Controller, Post, UseGuards } from '@nestjs/common';
 2import { LocalGuard } from './local.guard';
 3
 4@Controller('auth')
 5export class AuthController {
 6  @UseGuards(LocalGuard)
 7  @Post('login')
 8  login() {
 9    return { result: 'ok' }; // LocalGuardが実際のログイン処理を行うので、成功時のレスポンスを返すだけ
10  }
11}

ここで先程作ったGuardをログインのエンドポイントに割り当てる。 これにより、/auth/loginを呼び出すとLocalGuardによりIDパスワード認証が実行される。 LocalGuard(AuthGuard('local'))はLocalStrategyを内部で呼び出すため、 先程実装したように、認証失敗時にはUnauthorizedExceptionをスローする。 これにより、認証に成功した場合のみloginメソッドの内容が実行される。 今回はログインが成功したことを示す簡単なレスポンスを返している。

これにより、ログイン処理を行うエンドポイントが一応出来上がった。

3. express-sessionの設定

次に実際にセッションを管理する部分を設定する。今回はexpress-sessionとRedisを使ってセッション管理を行う。

まずは必要なパッケージをインストールする。

1$ npm install express-session ioredis connect-redis

次に、sessionモジュールとredisモジュールを作成する。

1$ nest new mo session
2$ nest new mo redis

redisモジュールの実装

redisモジュールでは、Redisインスタンスを提供できるようにする。

まずはRedisインスタンスを管理させるためにRedisServiceを作成する。

以下のコマンドを実行してファイルを生成する。

1$ nest new s redis

次に、redis.service.tsを以下のように編集する。

 1import { Injectable, OnModuleDestroy } from '@nestjs/common';
 2import Redis from 'ioredis';
 3
 4@Injectable()
 5export class RedisService implements OnModuleDestroy {
 6  driver: Redis;
 7
 8  constructor() {
 9    this.driver = new Redis({
10      host: process.env.REDIS_HOST ?? 'localhost',
11      port: process.env.REDIS_PORT ?? 6379,
12    });
13  }
14
15  async onModuleDestroy() {
16    await this.driver.disconnect();
17  }
18}

ポイントはonModuleDestroyを呼び出していることである。 これを呼び出さないと、RedisModuleの終了後もコネクションが張りっぱなしになってしまうため、 E2Eテストを行った際などにNode.jsが終了しなくなってしまうことがある。

また、今回は簡単のため環境変数から設定値を取り出しているが、実際は@nestjs/configのConfigModule等を用いるのが良い。

次に、redis.module.tsを以下のように編集する。

 1import { Module } from '@nestjs/common';
 2import Redis from 'ioredis';
 3import { RedisService } from './redis.service';
 4
 5@Module({
 6  providers: [
 7    RedisService,
 8    {
 9      provide: Redis,
10      useFactory: async (redisService: RedisService) => redisService.driver,
11      inject: [RedisService],
12    },
13  ],
14  exports: [Redis],
15})
16export class RedisModule {}

これでRedisをDIできるようになった。

sessionモジュールの実装

sessionモジュールでは、先程準備したRedisインスタンスを用いてexpress-sessionを設定する。

session.module.tsを以下のように編集する。

 1import { Module } from '@nestjs/common';
 2import Redis from 'ioredis';
 3import { RedisModule } from '../redis/redis.module';
 4import expressSession from 'express-session';
 5import connectRedis from 'connect-redis';
 6import passport from 'passport';
 7
 8@Module({
 9  imports: [SessionConfigModule, RedisModule],
10})
11export class SessionModule {
12  constructor(
13    private readonly redis: Redis,
14  ) {}
15
16  configure(consumer: MiddlewareConsumer) {
17    const RedisStore = connectRedis(expressSession);
18
19    consumer
20      .apply(
21        expressSession({
22          secret: process.env.SESSION_SECRET,
23          resave: false,
24          saveUninitialized: false,
25          cookie: {
26            maxAge: 8 * 60 * 60 * 1000,
27            secure: true,
28          },
29          store: new RedisStore({ client: this.redis }),
30        }),
31        passport.initialize(),
32        passport.session(),
33      )
34      .forRoutes('*');
35  }
36}

configureメソッドをオーバーライドすることで、サーバーの実体(express)でuseしたいミドルウェアを登録することができる。 ミドルウェアを登録するには、第一引数に渡ってくるMiddlewareConsumerに対してapplyメソッドで渡してやればよい。

express-session自体は普通にexpressで使うときと同様に設定すれば良い。 その下にあるpassport.initialize()passport.session()は、express上でPassport.jsを使う際に渡す必要のあるミドルウェアである。 今回はexpressのセッション機能とPassport.jsが直接やり取りをするためこれを記述する必要がある。

ちなみに、passport.session()expressSession(...)より前に置くことは出来ない。 これは、passport.session()がセッション機構を初期化する際にexpress-sessionの有無を確認し、 存在する場合にのみexpress-sessionにアタッチするためである。

4. ユーザーデータのSerializerの作成

ログイン時にLocalStrategyで設定したUserオブジェクトは、ログインリクエストの終了時にセッションに紐づけて保存する必要がある。 このとき、Userオブジェクトを丸ごとセッションに保存するわけにはいかないので、 メモリ上のUserオブジェクトとセッションに保存する値を相互変換する必要がある。

この相互変換処理を実装するには、Serializerと呼ばれるオブジェクトを実装する必要がある。

以下の内容でauth/auth.serializer.tsを作成する。

 1import { Injectable } from '@nestjs/common';
 2import { PassportSerializer } from '@nestjs/passport';
 3import { Manager } from '@prisma/client';
 4import { ManagersService } from '../managers/managers.service';
 5
 6export type SessionData = { id: number };
 7
 8@Injectable()
 9export class AuthSerializer extends PassportSerializer {
10  constructor(private managersService: ManagersService) {
11    super();
12  }
13
14  serializeUser(
15    user: Manager,
16    done: (err: Error | null, user: SessionData) => void,
17  ) {
18    // IDのみを保存する
19    done(null, { id: user.id });
20  }
21
22  async deserializeUser(
23    payload: SessionData,
24    done: (err: Error | null, user: Omit<Manager, 'hash'>) => void,
25  ) {
26    const user_id = payload.id;
27    const user = // DBからユーザーデータを読み出す
28    done(null, user!);
29  }
30}

serializeUserはセッションデータの保存時に呼ばれ、 deserializeUserはセッションデータの復元時に呼ばれる。

今回の実装では、セッションデータにはユーザーのidのみを保存し、 Userオブジェクトの復元時には保存したidを使って再度DBから読み出すようにしている。

あとは、このSerializerをProviderとして登録する。

 1import { Module } from '@nestjs/common';
 2import { PassportModule } from '@nestjs/passport';
 3import { AuthService } from './auth.service';
 4import { LocalStrategy } from './local.strategy';
 5import { AuthController } from './auth.controller';
 6import { AuthSerializer } from './auth.serializer';
 7
 8@Module({
 9  imports: [PassportModule.register({ session: true })],
10  providers: [AuthService, LocalStrategy, AuthSerializer],
11  controllers: [AuthController],
12})
13export class AuthModule {}

これにより、Passport.jsがUserオブジェクトとセッションデータを相互変換できるようになった。

セッションをアプリケーションで利用する

上記の手順により、Passport.jsによるセッションベースの認証が完成した。 今度はアプリケーションでこれを利用してみる。

認証済みかどうかのチェック

まずは認証済みかどうかをチェックするGuardを作成してみる。

auth/loggedin.guard.tsを以下の内容で作成する。

1import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
2
3@Injectable()
4export class LoggedInGuard implements CanActivate {
5  canActivate(context: ExecutionContext) {
6    return context.switchToHttp().getRequest().isAuthenticated();
7  }
8}

isAuthenticatedはPassport.jsによって生やされたメソッドで、これを使うことで現在のセッションが認証済みかどうかをチェックできる。

あとはこのGuardをエンドポイントに割り当てれば、未認証ユーザーを弾くことができる。

セッションに紐付いたユーザーを取得

Passport.jsはreq.userにUserオブジェクト(deserializeUserの結果)を格納するので、 単純にRequestオブジェクトを直接参照すれば取得できる。

1  // 現在のユーザーを返す
2  @Get('session')
3  async session(@Req req: Request) {
4    return req.user;
5  }

ただ、実際には使い勝手が悪いので以下のようなデコレーターを作成するのが良いだろう。

1import { createParamDecorator, ExecutionContext } from '@nestjs/common';
2import { Manager } from '@prisma/client';
3
4export const User = createParamDecorator(
5  (data: unknown, ctx: ExecutionContext): User | null => {
6    const req = ctx.switchToHttp().getRequest();
7    return req.user;
8  },
9);

あとは、以下のように引数をデコレートすればUserオブジェクトを利用できる。

1async session(@User() user: User | null) {
2  // ...
3}

まとめ

非常に長くなってしまったが、以上の手順によりCookieベースのセッションを実装できる。

個人的にはこのあたりはもう少しちゃんと公式ドキュメントに記述されていてほしいところではある。

参考文献

Setting Up Sessions with NestJS, Passport, and Redis - DEV Community 👩‍💻👨‍💻