[キャッチアップ] Nest.js で JWT を使ったユーザー認証
概要
- Nest.js を業務で扱うための素振り中
- マイクロサービスとしての運用を想定しているので、JWT を用いた認証をどう提供できるのか試してみる
- 試行錯誤段階なので、内容に不備とか誤りあると思う
- 公式ドキュメントをベースにするが、ドキュメントの和訳を書くわけではなくメモ程度
-
MySQL
typeORM
を用いて、既に認証対象となるUser
モデルが作成済みの状態からスタート
Passport
- 人気の Node 認証ライブラリ
- 認証手順は概ね以下の通り
- クレデンシャルを使ってユーザーを認証する
- 認証状態を管理する
- 認証状態を
Request
オブジェクトに紐付けて、ルートハンドラで使用できるようにする
- シンプルな手順ではあるが、豊富なエコシステムを持ち合わせている
- クレデンシャルにはパスワードのほかに、JWT や IdP トークンが使用可能
- JWT トークンや Express session のようなポータブルなトークンの発行が可能
@nestjs/passport
- Nest.js のアーキテクチャに Passport の仕組みを取り入れるための公式パッケージ
- Guard と組み合わせることで、宣言的に認証の仕組みをエンドポイント単位で組み込める
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
を追加する
これで下準備は完了
Auth サービスに認証用のメソッドを追加する
@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 エンティティを戻す。
username/password による認証用のクラスを作成する
Passport
では任意の認証方法を用いた認証状態の管理を行えるが、ここでは username/password を用いた passport-local
を使用するため、それを Nest に組み込むための LocalStrategy
クラスを作成する。
@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
側に、どのように認証を行うかの具体的な方法を伝える。
Auth モジュールの依存関係を整理する
Auth モジュールは、 Auth サービスを利用しつつ、そこに User サービス を注入するため、このような構成になる
@Module({
imports: [UsersModule, PassportModule],
providers: [AuthService, LocalStrategy]
})
export class AuthModule {}
ログインエンドポイントを作る
@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 の生成と再利用を行っていく。
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 の型パッケージ
Auth サービスに、JWT 生成用のメソッドを追加する
@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
を用いてモジュールを追加する。
@Module({
imports: [
UsersModule,
PassportModule,
JwtModule.register({
secret: '1q2w3e4r5t6y',
signOptions: { expiresIn: '60s' }
})
],
providers: [AuthService, LocalStrategy],
exports: [AuthService, JwtModule]
})
export class AuthModule {}
この際に使用する secret
は、ここでは素振り用ということでハードコードしておくが、本来は秘匿情報として扱う。
ログイン用のエンドポイントで、JWT を生成して返却する
@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..."}$
JWT を用いたエンドポイントの制限
ここからは、生成された適切な JWT をもったクライアントにのみ、エンドポイントの利用を許可する仕組みを作る
JWT デコード用のサービスを作成する
@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 を追加する
@Module({
imports: [
UsersModule,
PassportModule,
JwtModule.register({
secret: '1q2w3e4r5t6y',
signOptions: { expiresIn: '60s' }
})
],
providers: [AuthService, LocalStrategy, JwtStrategy], // ここを追加
exports: [AuthService, JwtModule]
})
export class AuthModule {}
既存のエンドポイントに JWT による認証ガードを追加する
@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"}