📘

NestJS でログイン情報をセッションに保存

2023/02/25に公開

はじめに

NestJS の公式ドキュメントに載っている認証では、ログイン済みのユーザー情報を JWT で管理していますが、これをセッションで管理するように変更する方法を紹介します。

ログインの実装

まずは公式ドキュメントに従ってログイン機能を実装します。
https://docs.nestjs.com/security/authentication

以下は公式ドキュメントに書いてあるコードを少しアレンジして転記しました。
公式ドキュメントの内容についてしっかり理解している方は読み飛ばしてください。

$ yarn add @nestjs/passport passport passport-local
$ yarn add --dev @types/passport-local
$ nest generate module auth
$ nest generate service auth
$ nest generate module users
$ nest generate service users
users/users.service.ts
import { Injectable } from '@nestjs/common';

// This should be a real class/interface representing a user entity
export type User = any;

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

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

公式ドキュメントに登場する UsersService は、永続化されたユーザーを操作する役割を持つようです。Repository と言った方が解りやすいかもしれません。
https://zenn.dev/kohii/articles/e4f325ed011db8

例では配列にデータを格納していますが、実際には DB に保存して、Prisma や TypeORM などで取得することになるでしょう。パスワードももちろんハッシュ化します。

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

@Module({
  providers: [UsersService],
  exports: [UsersService],
})
export class UsersModule {}
auth/auth.service.ts
import { Injectable } from '@nestjs/common';
import { UsersService, User } 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.findByUsername(username);

    if (user && user.password === pass) {
      // MEMO: パスワードを隠す処理はここじゃないところに実装する方がいい気がする
      const { password, ...result } = user;
      return result;
    }

    return null;
  }
}

AuthService には認証に関する処理を実装します。
validateUser() は渡された usernamepass でログインを試し、ログインできたらそのユーザーを返します。
もし退会フラグを持たせたり、承認されていないユーザーはログインできなくしたり、ログインに条件を付けたくなったら、ここにその判定処理を実装します。

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;
  }
}

公式ドキュメントを読んだだけだと、この LocalStrategy がどこで使われているのかが解らないと思います。
passport-local から import した Strategy'local' という名前を持っていて、PassportStrategy(Strategy) を呼んだタイミングで Passport にその名前で LocalStrategy が登録されます。
後で 'local' という名前を指定するコードが出てくるので、それがこのクラスを指していると思ってください。

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

@Module({
  imports: [UsersModule, PassportModule],
  providers: [AuthService, LocalStrategy],
})
export class AuthModule {}
auth/local-auth.guard.ts
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

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

ここで出てくる 'local' が上で作った LocalStrategy を指しています。

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

@Controller()
export class AuthController {
  @UseGuards(LocalAuthGuard)
  @Post('auth/login')
  @HttpCode(200)
  async login(@Request() request: any) {
    return request.user;
  }
}

ここまで作ったら、次は JWT を使ってログインセッションを管理する方法が書かれているのですが、この記事ではセッションを使う方法を記していきます。

ログイン状態をセッションで管理する

セッションを有効にする

セッションを有効にするために express-session をインストールします。

$ yarn add express-session
$ yarn add --dev @types/express-session

また、Passport を直接利用するために、Passport の型定義ファイルも追加します。

$ yarn add --dev @types/passport

express-session と Passport のセッションをそれぞれ有効にします。

main.ts
import { NestFactory } from '@nestjs/core';
import * as session from 'express-session';
import * as passport from 'passport';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  app.use(
    session({
      secret: process.env.APP_SECRET,
      resave: false,
      saveUninitialized: false,
      cookie: {
        maxAge: 7 * 24 * 60 * 60 * 1000, // 7days
      },
    }),
  );
  app.use(passport.initialize());
  app.use(passport.session());

  await app.listen(3000);
}
bootstrap();

詳しくは express-session のドキュメントをご覧ください。
https://github.com/expressjs/session

ログインユーザーをセッションに保存する準備

公式ドキュメントで使われている JWT では、クライアントに渡すトークンの中にユーザー ID を含め、それを使ってアクセスしてきたユーザーが何者かを認証しています。
ここでは JWT ではなくセッションに ID を保存し、アクセスがあった際はセッションから ID を取り出して認証するようにします。

Passport では、ログインしたユーザーの情報をセッションに保存するときと、セッションから取り出すときに、Serializer というクラスを使います。

auth/session.serializer.ts
import { Injectable } from '@nestjs/common';
import { PassportSerializer } from '@nestjs/passport';
import { AuthService } from '../auth/auth.service';

@Injectable()
export class SessionSerializer extends PassportSerializer {
  constructor(private authService: AuthService) {
    super();
  }

  serializeUser(user: any, done: (err: Error, id: number) => void): void {
    done(null, user.id);
  }

  async deserializeUser(
    id: number,
    done: (err: Error, user: any) => void,
  ): Promise<void> {
    const user = await this.authService.getAuthenticatedUser(id);

    done(null, user);
  }
}

serializeUser() は。ユーザーの情報からセッションに保存したい情報だけを取り出して返すメソッドです。ここでは ID だけを保存するようにしています。

deserializeUser() は、セッションから取り出した情報からユーザー情報を復元するメソッドです。セッションには ID しか保存していないので、残りの情報は DB から取得します。

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

// This should be a real class/interface representing a user entity
export type User = any;

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

  // このメソッドを追加
  async findById(id: number): Promise<User | null> {
    return this.users.find(user => user.id === id);
  }

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

AuthService の実装の前に、まずは UsersService に ID を使って DB からユーザーを取得するメソッド、findById() を実装します。
ここでは取得のみを行い、中身に関する処理は行いません。

auth/auth.service.ts
import { Injectable } from '@nestjs/common';
import { UsersService, User } 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.findByUsername(username);

    // ログインしようとしたユーザーがアクティブかどうかもチェック
    if (user && this.isActive(user) && user.password === pass) {
      // MEMO: パスワードを隠す処理はここじゃないところに実装する方がいい気がする
      const { password, ...result } = user;
      return result;
    }

    return null;
  }

  async getAuthenticatedUser(id: number): Promise<any> {
    const user = await this.usersService.findById(id);

    if (user && this.isActive(user)) {
      // MEMO: パスワードを隠す処理はここじゃないところに実装する方がいい気がする
      const { password, ...result } = user;
      return result;
    }

    return null;
  }

  private isActive(user: User): boolean {
    // 要件に合わせてここをアレンジ
    return !!user;
  }
}

続いて、AuthService に、ID を使ってユーザーを取得し、そのユーザーが引き続きログイン可能であればユーザーを、そうでなければ null を返すメソッド、getAuthenticatedUser() を実装します。

isActive() では、DB から取得したユーザーがアクティブかどうかをチェックしています。
実際の処理としては、例えば退会フラグが立っていた場合は false にするなどが考えられます。
このメソッドはログイン時にも使用します。

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

@Module({
  imports: [UsersModule, PassportModule.register({ session: true })],
  providers: [AuthService, SessionSerializer, LocalStrategy],
  controllers: [AuthController],
})
export class AuthModule {}

最後に、作成した Sterilizer を使うように Passport を設定します。

ログインユーザーをセッションに保存する

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

@Injectable()
export class LocalAuthGuard extends AuthGuard('local') {
  async canActivate(context: ExecutionContext): Promise<boolean> {
    const result = (await super.canActivate(context)) as boolean;
    const request = context.switchToHttp().getRequest();

    await super.logIn(request);

    return result;
  }
}

ログインに使っていた LocalAuthGuardcanActive() をオーバーライドして、ログイン時にユーザー情報をセッションに保存するようにします。

認証していないとアクセスできないルートを作る

auth/authenticated.guard.ts
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';

@Injectable()
export class AuthenticatedGuard implements CanActivate {
  async canActivate(context: ExecutionContext) {
    const request = context.switchToHttp().getRequest();

    return request.isAuthenticated();
  }
}

セッションを見てログイン中かどうかを判定する Guard を作成します。

auth/auth.controller.ts
import {
  Controller,
  UseGuards,
  Get,
  Post,
  HttpCode,
  Request,
} from '@nestjs/common';
import { LocalAuthGuard } from './local-auth.guard';
import { AuthenticatedGuard } from './authenticated.guard';

@Controller()
export class AuthController {
  @UseGuards(LocalAuthGuard)
  @Post('auth/login')
  @HttpCode(200)
  async login(@Request() request: any) {
    return request.user;
  }

  @UseGuards(AuthenticatedGuard)
  @Get('auth/me')
  async me(@Request() request: any) {
    return request.user;
  }
}

その Guard を使って、ログインしないと見れないルートを作成します。
ここでは /auth/me というログインユーザーの情報を返すエンドポイントを作成してみました。

ログアウト機能を実装

auth/auth.controller.ts
import {
  Controller,
  UseGuards,
  Get,
  Post,
  HttpCode,
  Request,
} from '@nestjs/common';
import { LocalAuthGuard } from './local-auth.guard';
import { AuthenticatedGuard } from './authenticated.guard';

@Controller()
export class AuthController {
  @UseGuards(LocalAuthGuard)
  @Post('auth/login')
  @HttpCode(200)
  async login(@Request() request: any) {
    return request.user;
  }

  @UseGuards(AuthenticatedGuard)
  @Get('auth/me')
  async me(@Request() request: any) {
    return request.user;
  }

  @Post('auth/logout')
  @HttpCode(204)
  async logout(@Request() request: any): Promise<void> {
    request.session.destroy();
  }
}

ログアウトするときは、セッションを丸ごと破棄します。
セッションに残しておきたい情報がある場合も、全体を破棄してから改めてその情報だけ保存するようにした方が安全です。

さいごに

NestJS + Passport で、ログイン情報をセッションに保存する方法が、ググってもほとんど見つからなかったため記事にしました。
説明不足なところもありますが、参考になれば幸いです。

オーバーライドしているところとか、実装するメソッド名が決まっているところで Typo すると、エラーにならずに機能も動かないので、ご注意ください。

Discussion