👁️

NestJS, Prisma, PostgreSQL(Docker)で...Part3

2024/08/22に公開

認証つくっていくぜ

今回はNestJS公式で紹介されてるJWT認証を実装していきます

具体的には

  1. JWTを発行する(signin時)
  2. リクエストのヘッダーを見て、有効なJWTが含まれているか?を確認するGuardを作る
  3. 実行前にGuardで正しいJWT持ってますか?て確認をするAPIを作る
    です。

JWTよくわからんて方は、わかりやすく解説してくれてる方がいたのでこちらをさっと読んでみてください

email, passwordでuserをfindする超簡単な認証を作る

AuthModule, AuthService, AuthController 生成

$ nest g module auth &&
nest g controller auth &&
nest g service auth

まずはもろもろ生成...

AuthModule

# auth.module.ts
import { Module } from '@nestjs/common';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { PrismaModule } from 'src/prisma/prisma.module'; //追加

@Module({
  imports: [PrismaModule], //追加
  controllers: [AuthController],
  providers: [AuthService],
})
export class AuthModule {}

PrismaModuleimportに追加してAuthService内で使えるようにしておきます
UserModuleでもやったね!

AuthService

# auth.service.ts
import { Injectable } from '@nestjs/common';
import { User } from '@prisma/client';
import { PrismaService } from 'src/prisma/prisma.service';
import * as bcrypt from 'bcrypt';

@Injectable()
export class AuthService {
  constructor(private prisma: PrismaService) {}

  async validate(
    email: string,
    password: string,
  ): Promise<Omit<User, 'password'> | null> {
    const user = await this.prisma.user.findFirst({ where: { email } });
    if (user && (await bcrypt.compare(password, user.password))) {
      const result = { ...user };
      delete result.password;
      return result;
    }
    return null;
  }
}

emailpasswordを受け取り、User型からpasswordを除外したObjectを返します
emailが一致するuserが見つからなかった、もしくはuserpasswordが一致しなかった場合はnullを返します

AuthController

  • 受け取るデータの型を定義しておきます
# auth/dto/signin.dto.ts
export interface SigninDto {
  email: string;
  password: string;
}
  • controller
# auth.controller.ts
import {
  Body,
  Controller,
  Post,
  HttpCode,
  HttpStatus,
  UnauthorizedException,
} from '@nestjs/common';
import { AuthService } from './auth.service';
import { SigninDto } from './dto/signin.dto';

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

  @HttpCode(HttpStatus.OK)
  @Post('signin')
  async signIn(@Body() signInDto: SigninDto) {
    const user = this.authService.validate(signInDto.email, signInDto.password);
    if (!user) {
      throw new UnauthorizedException('Invalid credentials');
    }
    return user;
  }
}

auth/signinPOST requestがあったときにSigninDto型Bodyを受け取り、
先ほど作成したvalidateを呼び出し、問題なくuserが返ってきた場合はそのまま返し、nullの場合は401 errorを返します

動作確認

今回も私はPostmanで

method: POST
URL: http://localhost:3000/auth/signin
Body: {
  "email": "user1@example.com",
  "password": "password"
}

よき(≧∇≦)b

JWT!!

まずはJWTを生成する処理を書いていきます

npm install

$ npm install --save @nestjs/jwt
tokenの生成、検証につかいやす

JWTの署名に必要なSecretを定義

# auth/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.',
};

access_tokenの生成

# auth.service.ts
export class AuthService {
  constructor(
    private prisma: PrismaService,
    private jwtService: JwtService,
  ) {}
略...
  async signin(
    user: Omit<User, 'password'>,
  ): Promise<{ access_token: string }> {
    const payload = { sub: user.id, username: user.username };
    return {
      access_token: await this.jwtService.signAsync(payload),
    };
  }
}

passwordを除外したuserを受け取って署名したtokenを発行して返します

# auth.controller.ts
@Controller('auth')
export class AuthController {
  constructor(private authService: AuthService) {}

  @HttpCode(HttpStatus.OK)
  @Post('signin')
  async signIn(@Body() signInDto: SigninDto) {
    const user = await this.authService.validate(
      signInDto.email,
      signInDto.password,
    );

    if (!user) {
      throw new UnauthorizedException('Invalid credentials');
    }
    return this.authService.signin(user);
  }
}

POST auth/signinリクエストが来たら一致するuserを探して問題なければ先ほど作成したJWTを生成するauthService.signinを呼んでそのまま返ってきたtokenを返します

# auth.module.ts
略...
@Module({
  imports: [
    PrismaModule,
    JwtModule.register({
      global: true,
      secret: jwtConstants.secret,
      signOptions: { expiresIn: '60s' },
    }),
  ],
  controllers: [AuthController],
  providers: [AuthService],
})
  • JwtModuleglobalに設定してます
  • 定義したSecretを注入してあげます
  • tokenの有効期限を定義してます(60sはかなり短い)

動作確認

リクエストは先程同様で...

JWT返ってきましたね!
よきき(≧∇≦)b

Guard実装

リクエストが来たときにJWT持ってる?それまだ有効?ただしいやつ?を確認する処理を書いていきます

AuthGuard実装

# auth/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,
      });
      request['user'] = payload;
    } catch (error) {
      throw new UnauthorizedException();
    }
    return true;
  }

  private extractTokenFromHeader(request: Request): string | undefined {
    const [type, token] = request.headers.authorization?.split(' ') ?? [];
    return type === 'Bearer' ? token : undefined;
  }
}

private extractTokenFromHeader関数はrequestAuthorizationヘッダーからtokenを抽出して返します、無かったらundefinedを返します
上記を実行してトークンが取得できたら、そのトークンを検証します
有効であればrequest['user']に保存してtrueを返します。(この保存されたuserは後ほど使います

AuthGuardを適用したエンドポイント

# auth.controller.ts
略...
  @UseGuards(AuthGuard)
  @Get('profile')
  getProfile(@Request() req) {
    return req.user;
  }
}

GET auth/profileリクエストが来た場合、AuthGuardを通り、getProfile APIが実行されまマ先ほど保存したuser(JWTから復元した内容)が返されます

動作確認

  1. tokenheaderに含めないで保護されたエンドポイントを叩く
method: GET
URL: http://localhost:3000/auth/profile

結果: 401(未認証だよ😠)

  1. tokenheaderに含めて保護されたエンドポイントを叩く
    まずはJWTを取得する
method: POST
URL: http://localhost:3000/auth/signin
Body: {
  "email": "user1@example.com",
  "password": "password"
}

JWTheaderに追加して叩き直す
tokenの有効期限が60sなのでかなり急いだ

method: GET
URL: http://localhost:3000/auth/profile
header: {
    Authorization: Bearer {取得したaccess_token}
}

AuthGuardを問題なくPASSして、JWTのPayloadに設定した値が返ってきたね(^^)

乙!

これでJWTを使ってエンドポイントの保護と認証ができますた
localStorageなりcookieなりに保存してログイン状態を保持できてこそログイン機能作れた感あるよね。次回やりましょう!

Discussion