Closed11

[キャッチアップ] Nest.js で JWT を使ったユーザー認証

shingo.sasakishingo.sasaki

概要

  • Nest.js を業務で扱うための素振り中
  • マイクロサービスとしての運用を想定しているので、JWT を用いた認証をどう提供できるのか試してみる
  • 試行錯誤段階なので、内容に不備とか誤りあると思う
  • 公式ドキュメントをベースにするが、ドキュメントの和訳を書くわけではなくメモ程度
  • MySQL typeORM を用いて、既に認証対象となる User モデルが作成済みの状態からスタート
shingo.sasakishingo.sasaki

Passport

  • 人気の Node 認証ライブラリ
  • 認証手順は概ね以下の通り
    • クレデンシャルを使ってユーザーを認証する
    • 認証状態を管理する
    • 認証状態を Request オブジェクトに紐付けて、ルートハンドラで使用できるようにする
  • シンプルな手順ではあるが、豊富なエコシステムを持ち合わせている
    • クレデンシャルにはパスワードのほかに、JWT や IdP トークンが使用可能
    • JWT トークンや Express session のようなポータブルなトークンの発行が可能

@nestjs/passport

  • Nest.js のアーキテクチャに Passport の仕組みを取り入れるための公式パッケージ
  • Guard と組み合わせることで、宣言的に認証の仕組みをエンドポイント単位で組み込める
shingo.sasakishingo.sasaki

username/password で認証する仕組みを作る

まずはリクエストボディに含まれる、 username password のフィールドを用いて、 User テーブルに合致するユーザーがいるかを認証する仕組みを作っていく。

パッケージのインストール

$ yarn add @nestjs/passport passport passport-local
$ yarn add -D @types/passport-local
  • passport : 本体
  • passport-local : username/password 認証のためのモジュール
  • @nestjs/passport : passport を Nest アプリに組み込むためのモジュール
  • @types/passport-local : TS を使う場合の型パッケージ

認証用モジュール、サービスを作成

$ nest g module auth
$ nest g service auth --no-spec

CLI を用いて、認証用のモジュールとサービスを生成する。

今回はテストコードまで手を入れないので --no-spec を付けておく

認証対象となる User エンティティを用意しておく

今回使用する User エンティティはだいたいこんなやつ
(※ 実際は他にもカラムがあったり、コンストラクタが生えてたりするけど割愛)

@Entity()
export class User extends BaseEntity {
  @PrimaryGeneratedColumn()
  readonly id!: number

  @Column()
  name!: string

  @Column()
  password!: string

  // 以下略
}

SQLで出力するとこんな感じ

mysql> select * from user;
+----+--------+-------------+-----------------+--------------+----------------------------+----------------------------+
| id | name   | displayName | description     | password     | createdAt                  | updatedAt                  |
+----+--------+-------------+-----------------+--------------+----------------------------+----------------------------+
|  1 | sasaki | 笹木        | こんにちは      | 1q2w3e4r5t6y | 2021-03-13 13:39:46.118788 | 2021-03-13 13:39:46.118788 |
+----+--------+-------------+-----------------+--------------+----------------------------+----------------------------+
1 row in set (0.00 sec)

パスワードが平文で入ってるやばいやつだけど、今回は素振りなので暗号化も不要

User サービスを Auth モジュールから利用できるようにする

@Module({
  imports: [TypeOrmModule.forFeature([User])],
  providers: [UsersService],
  controllers: [UsersController],
  exports: [UsersService] // これを追加
})
export class UsersModule {}

モジュールの exports フィールドに、他のモジュールでも使えるように UserService を追加する

これで下準備は完了

shingo.sasakishingo.sasaki

Auth サービスに認証用のメソッドを追加する

auth.service.ts
@Injectable()
export class AuthService {
  constructor(private usersService: UsersService) {} // 注入される予定の User サービス

  async validateUser(name: string, password: string) {
    const user = await this.usersService.findByName(name)
    if (user && user.password === password) {
      const { password, ...result } = user
      return result
    }
    return null
  }
}

username と password を受け取り、パスワードが合致していれば、 password フィールドを取り除いた User エンティティを戻す。

shingo.sasakishingo.sasaki

username/password による認証用のクラスを作成する

Passport では任意の認証方法を用いた認証状態の管理を行えるが、ここでは username/password を用いた passport-local を使用するため、それを Nest に組み込むための LocalStrategy クラスを作成する。

auth/local.strategy.ts
@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
  constructor(private authService: AuthService) {
    super()
  }

  async validate(username: string, password: string) {
    const user = await this.authService.validateUser(username, password)
    if (!user) {
      throw new UnauthorizedException()
    }
    return user
  }
}

LocalStrategy クラスは、認証方法に基づく PassportStrategy クラスを継承しており、ここでは validate メソッドを定義することで、 Passport 側に、どのように認証を行うかの具体的な方法を伝える。

shingo.sasakishingo.sasaki

Auth モジュールの依存関係を整理する

Auth モジュールは、 Auth サービスを利用しつつ、そこに User サービス を注入するため、このような構成になる

auth/auth.module.ts
@Module({
  imports: [UsersModule, PassportModule],
  providers: [AuthService, LocalStrategy]
})
export class AuthModule {}
shingo.sasakishingo.sasaki

ログインエンドポイントを作る

app.controller.ts
@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

  @UseGuards(AuthGuard('local'))
  @Post('auth/login')
  async login(@Request() req: any) {
    return req.user
  }
}

ログイン用のエンドポイント POST /auth/login を作成し、AuthGuard を用いて、リクエストパラメータに含まれるクレデンシャルから認証する。

動作確認

curl コマンドでリクエストを投げると

$ curl -X POST http://localhost:3000/auth/login -d '{"username": "sasaki", "password": "1q2w3e4r5t6y"}' -H "Content-Type: application/json"

password フィールドが取り除かれた User 情報が返ってくる

{"id":1,"name":"sasaki","displayName":"笹木","description":"こんにちは","createdAt":"2021-03-13T04:39:46.118Z","updatedAt":"2021-03-13T04:39:46.118Z"}

これで認証するための基本的な仕組みが出来上がったが、まだ認証された状態を用いて他のエンドポイントを利用すると言ったことは何も出来ないので、ここからは JWT の生成と再利用を行っていく。

shingo.sasakishingo.sasaki

JWT を扱うためのパッケージの追加

$ yarn add @nestjs/jwt passport-jwt
$ yarn add -D @types/passport-jwt
  • @nestjs/jwt: Nest で JWT を扱うための汎用パッケージ
  • passport-jwt: Passport で JWT を扱うためのパッケージ
  • @types/passport-jwt: passport-jwt の型パッケージ
shingo.sasakishingo.sasaki

Auth サービスに、JWT 生成用のメソッドを追加する

auth/auth.service.ts
@Injectable()
export class AuthService {
  constructor(private usersService: UsersService, private jwtService: JwtService) {}

  async validateUser(name: string, password: string) {
    const user = await this.usersService.findByName(name)
    if (user && user.password === password) {
      const { password, ...result } = user
      return result
    }
    return null
  }

  async login(user: any) {
    const payload = { username: user.name, sub: user.id }
    return {
      access_token: this.jwtService.sign(payload)
    }
  }
}

JwtService が注入されるようにコンストラクタを変更し、JWT を生成する login メソッドを実装する。

ここでは、認証を通した User を受け取り、それを元に JWT のペイロードを作成、 JwtService#sign を用いてトークンを生成している。

JWT 用のサービスを Auth モジュールに注入する

Auth サービスから JwtService を利用できるように、 JwtModule.register を用いてモジュールを追加する。

auth/auth.module.ts
@Module({
  imports: [
    UsersModule,
    PassportModule,
    JwtModule.register({
      secret: '1q2w3e4r5t6y',
      signOptions: { expiresIn: '60s' }
    })
  ],
  providers: [AuthService, LocalStrategy],
  exports: [AuthService, JwtModule]
})
export class AuthModule {}

この際に使用する secret は、ここでは素振り用ということでハードコードしておくが、本来は秘匿情報として扱う。

ログイン用のエンドポイントで、JWT を生成して返却する

app.controller.ts
@Controller()
export class AppController {
  constructor(private readonly authService: AuthService) {}

  @UseGuards(AuthGuard('local'))
  @Post('auth/login')
  async login(@Request() req: any) {
    return this.authService.login(req.user)
  }
}

ここまでで作成した JWT 生成の仕組みを用いて、クライアントには生成した JWT を戻せるようになった

動作確認

$ curl -X POST http://localhost:3000/auth/login -d '{"username": "sasaki", "password": "1q2w3e4r5t6y"}' -H "Content-Type: application/json"
{"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2Vy..."}$
shingo.sasakishingo.sasaki

JWT を用いたエンドポイントの制限

ここからは、生成された適切な JWT をもったクライアントにのみ、エンドポイントの利用を許可する仕組みを作る

JWT デコード用のサービスを作成する

jwt.strategy.ts
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor() {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      secretOrKey: '1q2w3e4r5t6y'
    })
  }

  async validate(payload: any) {
    return { userId: payload.sub, username: payload.username }
  }
}

JwtStrategy は、passport-jwt を用いて、 JWT のデコード方法の定義及びデコードの実行を行うサービス。

ここでも secretOrKey にハードコードしているが、これは本来は秘匿情報なので注意。

Auth モジュールに JwtStrategy を追加する

auth.module.ts
@Module({
  imports: [
    UsersModule,
    PassportModule,
    JwtModule.register({
      secret: '1q2w3e4r5t6y',
      signOptions: { expiresIn: '60s' }
    })
  ],
  providers: [AuthService, LocalStrategy, JwtStrategy], // ここを追加
  exports: [AuthService, JwtModule]
})
export class AuthModule {}
shingo.sasakishingo.sasaki

既存のエンドポイントに JWT による認証ガードを追加する

app.controller.ts
@Controller()
export class AppController {
  constructor(private readonly authService: AuthService) {}

  @UseGuards(AuthGuard('local'))
  @Post('auth/login')
  async login(@Request() req: any) {
    return this.authService.login(req.user)
  }

  @UseGuards(AuthGuard('jwt'))
  @Get('profile')
  getProfile(@Request() req: any) {
    return req.user
  }
}

自身のユーザー情報を取得する profile エンドポイントを用意する。

ここでは AuthGuard('jwt') を使って、 JWT による認証ガードを注入することで、リクエストに適切な JWT が含まれていればそのユーザー情報を、 request オブジェクトに追加してくれるので、それをそのまま return している。

動作確認

普通にエンドポイントを呼び出すと 401

$ curl http://localhost:3000/profile{"statusCode":401,"message":"Unauthorized"}
{"statusCode":401,"message":"Unauthorized"}

auth/login エンドポイントに username/password を渡して手に入れた JWT を、リクエストヘッダーに含めると、そのユーザー情報が取得できる

$ curl http://localhost:3000/profile -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InNhc2FraSIsInN1YiI6MSwiaWF0IjoxNjE1NjIyMzQyLCJleHAiOjE2MTU2MjI0MDJ9.MU_tsSJEcKPGD5DH1Z6VmSb-Ws4I3jolgtYY3IgeGjA"

{"userId":1,"username":"sasaki"}
このスクラップは2021/03/13にクローズされました