【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-transformernpm 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/dtotouch src/auth/dto/auth.dto.ts
import { 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の有効化
import { 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の実装
import { 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の修正
import { 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 {}
UsersService
でUsersRepository
が利用できるようにimportし、後にAuth
モジュールでもDIするのでexportする。
2-6. 型定義
touch src/auth/auth.type.ts
export type Message = { message: string;};
2-7. AuthModuleの修正
import { 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 {}
AuthService
でUsersService
をDIするためにUsersModule
をimportする。
2-8. AuthServiceの実装
import { 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で受け取った
email
とpassword
からユーザーを作成bcrypt.hash
でパスワードをハッシュ化
- もし同一の
email
がすでに存在する場合はConflictException
を投げる
2-9. AuthControllerの実装
import { 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" }
適当なemail
とpassword
を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. 型定義
import { 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
import { User } from './entities/user.entity';
export type PasswordOmitUser = Omit<User, 'hashedPassword'>;
3-2. AuthServiceの修正
import { 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する- 正常な
email
とpassword
かを検証するvalidateUser`メソッドの実装bcrypt.compare()
でハッシュ化前と後のパスワードを比較し検証- レスポンスにパスワードを含めたくないので
User
オブジェクトから`hashedPasswordを除外して返す
- アクセストークンを生成する
login
メソッドの実装userId
とemail
をペイロードに指定- 今回は
User
エンティティに最低限のカラムしか追加してないのでこのようにしているが、本来はメールアドレスのような情報はペイロードに含めるべきでないので注意
3-3. LocalGuardの実装
/auth/login
にアクセスした際にまず有効なemail
・password
かどうかを検証する必要がある。
mkdir src/auth/strategiestouch src/auth/strategies/local.strategy.tsmkdir src/auth/guardstouch src/auth/guards/local.guard.ts
そのために今回はLocalStrategy
をクラス化したLocalGuard
を実装する。
import { 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はデフォルトで
username
とpassword
を受け取るようになっているが、今回はemail
とpassword
の組み合わせにしたいのでusernameField
を指定 validate
メソッド内では、先ほど実装したauthService.validateUser()
を呼び出しUserをそのまま返す
import { Injectable } from '@nestjs/common';import { AuthGuard } from '@nestjs/passport';
@Injectable()export class LocalGuard extends AuthGuard('local') {}
LocalStrategy
をGuardクラス化する。
3-4. AuthModuleの修正
JWT_SECRET_KEY=<SECRET_KEY>
トークンの生成に使用する秘密鍵を環境変数として登録する。
import { 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するuseFactory
でConfigService
をDIし、秘密鍵を参照
providers
にLocalStrategy
を指定
3-5. AuthControllerの修正
import { 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()
が実行される前にemail
・password
が検証され、無事成功すればLocalStrategy.validate()
から返したUser
がreq.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}
適当なemail
・password
を指定すると、無事LocalStrategy
での検証に失敗している。
4. 認可機能
4-1. JwtGuardの実装
有効なアクセストークンを保持しているかどうかを検証するGuardを実装する。
touch src/auth/strategies/jwt.strategy.tstouch src/auth/guards/jwt.guard.ts
import { 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
を除外して返すだけ
import { Injectable } from '@nestjs/common';import { AuthGuard } from '@nestjs/passport';
@Injectable()export class JwtGuard extends AuthGuard('jwt') {}
4-2. AuthModuleの修正
import { 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の実装
import { 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すると、無事認可に失敗した。