Nest.JSでJWT認証機能を実装する

# サインアップ編
※ 簡単なUser情報を入力してサインアップを行うことを想定しています。

1. JwtModuleをAuthModuleにimportする。
    - AuthModuleは認証用のモジュールで、実際のプロジェクトによってモジュール名は異なる場合があるので注意。

    ```typescript
    import { Module } from '@nestjs/common';
    import { PrismaModule } from 'src/prisma/prisma.module';
    import { AuthController } from './auth.controller';
    import { AuthService } from './auth.service';
    import { JwtModule } from '@nestjs/jwt';
    
    @Module({
      imports: [PrismaModule, JwtModule.register({})],
      controllers: [AuthController],
      providers: [AuthService],
    })

    export class AuthModule {}
    ```

2. JWT用の環境変数を設定する。

    ```.env
    JWT_SECRET="test"
    ```

3. DTOを作成する。
    ```typescript
    import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator';

    export class AuthDto {
      @IsEmail()
      @IsNotEmpty()
      email: string;
    
      @IsString()
      @IsNotEmpty()
      @MinLength(5)
      password: string;
    }
    ```

4. インターフェースを作成する。

    ```typescript
    export interface Jwt {
      accessToken: string;
    }
    ```

5. 認証サービスを作成する。

    ```typescript
    import { Injectable, ForbiddenException } from '@nestjs/common';
    import { PrismaClientKnownRequestError } from '@prisma/client/runtime';
    import { ConfigService } from '@nestjs/config';
    import { JwtService } from '@nestjs/jwt';
    import * as bcrypt from 'bcrypt';
    import { PrismaService } from '../prisma/prisma.service';
    import { AuthDto } from './dto/auth.dto';
    
    @Injectable()
    export class AuthService {
      constructor(
        private readonly prisma: PrismaService,
        private readonly jwt: JwtService,
        private readonly config: ConfigService,
      ) {}
      async signUp(dto: AuthDto): void {
        const hashed = await bcrypt.hash(dto.password, 12);
        try {
          return await this.prisma.user.create({
            data: {
              email: dto.email,
              hashedPassword: hashed,
            },
          });;
        } catch (error) {
          if (error instanceof PrismaClientKnownRequestError) {
            if (error.code === 'P2002') {
              throw new ForbiddenException('This email is already taken');
            }
          }
          throw error;
        }
      }
    }
    ```

6. コントローラーを実装する。

    ```typescript
    import {
      Controller,
      Post,
      Body,
    } from '@nestjs/common';
    import { AuthService } from './auth.service';
    import { AuthDto } from './dto/auth.dto';
    
    @Controller('auth')
    export class AuthController {
      constructor(private readonly authService: AuthService) {}
    
      @Post('signup')
      signUp(@Body() dto: AuthDto): void {
        return this.authService.signUp(dto);
      }
    }
    ```

# ログイン編
※ 「サインアップ編」が完了しているものとする。
※ cookieにJWTトークンを格納することを想定する。

1. サービスにログイン用の関数を定義する。

    ```typescript
    import { Injectable, ForbiddenException } from '@nestjs/common';
    import { PrismaClientKnownRequestError } from '@prisma/client/runtime';
    import { ConfigService } from '@nestjs/config';
    import { JwtService } from '@nestjs/jwt';
    import * as bcrypt from 'bcrypt';
    import { PrismaService } from '../prisma/prisma.service';
    import { AuthDto } from './dto/auth.dto';
    import { Jwt } from './interfaces/auth.interface';
    
    @Injectable()
    export class AuthService {
      constructor(
        private readonly prisma: PrismaService,
        private readonly jwt: JwtService,
        private readonly config: ConfigService,
      ) {}
    
      ...
    
      async login(dto: AuthDto): Promise<Jwt> {
        const user = await this.prisma.user.findUnique({
          where: {
            email: dto.email,
          },
        });
    
        if (!user) throw new ForbiddenException('Email or password incorrect');
        const isValid = await bcrypt.compare(dto.password, user.hashedPassword);
    
        if (!isValid) throw new ForbiddenException('Email or password incorrect');
        return this.generateJwt(user.id, user.email);
      }
    
      private async generateJwt(userId: number, email: string): Promise<Jwt> {
        const payload = {
          sub: userId,
          email,
        };
        const secret = this.config.get('JWT_SECRET');
        const token = await this.jwt.signAsync(payload, {
          expiresIn: '5m',
          secret: secret,
        });
    
        return {
          accessToken: token,
        };
      }
    }
    ```

2. コントローラーにログイン用のエンドポイントを追加する。

    ```typescript
    import {
      Controller,
      Post,
      Body,
      HttpCode,
      HttpStatus,
      Res,
    } from '@nestjs/common';
    import { AuthService } from './auth.service';
    import { AuthDto } from './dto/auth.dto';
    
    @Controller('auth')
    export class AuthController {
      constructor(private readonly authService: AuthService) {}

      ...
    
      @HttpCode(HttpStatus.OK)
      @Post('login')
      async login(
        @Body() dto: AuthDto,
        @Res({ passthrough: true }) res: Response,
      ): void {
        const jwt = await this.authService.login(dto);

        return res.cookie('access_token', jwt.accessToken, {
          httpOnly: true,
          secure: true,
          sameSite: 'none',
          path: '/',
        });
      }
    }
    ```

# API認証編
※ user更新APIを実装する。

1. DTOを作成する。

    ```typescript
    import { IsNotEmpty, IsString } from 'class-validator';
    
    export class UpdateUserDto {
      @IsString()
      @IsNotEmpty()
      email: string;
    }
    ```

2. サービスにユーザー更新用の関数を定義する。

    ```typescript
    import { Injectable } from '@nestjs/common';
    import { PrismaService } from '../prisma/prisma.service';
    import { UpdateUserDto } from './dto/update-user.dto';
    import { User } from '@prisma/client';
    
    @Injectable()
    export class UserService {
      constructor(private prisma: PrismaService) {}
    
      async updateUser(
        userId: number,
        dto: UpdateUserDto,
      ): Promise<Omit<User, 'hashedPassword'>> {
        const user = await this.prisma.user.update({
          where: {
            id: userId,
          },
          data: {
            ...dto,
          },
        });
        delete user.hashedPassword;
        return user;
      }
    }
    ```

3. JWTストラテジーを作成する。

    ```typescript
    import { Injectable } from '@nestjs/common';
    import { ConfigService } from '@nestjs/config';
    import { PassportStrategy } from '@nestjs/passport';
    import { ExtractJwt, Strategy } from 'passport-jwt';
    import { PrismaService } from '../../prisma/prisma.service';
    
    @Injectable()
    export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
      constructor(
        private readonly config: ConfigService,
        private readonly prisma: PrismaService,
      ) {
        super({
          jwtFromRequest: ExtractJwt.fromExtractors([
            (req) => {
              let jwt = null;
              if (req && req.cookies) {
                jwt = req.cookies['access_token'];
              }
              return jwt;
            },
          ]),
          ignoreExpiration: false,
          secretOrKey: config.get('JWT_SECRET'),
        });
      }
    
      async validate(payload: { sub: number; email: string }) {
        const user = await this.prisma.user.findUnique({
          where: {
            id: payload.sub,
          },
        });
        delete user.hashedPassword;
        return user;
      }
    }
    ```

4. コントローラーにユーザー更新用のエンドポイントを追加する。

    ```typescript
    import { Body, Controller, Patch, Req, UseGuards } from '@nestjs/common';
    import { AuthGuard } from '@nestjs/passport';
    import { Request } from 'express';
    import { UserService } from './user.service';
    import { UpdateUserDto } from './dto/update-user.dto';
    import { User } from '@prisma/client';
    
    @UseGuards(AuthGuard('jwt'))
    @Controller('user')
    export class UserController {
      constructor(private readonly userService: UserService) {}
    
      @Patch()
      updateUser(
        @Req() req: Request,
        @Body() dto: UpdateUserDto,
      ): Promise<Omit<User, 'hashedPassword'>> {
        return this.userService.updateUser(req.user.id, dto);
      }
    }
    ```

5. JwtStrategyをAuthModuleに読み込ませる。

    ```typescript
    import { Module } from '@nestjs/common';
    import { PrismaModule } from 'src/prisma/prisma.module';
    import { AuthController } from './auth.controller';
    import { AuthService } from './auth.service';
    import { JwtModule } from '@nestjs/jwt';
    import { JwtStrategy } from './strategy/jwt.strategy';
    
    @Module({
      imports: [PrismaModule, JwtModule.register({})],
      controllers: [AuthController],
      providers: [AuthService, JwtStrategy],
    })

    export class AuthModule {}
    ```

# ログアウト編

1. コントローラーにログアウト用のエンドポイントを追加する。

    ```typescript
    import {
      Controller,
      Post,
      Body,
      HttpCode,
      HttpStatus,
      Res,
      Req,
      Get,
    } from '@nestjs/common';
    import { Request, Response } from 'express';
    import { AuthService } from './auth.service';
    import { AuthDto } from './dto/auth.dto';
    
    @Controller('auth')
    export class AuthController {
      constructor(private readonly authService: AuthService) {}
    
      ...
    
      @HttpCode(HttpStatus.OK)
      @Post('/logout')
      logout(@Req() req: Request, @Res({ passthrough: true }) res: Response): void {
        return res.cookie('access_token', '', {
          httpOnly: true,
          secure: true,
          sameSite: 'none',
          path: '/',
        });
      }
    }
    ```

# CSRFトークン設定編

1. csurfの設定を行う。

    ```main.ts
    import { NestFactory } from '@nestjs/core';
    import { AppModule } from './app.module';
    import { ValidationPipe } from '@nestjs/common';
    import { Request } from 'express';
    import * as cookieParser from 'cookie-parser';
    import * as csurf from 'csurf';
    
    async function bootstrap() {
      const app = await NestFactory.create(AppModule);
      
      ...
    
      app.use(
        csurf({
          cookie: {
            httpOnly: true,
            sameSite: 'none',
            secure: true,
          },
          value: (req: Request) => {
            return req.header('csrf-token');
          },
        }),
      );
      await app.listen(process.env.PORT || 3005);
    }
    bootstrap();
    ```

2. CSRFトークンを返すエンドポイントを追加する。

    ```
    import {
      Controller,
      Post,
      Body,
      HttpCode,
      HttpStatus,
      Res,
      Req,
      Get,
    } from '@nestjs/common';
    import { Request, Response } from 'express';
    import { AuthService } from './auth.service';
    import { AuthDto } from './dto/auth.dto';
    import { Csrf } from './interfaces/auth.interface';
    
    @Controller('auth')
    export class AuthController {
      constructor(private readonly authService: AuthService) {}
    
      @Get('/csrf')
      getCsrfToken(@Req() req: Request): Csrf {
        return { csrfToken: req.csrfToken() };
      }
    
      ...
    }
    ```

3. APIを叩く場合はヘッダー情報にCSRFトークンを設定するようにする。

    ```
    csrf-token: 受け取ったCSRFトークン
    ```

# 参考
[Guards | NestJS](https://docs.nestjs.com/guards)
[Guards | NestJS 【翻訳】](https://qiita.com/mana-vv/items/1c975573516eeefa92b3)
[NestJSでGuardを使って認可を実装する](https://zenn.dev/vbbvbbvbbv/articles/cd46e02719b71a)
[Nest.jsでGuardからControllerへデータを渡す方法](https://qiita.com/biga816/items/bc0fb17725f9dc2b8525)
[NestJSのAuthGuard周りをアレンジしたり、深掘りしたり](https://zenn.dev/maronn/articles/about-auth-guard-in-nestjs)
[NestJSでJWTを使った認証を実装する](https://zenn.dev/uttk/articles/9095a28be1bf5d)