NestJSでJWTを使った認証を実装する

19 min read読了の目安(約17300字

この記事について

NestJSで、JWTを使った認証機能を実装して行きます💪

※ 基本的に以下の公式ドキュメントを参考にしていますが、一部実用的なモノに近づけた形で内容を変更しています。

https://docs.nestjs.com/security/authentication

必要なnpmモジュールをインストール

既にnestコマンドなどで、プロジェクトのひな型を生成している事を想定しています。

$ npm i --save @nestjs/passport passport passport-local @nestjs/jwt passport-jwt
$ npm i --save-dev @types/passport-local @types/passport-jwt

passportは、nodejsでよく使われている認証ライブラリ。
passport-localは、ユーザー名とパスワードでログインできる機能を実装できるライブラリ。
passport-jwtは、JWTの検証などをするためのライブラリ。

※ passportは、passport-localやpassport-jwtなどのライブラリを戦略( strategy )と言うので、覚えておいた方が良いかもしれません。

Passport recognizes that each application has unique authentication requirements. Authentication mechanisms, known as strategies, are packaged as individual modules. Applications can choose which strategies to employ, without creating unnecessary dependencies.

また他の認証ロジックを使いたい場合は、PassportのサイトからStrategyを検索できます👨‍💻

http://www.passportjs.org/packages/

認証機能を実装する

既にTypeORMでUserエンティティを定義している事を前提に進めていきます。
まだ作ってない場合やTypeORM以外を使っている場合は、適時対応してください🙏

nestコマンドでひな型を作成して、local.strategy.tsなどのファイルも作成します。

$ # 認証機能を扱うモジュールとサービスクラスのひな型を作成
$ nest g module auth
$ nest g service auth

$ # ユーザー情報を扱うモジュールとサービスクラスのひな型を作成
$ nest g module users
$ nest g service users

$ # Strategyファイルを作成
$ touch src/auth/local.strategy.ts
$ touch src/auth/jwt.strategy.ts

次に作ったひな型やファイルを修正して行きます。

UsersServiceの実装

users/users.service.ts
import { Repository } from 'typeorm';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { User } from './user.entity'; // typeormで定義したUserエンティティ

/**
 * @description User情報を扱うクラス
 */
@Injectable()
export class UsersService {

  constructor(
    @InjectRepository(User)
    private readonly userRepository: Repository<User>
  ) {}

  // ユーザーを一人を返す
  findOne(username: User['name']): Promise<User | undefined> {
    // typeormからDBにアクセスして、ユーザーを取得する
    return this.userRepository.findOne({ where: { name } });
  }
}

UsersModuleの実装

users/users.modules.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from './user.entity'; // typeormで定義したUserエンティティ
import { UsersService } from './users.service'; // 上記で定義したServiceクラス

@Module({
  // UserエンティティをUsersServiceで使えるようにする
  imports: [TypeOrmModule.forFeature([User])], 

  // exportsするために必要。UsersModule内で使うのにも必要。
  providers: [UsersService], 

  // UsersServiceを他のクラスでも使えるようにする
  exports: [UsersService], 
})
export class UsersModule {}

AuthServiceの実装

auth/auth.service.ts
import bcrypt = require('bcrypt');
import { JwtService } from '@nestjs/jwt';
import { Injectable } from '@nestjs/common';
import { User } from 'src/users/user.entity';
import { UsersService } from 'src/users/users.service';

type PasswordOmitUser = Omit<User, 'password'>;

interface JWTPayload  {
  userId: User['id'];
  username: User['name'];
}

/**
 * @description Passportでは出来ない認証処理をするクラス
 */
@Injectable()
export class AuthService {
  constructor(private jwtService: JwtService, private usersService: UsersService) {}

  // ユーザーを認証する
  async validateUser(name: User['name'], pass: User['password']): Promise<PasswordOmitUser | null> {
    const user = await this.usersService.findOne(name); // DBからUserを取得

    // DBに保存されているpasswordはハッシュ化されている事を想定しているので、
    // bcryptなどを使ってパスワードを判定する
    if (user && bcrypt.compareSync(pass, user.password)) {
      const { password, ...result } = user; // パスワード情報を外部に出さないようにする

      return result;
    }

    return null;
  }

  // jwt tokenを返す
  async login(user: PasswordOmitUser) {
    // jwtにつけるPayload情報
    const payload: JwtPayload = { userId: user.id, username: user.name };

    return {
      access_token: this.jwtService.sign(payload),
    };
  }
}

LocalStrategyの実装

auth/local.strategy.ts
// import先が'passport-jwt'では無い事に注意!
import { Strategy as BaseLocalStrategy } from 'passport-local';

import { PassportStrategy } from '@nestjs/passport';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { AuthService } from './auth.service';
import { User } from 'src/users/user.entity';

type PasswordOmitUser = Omit<User, 'password'>;

/**
 * @description usernameとpasswordを使った認証処理を行うクラス
 */
@Injectable()
export class LocalStrategy extends PassportStrategy(BaseLocalStrategy) {
  constructor(private authService: AuthService) {
    super();
  }

  // passport-localは、デフォルトで username と password をパラメーターで受け取る
  async validate(name: User['name'], pass: User['password']): Promise<PasswordOmitUser> {
    // 認証して結果を受け取る
    const user = await this.authService.validateUser(name, pass);

    if (!user) {
      throw new UnauthorizedException(); // 認証失敗
    }

    return user;
  }
}

JwtStrategyの実装

auth/jwt.strategy.ts
// import先が'passport-local'では無い事に注意!
import { ExtractJwt, Strategy as BaseJwtStrategy } from 'passport-jwt'; 

import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { JwtPayload } from './auth.interface';
import { User } from 'src/users/user.entity';


// JwtについているPayload情報の型
interface JWTPayload  {
  userId: User['id'];
  username: User['name'];
}

/**
 * @description JWTの認証処理を行うクラス
 */
@Injectable()
export class JwtStrategy extends PassportStrategy(BaseJwtStrategy) {
  constructor(private readonly configService: ConfigService) {
    super({
      // Authorization bearerからトークンを読み込む関数を返す
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), 
      // 有効期間を無視するかどうか
      ignoreExpiration: false,
      // envファイルから秘密鍵を渡す
      secretOrKey: configService.get<string>('JWT_SECRET_KEY'), 
    });
  }

  // ここでPayloadを使ったバリデーション処理を実行できる
  // Payloadは、AuthService.login()で定義した値
  async validate(payload: JWTPayload): Promise<JwtPayload> {
    return { userId: payload.userId, username: payload.username };
  }
}

AuthModuleの実装

上記で実装してきたクラス達をAuthModuleでまとめます。

auth/auth.module.ts
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { PassportModule } from '@nestjs/passport';

import { AuthService } from './auth.service';
import { UsersModule } from 'src/users/users.module';

// Strategyクラス
import { JwtStrategy } from './jwt.strategy';
import { LocalStrategy } from './local.strategy';

@Module({
  imports: [
    UsersModule,
    PassportModule,

    // JWTを使うための設定をしている
    JwtModule.registerAsync({
      useFactory: async (configService: ConfigService) => {
        return {
          // envファイルから秘密鍵を渡す
          secret: configService.get<string>('JWT_SECRET_KEY'), 
          signOptions: { 
            // 有効期間を設定
            // 指定する値は以下を参照
            // https://github.com/vercel/ms
            expiresIn: '1200s' 
          },
        };
      },
      inject: [ConfigService], // useFactoryで使う為にConfigServiceを注入する
    }),
  ],
  providers: [AuthService, LocalStrategy, JwtStrategy],
  exports: [AuthService],
})
export class AuthModule {}

AppModuleの実装

そして、AuthModuleAppModuleにインポートします。
これによって、認証処理をNestJSアプリ全体に組み込めるので、忘れないようにしましょう👨‍🏫

src/app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { UsersModule } from './users/users.module';
import { AuthModule } from './auth/auth.module';
import { RentalModule } from './rental/rental.module';
import { SellModule } from './sell/sell.module';

@Module({
  imports: [
    TypeOrmModule.forRoot(), // typeormを使うために使用
    ConfigModule.forRoot({ // envファイルを組み込むために使用
      isGlobal: true,
    }),
    AuthModule, // 必須!これが無いと認証処理が動かない
  ],
  controllers: [
    AppController // 後述するクラス
  ], 
  providers: [AppService],
})
export class AppModule {}

上記まで出来たら、AppControllerを定義して実際のリクエストを受け取れるようにします。

AppControllerの実装

ここの部分はAuthControllerを用意して、そちらのクラスに記述した方が良いかもしれないです。

app.controller.ts
import { AuthGuard } from '@nestjs/passport';
import { Controller, Get, Post, Request, UseGuards } from '@nestjs/common';
import { User } from './users/user.entity';
import { AppService } from './app.service';
import { AuthService } from './auth/auth.service';

type PasswordOmitUser = Omit<User, "password">

@Controller()
export class AppController {
  constructor(private readonly authService: AuthService) {}

  @UseGuards(AuthGuard('local')) // passport-local戦略を付与する
  @Post('login') 
  async login(@Request() req: { user: PasswordOmitUser }) {
    // LocalStrategy.validate()で認証して返した値がreq.userに入ってる
    const user = req.user;

    // JwtToken を返す
    return this.authService.login(req.user);
  }

  /**
   * @description JWT認証を用いたサンプルAPI
   */
  @UseGuards(AuthGuard('jwt')) // passport-jwt戦略を付与する
  @Get('profile')
  getProfile(@Request() req: { user: PasswordOmitUser }) {
    // JwtStrategy.validate()で認証して返した値がreq.userに入ってる
    const user = req.user;

    // 認証に成功したユーザーの情報を返す
    return req.user;
  }
}

認証機能を確認する

上記で実装した認証機能を、curlなどを使って実際にリクエストして処理を確認していきます。

ログインしてみる

以下のようにして、ログインしてみます。

※ ログインするユーザーは、各自で用意してください。

AppController.login()にリクエストしてみる
$ curl -X POST http://localhost:3000/login -d '{"username": "john", "password": "changeme"}' -H "Content-Type: application/json"
$ # result -> {"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2Vybm... }

上記のように、{ "access_token": "..." }みたいな値が帰ってくれば成功です🎉

この時のパラメーターは{ "username": "...", "password": "..." }となっていますが、
これはpassport-local戦略によるものです。パラメーターを変更したい場合は、passport-localの設定を変更する必要があります。
詳しくは : https://docs.nestjs.com/security/authentication#customize-passport

実際の処理の流れ

ログインリクエストを投げた時に、この記事で書いて来たソースコードがどの順番で実行されるかを以下に示します。

1. AppController.login()がリクエストを受け取る( ※ブロックの実行はまだ )

ここでの注意するべきところがあります!
それは、login()のブロックに記述されている処理はまだ実行されないという事です。
何故なら、実行される前にデコレーターの@UseGuards(AuthGuard('local'))の処理が走るためです。

該当の部分
export class AppController {
  
  /* -- 省略 -- */

  @UseGuards(AuthGuard('local')) // passport-local戦略を付与する
  @Post('login') 
  async login(@Request() req: { user: PasswordOmitUser }) {
    // LocalStrategy.validate()で認証して返した値がreq.userに入ってる
    const user = req.user;

    // JwtToken を返す
    return this.authService.login(req.user);
  }

  /* -- 省略 -- */
}

2. @UseGuards(AuthGuard('local'))によって、LocalStrategy.validate()が実行される

LocalStrategyBaseLocalStrategy を継承しているため、passport-localが実行される時に、@nestjs/passportによって内部でLocalStrategy.validate()を実行します。
なので、LocalStrategy.validate()を明示的に実行している部分はソースコードにはありませんので、注意してください。

該当の部分
@Injectable()
export class LocalStrategy extends PassportStrategy(BaseLocalStrategy) {
  
  /* -- 省略 -- */
  
  /**
   * @note passport-localは、デフォルトで username と password を取る
   */
  async validate(name: User['name'], pass: User['password']): Promise<PasswordOmitUser> {
    const user = await this.authService.validateUser(name, pass);

    if (!user) {
      throw new UnauthorizedException();
    }

    return user;
  }
}

3. LocalStrategy.validate()が成功した場合、AppController.login()が実行される。

ここで初めて、AppController.login()のブロック内に記述された処理が実行されます。

※ もしLocalStrategy.validate()が認証に失敗した場合(Errorを投げた場合)は、AppController.login()は実行されません。

該当の部分
async login(@Request() req: { user: PasswordOmitUser }) {
  // LocalStrategy.validate()で認証して返した値がreq.userに入ってる
  const user = req.user;

  // JwtToken を返す
  return this.authService.login(req.user);
}

4. this.authService.login()が実行される

AuthService.login()が実行され、JWTのトークンを生成して返します。

該当の部分
@Injectable()
export class AuthService {
  
  /* -- 省略 -- */

  /**
   * @description jwt tokenを返す
   */
  async login(user: PasswordOmitUser) {
    const payload: JwtPayload = { username: user.name, userId: user.id };

    return {
      access_token: this.jwtService.sign(payload),
    };
  }
}

5. リクエスト結果として{ "access_token": "..." }が返ってくる

curlの結果
$ # result -> {"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2Vybm... }

JWTを使ってリクエストしてみる

次に/loginで取得したJWTを使ってみましょう。
acess_tokenの値をヘッダーに付けてリクエストすることで、JWT認証することが出来ます。
実際にやってみると以下のようになります。

AppController.getProfile()にリクエストしてみる
$ curl http://localhost:3000/profile -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2Vybm..."
$ # result -> {"userId":1,"username":"john"}

ちゃんとUser情報が取得出来ていたら成功です🎊

もしaccess_tokenを付けていても失敗した場合は、有効期限が切れている可能性がありますので、トークンを再生成してから同じようにやってみると成功すると思います。 (この記事では1200秒を有効期限として設定しています。)

実際の処理の流れ

JWTが必要なリクエスト( 今回は/profile )を投げた時に、この記事で書いて来たソースコードがどの順番で実行されるかを以下に示します。

1. AppController.getProfile()がリクエストを受け取る( ※ブロックの実行はまだ )

ここでも /login の時と同じように、 AppController.getProfile() はまだ実行されません。
代わりに、デコレーターの @UseGuards(AuthGuard('jwt')) の部分が実行されます。

該当の部分
@Controller()
export class AppController {
  
  /* -- 省略 -- */

  /**
   * @description JWT認証を用いたサンプルAPI
   */
  @UseGuards(AuthGuard('jwt')) // passport-jwt戦略を付与する
  @Get('profile')
  getProfile(@Request() req: { user: PasswordOmitUser }) {
    // JwtStrategy.validate()で認証して返した値がreq.userに入ってる
    const user = req.user;

    // 認証に成功したユーザーの情報を返す
    return req.user;
  }
}

2. @UseGuards(AuthGuard('jwt'))によって、JwtStrategy.validate()が実行される

ここも LocalStrategy とほとんど同じです。
JwtStrategy は、 BaseJwtStrategy を継承しているため、passport-jwtが実行される時に、@nestjs/passportによって内部でJwtStrategy.validate()を実行します。
なので、JwtStrategy.validate()を明示的に実行している部分はソースコードにはありませんので、注意してください。

該当の部分
@Injectable()
export class JwtStrategy extends PassportStrategy(BaseJwtStrategy) {

  /* -- 省略 -- */
 
  // ここでPayloadを使ったバリデーション処理を実行できる
  // Payloadは、AuthService.login()で定義した値
  async validate(payload: JWTPayload): Promise<JwtPayload> {
    return { userId: payload.userId, username: payload.username };
  }
}

3. JwtStrategy.validate()が成功した場合、AppController.getProfile()が実行される。

ここで初めて、AppController.getProfile()のブロック内に記述された処理が実行されます。

該当の部分
getProfile(@Request() req: { user: PasswordOmitUser }) {
  // JwtStrategy.validate()で認証して返した値がreq.userに入ってる
  const user = req.user;

  // 認証に成功したユーザーの情報を返す
  return req.user;
}

4. リクエスト結果としてリクエストしたユーザー情報が返ってくる

curlの結果(/profile)
$ # result -> {"userId":1,"username":"john"}

JWTに失敗した時

因みに、もし認証に失敗すると以下のようになります。

わざとaccess_tokenをつけずにリクエストして見る
$ curl http://localhost:3000/profile
$ # result -> {"statusCode":401,"message":"Unauthorized"}

上記より、ちゃんと認証が効いていることが確認できますね🔐

終わり

これで認証処理を付けることが出来ました。

これから先は、認証を付けたいメソッドに@UseGuards(AuthGuard('jwt'))を付けてあげるか、以下のようにControllerにつける事で、そのController全体に適用することも出来ます。

全体にjwt認証を適用する
import { AuthGuard } from '@nestjs/passport';
import { UseGuards, Controller } from '@nestjs/common';

@Controller("sample")
@UseGuards(AuthGuard('jwt')) // ここに追加することで、Controller全体にjwt認証を適用する
export class SampleController {
  /* -- 省略 -- */
}

以上で、解説終了です。お疲れさまでした🙌

セッション管理する方法

参考した公式ドキュメントより以下を引用。

Manage authenticated state (by issuing a portable token, such as a JWT, or creating an Express session)

どうやら今回紹介したJWTを使う方法の他に、express-sessionを使う方法があるらしいです。express-sessionの設定方法などは、以下のドキュメントを参考に実装できると思います。fastify-secure-sessionによる設定方法も載っているので、そちらを使う事も出来るようです。

https://docs.nestjs.com/techniques/session

ここら辺の実装は、知見がまとまったら別記事にして共有したいと思います🏌️‍♂️

JWTの鍵生成について🔑

私が無知なだけかもしれないですが、もしかしたら知らない人が居るかもしれないので共有しておきます🙏 注意として、セキュリティ的に間違っている場合がありますので、あくまで参考程度にした方が良いと思います。
より良い方法を知っている方は、コメントなどで教えて頂けると嬉しいです🙇‍♂️

今回の実装では、@nestjs/configを使ってenvファイル上にある秘密鍵を使ったのですが、その秘密鍵の作り方を紹介したいと思います。

まず、以下のコマンドを実行して秘密鍵と公開鍵を作成します。

鍵の作成
$ ssh-keygen -t rsa -b 4096 -m pem -f 任意のフォルダーパス
$ # 例 : ssh-keygen -t rsa -b 4096 -m pem -f /nest-app/.settings/id_rsa

すると、-fで指定した場所に鍵ファイルが出来ていると思います。( 指定してなければ.sshフォルダーにあると思います。) しかし、公開鍵ファイル(.pubファイル)の方がPEMとして出力されてなかったので、以下のコマンドを実行してPEMに変換します。

公開鍵をPEMに変換
$ ssh-keygen -f 公開鍵ファイルへのパス -e -m pem > 新しく作るPEMファイルパス
$ # 例 : ssh-keygen -f id_rsa.pub -e -m pem > id_rsa.pem.pub

そして、出来たファイルをenvファイルにコピペします。
ここでの注意として、改行が反映されないので、\nを用いて改行する必要があります。

.env
JWT_PUBLIC_KEY="-----BEGIN RSA PUBLIC KEY-----\n ... \n-----END RSA PUBLIC KEY-----"
JWT_SECRET_KEY="-----BEGIN RSA PRIVATE KEY-----\n ... \n-----END RSA PRIVATE KEY-----"

コピペが出来たら、以下のようにして鍵情報を取得することが出来るようになります。

.envから秘密鍵の情報を取得する
const secret_key = configService.get<string>('JWT_SECRET_KEY');

@nestjs/configの詳しい使い方は、以下のスクラップを参考にして下さい。

https://zenn.dev/uttk/scraps/5169d2bb2e3d767533f3#comment-65d278330ab6d7a86627

参考にした記事

https://qiita.com/sa9ra4ma/items/7e8bb9e4877249ff2001

https://qiita.com/kunichiko/items/12cbccaadcbf41c72735

この記事に贈られたバッジ