Oteto Blogのロゴ

【NestJS】JWT認証によるログイン機能をPassportで実装する

NestJSでPassportを利用し、下記の最低限なJWT認証機能を実装してみる。

エンドポイント機能
/auth/signupメールアドレス・パスワードで新規登録
/auth/loginメールアドレス・パスワードでログイン(アクセストークンを返す)
/users/profile自身の登録情報を参照する(アクセストークンが有効な場合のみ)

前提

NestJS(Fastify) × TypeORM × PostgreSQL × Dockerで環境構築

NestJS × TypeORM 0.3 でCLIからmigrationする

上記2記事に沿って環境構築が完了し、DBにusersテーブルを作成できている前提で進める。

1. 各ライブラリのインストール

npm i @nestjs/passport passport passport-local @nestjs/jwt passport-jwt bcrypt class-validator class-transformer
npm i -D @types/passport-local @types/passport-jwt

2. 新規登録機能

2-1. Authリソースの作成

nest g res auth --no-spec

2-2. DTOの作成

mkdir -p src/auth/dto
touch src/auth/dto/auth.dto.ts
auth/dto/auth.dto.tsimport {
  IsEmail,
  IsNotEmpty,
  IsString,
  MinLength,
  MaxLength,
} from 'class-validator';

export class AuthDto {
  @IsEmail()
  @IsNotEmpty()
  email: string;

  @IsString()
  @MinLength(8)
  @MaxLength(24)
  @IsNotEmpty()
  password: string;
}

新規登録とログインの両方で利用するDTOを作成する。

2-3. Pipeの有効化

main.tsimport { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import {
  FastifyAdapter,
  type NestFastifyApplication,
} from '@nestjs/platform-fastify';
import { ValidationPipe } from '@nestjs/common';

async function bootstrap() {
  const app = await NestFactory.create<NestFastifyApplication>(
    AppModule,
    new FastifyAdapter(),
  );
  app.useGlobalPipes(new ValidationPipe({ whitelist: true }));
  await app.listen(3000, '0.0.0.0');
}
bootstrap();

class-validatorのバリデーションをグローバルで有効化しておく。

2-4. UsersServiceの実装

users/users.service.tsimport { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { User } from './entities/user.entity';
import { type Repository } from 'typeorm';

@Injectable()
export class UsersService {
  constructor(
    @InjectRepository(User) private readonly userRepository: Repository<User>,
  ) {}

  saveUser(user: User): Promise<User> {
    return this.userRepository.save(user);
  }

  findUserByEmail(email: User['email']): Promise<User | null> {
    return this.userRepository.findOneBy({ email });
  }
}

Userの保存と取得用のメソッドを追加する。

2-5. UsersModuleの修正

users/users.module.tsimport { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from './entities/user.entity';
import { UsersService } from './users.service';
import { UsersController } from './users.controller';

@Module({
  imports: [TypeOrmModule.forFeature([User])],
  controllers: [UsersController],
  providers: [UsersService],
  exports: [UsersService],
})
export class UsersModule {}

UsersServiceUsersRepositoryが利用できるようにimportし、後にAuthモジュールでもDIするのでexportする。

2-6. 型定義

touch src/auth/auth.type.ts
auth/auth.type.tsexport type Message = {
  message: string;
};

2-7. AuthModuleの修正

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

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

AuthServiceUsersServiceをDIするためにUsersModuleをimportする。

2-8. AuthServiceの実装

auth/auth.service.tsimport { ConflictException, Injectable } from '@nestjs/common';
import { UsersService } from '../users/users.service';
import { type AuthDto } from './dto/auth.dto';
import type { Message } from './auth.type';
import * as bcrypt from 'bcrypt';

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

  async signup({ email, password }: AuthDto): Promise<Message> {
    const hashedPassword = await bcrypt.hash(password, 12);
    try {
      await this.usersService.saveUser({ email, hashedPassword });
      return { message: 'Signup was successful' };
    } catch (e) {
      if (e.code === '23505') {
        throw new ConflictException('This email is already taken');
      }
      throw e;
    }
  }
}
  • DTOで受け取ったemailpasswordからユーザーを作成
    • bcrypt.hashでパスワードをハッシュ化
  • もし同一のemailがすでに存在する場合はConflictExceptionを投げる

2-9. AuthControllerの実装

auth/auth.controller.tsimport { Body, Controller, Post } from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthDto } from './dto/auth.dto';
import type { Message } from './auth.type';

@Controller('auth')
export class AuthController {
  constructor(private readonly authService: AuthService) {}

  @Post('signup')
  signup(@Body() dto: AuthDto): Promise<Message> {
    return this.authService.signup(dto);
  }
}

POSTのsignupメソッドを作成し、先ほど作成したAuthService.login()にDTOを渡す。

2-10. 動作確認

curl -X POST localhost:3000/auth/signup -H "Content-Type: application/json" -d '{"email": "user1@email.com", "password": "user1user1"}'
{ "message": "Signup was successful" }

適当なemailpasswordをbodyに含めてPOSTしてみると、上記レスポンスが返りusersテーブルにもレコードが保存されていた。

curl -X POST localhost:3000/auth/signup -H "Content-Type: application/json" -d '{"email": "user1@email.com", "password": "user1"}'
{
    "message": [
        "password must be longer than or equal to 8 characters"
    ],
    "error": "Bad Request",
    "statusCode": 400
}

DTOで設定したバリデーションにわざと失敗するようにPOSTしてみても、無事Bad Requestが返ってきた。

3. ログイン(認証)機能

3-1. 型定義

auth/auth.type.tsimport { type User } from '../users/entities/user.entity';

export type Message = {
  message: string;
};

export type JwtToken = {
  access_token: string;
};

export type JwtPayload = {
  sub: User['id'];
  email: User['email'];
};
touch src/users/users.type.ts
users/users.type.tsimport { User } from './entities/user.entity';

export type PasswordOmitUser = Omit<User, 'hashedPassword'>;

3-2. AuthServiceの修正

auth/auth.service.tsimport { ConflictException, Injectable } from '@nestjs/common';
import { UsersService } from '../users/users.service';
import { type AuthDto } from './dto/auth.dto';
import type { Message } from './auth.type';
import type { JwtPayload, JwtToken, Message } from './auth.type';
import type { PasswordOmitUser } from '../users/users.type';
import * as bcrypt from 'bcrypt';
import { JwtService } from '@nestjs/jwt';

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

  async signup({ email, password }: AuthDto): Promise<Message> {
    const hashedPassword = await bcrypt.hash(password, 12);
    try {
      await this.usersService.saveUser({ email, hashedPassword });
      return { message: 'Signup was successful' };
    } catch (e) {
      if (e.code === '23505') {
        throw new ConflictException('This email is already taken');
      }
      throw e;
    }
  }

  async validateUser({
    email,
    password,
  }: AuthDto): Promise<PasswordOmitUser | null> {
    const user = await this.usersService.findUserByEmail(email);
    if (!user) return null;

    const isValidPassword = await bcrypt.compare(password, user.hashedPassword);
    if (!isValidPassword) return null;

    const { hashedPassword, ...passwordOmitUser } = user;
    return passwordOmitUser;
  }

  async login({ id: userId, email }: PasswordOmitUser): Promise<JwtToken> {
    const payload: JwtPayload = { sub: userId, email };
    return {
      access_token: this.jwtService.sign(payload),
    };
  }
}
  • JwtServiceをDIする
  • 正常なemailpasswordかを検証するvalidateUser`メソッドの実装
    • bcrypt.compare()でハッシュ化前と後のパスワードを比較し検証
    • レスポンスにパスワードを含めたくないのでUserオブジェクトから`hashedPasswordを除外して返す
  • アクセストークンを生成するloginメソッドの実装
    • userIdemailをペイロードに指定
    • 今回はUserエンティティに最低限のカラムしか追加してないのでこのようにしているが、本来はメールアドレスのような情報はペイロードに含めるべきでないので注意

3-3. LocalGuardの実装

/auth/loginにアクセスした際にまず有効なemailpasswordかどうかを検証する必要がある。

mkdir src/auth/strategies
touch src/auth/strategies/local.strategy.ts
mkdir src/auth/guards
touch src/auth/guards/local.guard.ts

そのために今回はLocalStrategyをクラス化したLocalGuardを実装する。

auth/strategies/local.strategy.tsimport { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-local';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { AuthService } from '../auth.service';
import type { PasswordOmitUser } from '../../users/users.type';

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

  async validate(email: string, password: string): Promise<PasswordOmitUser> {
    const user = await this.authService.validateUser({ email, password });
    if (!user) throw new UnauthorizedException('email or password incorrect');

    return user;
  }
}
  • passport-localはデフォルトでusernamepasswordを受け取るようになっているが、今回はemailpasswordの組み合わせにしたいのでusernameFieldを指定
  • validateメソッド内では、先ほど実装したauthService.validateUser()を呼び出しUserをそのまま返す
auth/guards/local.guard.tsimport { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

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

LocalStrategyをGuardクラス化する。

3-4. AuthModuleの修正

.envJWT_SECRET_KEY=<SECRET_KEY>

トークンの生成に使用する秘密鍵を環境変数として登録する。

auth/auth.module.tsimport { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { UsersModule } from '../users/users.module';
import { JwtModule } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { LocalStrategy } from './strategies/local.strategy';

@Module({
  imports: [UsersModule],
  imports: [
    UsersModule,
    JwtModule.registerAsync({
      useFactory: async (configService: ConfigService) => ({
        secret: configService.get<string>('JWT_SECRET_KEY'),
        signOptions: { expiresIn: '60s' },
      }),
      inject: [ConfigService],
    }),
  ],
  controllers: [AuthController],
  providers: [AuthService],
  providers: [AuthService, LocalStrategy],
})
export class AuthModule {}
  • JwtModuleをimportする
    • useFactoryConfigServiceをDIし、秘密鍵を参照
  • providersLocalStrategyを指定

3-5. AuthControllerの修正

auth/auth.controller.tsimport { Body, Controller, Post } from '@nestjs/common';
import { Body, Controller, Post, Request, UseGuards } from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthDto } from './dto/auth.dto';
import type { Message } from './auth.type';
import type { JwtToken, Message } from './auth.type';
import type { PasswordOmitUser } from '../users/users.type';
import { LocalGuard } from './guards/local.guard';

@Controller('auth')
export class AuthController {
  constructor(private readonly authService: AuthService) {}

  @Post('signup')
  signup(@Body() dto: AuthDto): Promise<Message> {
    return this.authService.signup(dto);
  }

  @Post('login')
  @UseGuards(LocalGuard)
  login(
    @Body() dto: AuthDto,
    @Request() req: { user: PasswordOmitUser },
  ): Promise<JwtToken> {
    return this.authService.login(req.user);
  }
}

ログイン用のエンドポイントを作成する。

@UseGuards(LocalGuard)とすることでlogin()が実行される前にemailpasswordが検証され、無事成功すればLocalStrategy.validate()から返したUserreq.userが取得できる。

3-6. 動作確認

curl -X POST localhost:3000/auth/login -H "Content-Type: application/json" -d '{"email": "user1@email.com", "password": "user1user1"}'
{
  "access_token": "xxx.xxx.xxx"
}

先程登録したユーザーでPOSTしてみると、無事アクセストークンが返ってきた。

curl -X POST localhost:3000/auth/login -H "Content-Type: application/json" -d '{"email": "dummy@email.com", "password": "user1user1"}'
curl -X POST localhost:3000/auth/login -H "Content-Type: application/json" -d '{"email": "user1@email.com", "password": "dummydummy"}'
{
    "message": "email or password incorrect",
    "error": "Unauthorized",
    "statusCode": 401
}

適当なemailpasswordを指定すると、無事LocalStrategyでの検証に失敗している。

4. 認可機能

4-1. JwtGuardの実装

有効なアクセストークンを保持しているかどうかを検証するGuardを実装する。

touch src/auth/strategies/jwt.strategy.ts
touch src/auth/guards/jwt.guard.ts
auth/strategies/jwt.strategy.tsimport { ForbiddenException, Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { UsersService } from '../../users/users.service';
import { ConfigService } from '@nestjs/config';
import { JwtPayload } from '../auth.type';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor(
    private readonly configService: ConfigService,
    private readonly usersService: UsersService,
  ) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      secretOrKey: configService.get<string>('JWT_SECRET_KEY'),
    });
  }

  async validate({ email }: JwtPayload) {
    const user = await this.usersService.findUserByEmail(email);
    if (!user) throw new ForbiddenException();

    const { hashedPassword, ...passwordOmitUser } = user;
    return passwordOmitUser;
  }
}
  • passport-jwtのStrategyを継承し、super()を呼び出す際にオプションを指定(参考
  • トークンを検証する必要はないので、validate()ではペイロードのemailからUserを取得しhashedPasswordを除外して返すだけ
auth/guards/jwt.guard.tsimport { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

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

4-2. AuthModuleの修正

auth/auth.module.tsimport { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { UsersModule } from '../users/users.module';
import { JwtModule } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { LocalStrategy } from './strategies/local.strategy';
import { JwtStrategy } from './strategies/jwt.strategy';

@Module({
  imports: [
    UsersModule,
    JwtModule.registerAsync({
      useFactory: async (configService: ConfigService) => ({
        secret: configService.get<string>('JWT_SECRET_KEY'),
        signOptions: { expiresIn: '60s' },
      }),
      inject: [ConfigService],
    }),
  ],
  controllers: [AuthController],
  providers: [AuthService, LocalStrategy],
  providers: [AuthService, LocalStrategy, JwtStrategy],
})
export class AuthModule {}

4-3. UsersControllerの実装

users/users.controller.tsimport { Controller, Get, Request, UseGuards } from '@nestjs/common';
import { PasswordOmitUser } from './users.type';
import { JwtGuard } from '../auth/guards/jwt.guard';

@Controller('users')
export class UsersController {
  @Get('profile')
  @UseGuards(JwtGuard)
  profile(@Request() req: { user: PasswordOmitUser }) {
    return req.user;
  }
}

JwtGuardで返されたUserの情報をそのまま返すだけのprofile()を実装する。

4-4. 動作確認

curl -X POST localhost:3000/auth/login -H "Content-Type: application/json" -d '{"email": "user1@email.com", "password": "user1user1"}'
# => { "access_token": "xxx.xxx.xxx" }

curl localhost:3000/users/profile -H "Authorization: Bearer xxx.xxx.xxx"
{
    "id": 1,
    "createdAt": "2023-09-30T15:29:19.301Z",
    "updatedAt": "2023-09-30T15:29:19.301Z",
    "email": "user1@email.com"
}

/auth/loginを叩いて取得したアクセストークンをAuthorizationヘッダ内に含め/users/profileにGETすると、無事ログインしたユーザーの情報が返ってきた。

{
    "message": "Unauthorized",
    "statusCode": 401
}

不正なアクセストークンを指定してPOSTすると、無事認可に失敗した。