就職したばかりの新卒エンジニアです。
趣味で取り組んでいる個人開発ではNest.jsを使用して、開発を進めています。
なぜなら、僕はNest.jsのロゴと色に魅力を感じたためです。
ロマンしか感じられない。
また、TypeScriptでの開発はフロントエンドで主流となっているため、あえてバックエンドをTypeScriptで開発できるスキルを身につけることで、イキりたいからです。
こんなしょうもない理由で個人開発に取り組んでいますが、結構楽しいです。
本記事では、僕がドキュメントを読みながら実装した、JWT認証について書いていきたいと思います。
ドキュメントドリブンの認証実装(ログインのみ、登録なし)
僕は以下の公式ドキュメントを読みながら進めました。
Nest.jsといえば、日本語での情報が少ないため、学習コストの高さが特徴に挙げられるでしょう。
そこを本記事で補えることができれば光栄です。
以下ではそれぞれのファイルを並べています。
まずはモジュールシステムについて説明する必要があるかもしれません。
基本的なアーキテクチャについてです。
簡単に表すと、アプリケーションの機能を複数のブロック(モジュール)に分け、それぞれの機能や責務をカプセル化するための仕組みです。
モジュールファイルでは、依存性やどのサービスを利用できるかなどが表記されるため、依存性注入(DI)を効果的に行えます。
ここではあまり詳しい説明はしませんが、気になる方は以下の記事を読んでみてください。
以下は大変素晴らしい記事だと思います。
https://qiita.com/to3izo/items/ecbec71817ab589f87d7
https://qiita.com/mu-suke08/items/67ee5a1121ce5610c651
auth.module.ts
import { Module } from "@nestjs/common";
import { UsersModule } from "../user-cases/users.module";
import { JwtModule } from "@nestjs/jwt";
import { AuthService } from "./auth.service";
import { AuthController } from "./auth.controller";
import { jwtConstants } from "./constants";
import { PassportModule } from "@nestjs/passport";
import { JWTStrategy } from "./strategy/jwt.strategy";
import { LocalStrategy } from "./strategy/local.strategy";
@Module({
imports: [
UsersModule,
PassportModule,
JwtModule.register({
global: true,
secret: jwtConstants.secret,
signOptions: { expiresIn: '1h' },
}),
],
providers: [AuthService, JWTStrategy, LocalStrategy],
controllers: [AuthController],
exports: [AuthService],
})
export class AuthModule {}
このAuthModuleでは、外部モジュール内にあるサービスを利用するためにimportsでモジュールを読み込み、providersではモジュール内で有効化されるサービスを定義しています。
僕が詰まったところは、signOptionsの箇所です。
expiresInプロパティでは、アクセストークンの有効期間を指定しています。
前は60sにしていました。
そのため、トークンを取得してから、PostmanのAuthenticationにトークンをセットするのに60s以上かかっていたため、
「なぜかログインできていないなあ」と思っていました。
振り返るだけで恥ずかしいです
constants.ts
export const jwtConstants = {
secret: 'DO NOT USE THIS VALUE. INSTEAD, CREATE A COMPLEX SECRET AND KEEP IT SAFE OUTSIDE OF THE SOURCE CODE.',
};
ここは無視していただいて大丈夫です。
auth.controller.ts
import { Body, Controller, Get, HttpCode, HttpStatus, Post, Request, UseGuards } from "@nestjs/common";
import { AuthService } from "./auth.service";
import { SignInDto } from "./dto/signIn.dto";
import { AuthGuard } from "./guard/auth.guard";
@Controller('auth')
export class AuthController {
constructor(private authService: AuthService) {}
@HttpCode(HttpStatus.OK)
@Post('login')
signIn(@Body() signInDto: SignInDto) {
return this.authService.signIn(signInDto.email, signInDto.password);
}
//以下は認証テスト用の関数です
@UseGuards(AuthGuard)
@Get('profile')
getProfile(@Request() req) {
return req.user;
}
}
こちらはコントローラファイルです。リクエストの受け取り、レスポンスの受け渡しを担います。
また、ルーティングもこちらで定義されます。ルーティングの定義と同じファイルで処理も定義できるのは便利です。(日本語怪しい)
@Controller('auth')
...
@Post('login')
パスは" {basePath}/auth/login "となります。
感覚的でわかりやすいです。
注目して欲しいのはSignInDtoについてです。
ここでは、送られてくるリクエストをそのままオブジェクトとして受け取る要領です。
JSONレスポンスを整形することにも利用できます。
「このプロパティはレスポンスに含む、これは含まない」といった感じです。
this.authService.signInの引数では、emailとpasswordが指定されていますが、実際のリクエストは以下のように送られてきます。
{
"email" : "実際のメールアドレス",
"password" : "実際のパスワード"
}
これを指定しているわけですね。
また、下のgetProfile()についてですが、これは認証テスト用です。
ログインを行い、アクセストークンが返されたまではいいが、その後にログインユーザの情報を得ることができず、困っていた時に使用していました。
auth.service.ts
import { forwardRef, Inject, Injectable, UnauthorizedException } from "@nestjs/common";
import { JwtService } from "@nestjs/jwt";
import { UsersService } from "../user-cases/users.service";
import { SerializeBigInt } from "src/infrastructure/utils/SerializeBigInt";
@Injectable()
export class AuthService {
constructor(
@Inject(forwardRef(() => UsersService))
private usersService: UsersService,
private jwtService: JwtService
) { }
async validateUser(email: string, password: string): Promise<any> {
const user = await this.usersService.findUser(email);
if (user && user.password === password) {
const { password, ...result } = user;
return result;
}
return null;
}
async signIn(
email: string,
password: string,
): Promise<{ access_token: string }> {
const user = await this.usersService.findUser(email);
if (user?.password !== password) {
throw new UnauthorizedException();
}
const userId = SerializeBigInt.serialize(user.id).toString();
const payload = { sub: userId, email: user.email };
return {
access_token: await this.jwtService.signAsync(payload),
};
}
}
-
ユーザー認証:
validateUser メソッドで、ユーザーのメールアドレスを元にユーザー情報を取得し、パスワードが一致すればパスワード以外のユーザーデータを返し、一致しなければ null を返します。 -
JWTトークンの発行:
signIn メソッドでは、同様にユーザーのメールアドレスで情報を取得し、パスワードが一致しない場合は UnauthorizedException をスローします。一致する場合は、ユーザーのID(SerializeBigInt を利用してシリアライズ)とメールアドレスをペイロードに含めたJWTトークンを生成し、返します。 -
依存性注入と循環参照の解決:
UsersService は循環依存の可能性があるため、forwardRef を使って注入されています。
これにより、認証処理とJWTによるセッション管理を効率的に実現しています。
auth.guard.ts
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { jwtConstants } from '../constants';
import { Request } from 'express';
@Injectable()
export class AuthGuard implements CanActivate {
constructor(private jwtService: JwtService) { }
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const token = this.extractTokenFromHeader(request);
if (!token) {
throw new UnauthorizedException();
}
try {
const payload = await this.jwtService.verifyAsync(
token,
{
secret: jwtConstants.secret
}
);
// 💡 We're assigning the payload to the request object here
// so that we can access it in our route handlers
request['user'] = payload;
} catch (error){
console.error('JWT verification error:', error);
throw new UnauthorizedException();
}
return true;
}
private extractTokenFromHeader(request: Request): string | undefined {
const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
}
}
これはルーティングをガードするファイルです。
細かい実装は以下に要約しますが、「認証しないとこのリクエスト認められないぜ」のファイルです。
-
JWTトークンの抽出:
HTTPリクエストのAuthorizationヘッダーから「Bearer トークン」を取り出します。 -
トークンの検証:
取り出したJWTトークンをJwtServiceを使って秘密鍵で検証します。検証に失敗するとUnauthorizedExceptionが投げられます。 -
ユーザー情報の付与:
検証に成功した場合、JWTのペイロードをリクエストオブジェクトのuserプロパティに格納し、後続のルートハンドラで利用可能にします。
最後に
ここまで読んでくれた方はありがとうございます。
いいねしてくれると、僕の自己肯定感が上がるので嬉しいです。