TL;DR
- NestJS の GraphQL サーバで、Passport.js を使って JWT 認証を実装した。サインインは
passport-local、ログイン中のユーザだけが使える Resolver の保護はpassport-jwtで行う。 - Passport.js の
AuthGuardは REST API 向けなので、GraphQL で使うにはgetRequestをオーバーライドする必要がある。
背景
NestJS で GraphQL サーバを実装するで作った GraphQL サーバに、認証を追加する。
環境
次のパッケージを使う。
$ npm i @nestjs/passport passport passport-local @nestjs/jwt passport-jwt
$ npm i --save-dev @types/passport-local @types/passport-jwt@nestjs/passport: Passport.js 用の NestJS 拡張ライブラリpassport: Passport.js 本体passport-local: メールアドレスとパスワードによる認証の実装(ドキュメント)@nestjs/jwt: JWT 認証用の NestJS 拡張ライブラリpassport-jwt: JWT 認証用の Passport.js 拡張ライブラリ(ドキュメント)
検証
事前準備
ファイルを生成する。
$ nest g module auth
$ nest g service auth --no-spec
$ nest g resolver auth --no-specPassportModule と JwtModule をインポートする。JwtModule に渡せるオプションは README にある。
@Module({
imports: [
UserModule,
PassportModule,
JwtModule.register({
secret: process.env.JWT_SECRET,
signOptions: { expiresIn: "1h" },
}),
],
providers: [AuthResolver, AuthService],
})サインインを実装する
まず Service を実装する。validateUser は、渡された認証データが正しいかを確かめるメソッドで、Strategy の実装で使う。
@Injectable()
export class AuthService {
constructor(
private readonly userService: UserService,
private readonly jwtService: JwtService,
) {}
async validateUser(email: string, password: string): Promise<User | null> {
const user = await this.userService.getUser(email)
if (!user || !(await becrypt.compare(password, user.password))) {
return null
}
return user
}
async signIn(user: User): Promise<SignInResponse> {
const payload: JwtPayload = { email: user.email, sub: user.id }
return { accessToken: this.jwtService.sign(payload), user }
}
}次に、passport-local を使って Strategy を実装する。validate は PassportStrategy で定義されている抽象メソッドで、認証データの検証時に呼ばれるので実装する必要がある。
import { Injectable, UnauthorizedException } from "@nestjs/common"
import { PassportStrategy } from "@nestjs/passport"
import { Strategy } from "passport-local"
import { AuthService } from "../auth.service"
import { User } from "@prisma/client"
@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
constructor(private readonly authService: AuthService) {
super({ usernameField: "email" })
}
async validate(email: string, password: string): Promise<User> {
const user = await this.authService.validateUser(email, password)
if (!user) {
throw new UnauthorizedException()
}
return user
}
}作ったクラスは auth のモジュールの providers に加える。
@Module({
...
providers: [AuthResolver, AuthService, LocalStrategy],
})
export class AuthModule {}続いて Guard を実装する。AuthGuard は REST API 専用なので、GraphQL でも使えるように getRequest をオーバーライドする。
import { ExecutionContext } from "@nestjs/common"
import { GqlExecutionContext } from "@nestjs/graphql"
import { AuthGuard } from "@nestjs/passport"
export class GqlAuthGuard extends AuthGuard("local") {
constructor() {
super()
}
getRequest(context: ExecutionContext) {
const ctx = GqlExecutionContext.create(context)
const request = ctx.getContext()
request.body = ctx.getArgs().signInInput
return request
}
}最後に Resolver を実装する。@UseGuards に GqlAuthGuard を渡すと、signIn の前に validate が実行され、成功したときだけ signIn が実行される。@Context を使うと、リクエストに入っているコンテキストを引数で受け取れる。この中に User のインスタンスが入っている。
@Resolver()
export class AuthResolver {
constructor(private readonly authService: AuthService) {}
@Mutation(() => SignInResponse)
@UseGuards(GqlAuthGuard)
async signIn(@Args("signInInput") signInInput: SignInInput, @Context() context: any) {
return await this.authService.signIn(context.user)
}
}Resolver をログイン中のユーザだけが使えるようにする
passport-jwt を使って Strategy を実装する。passport-local のときとほとんど同じである。
import { PassportStrategy } from "@nestjs/passport"
import { ExtractJwt, Strategy } from "passport-jwt"
import { UserService } from "src/user/user.service"
import { JwtPayload } from "../types/jwtPayload.type"
import { User } from "@prisma/client"
import { Injectable } from "@nestjs/common"
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(private readonly userService: UserService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: process.env.JWT_SECRET,
})
}
async validate(payload: JwtPayload): Promise<User | null> {
return await this.userService.getUser(payload.email)
}
}このクラスもモジュールの providers に加える。
@Module({
...
providers: [AuthResolver, AuthService, LocalStrategy, JwtStrategy],
})
export class AuthModule {}Guard も、サインインのときとほとんど同じように getRequest をオーバーライドする。
import { ExecutionContext } from "@nestjs/common"
import { GqlExecutionContext } from "@nestjs/graphql"
import { AuthGuard } from "@nestjs/passport"
export class JwtAuthGuard extends AuthGuard("jwt") {
getRequest(context: ExecutionContext) {
const ctx = GqlExecutionContext.create(context)
return ctx.getContext().req
}
}保護したい Resolver の @UseGuards に、作った Guard を渡す。
@Resolver()
export class UserResolver {
constructor(private readonly userService: UserService) {}
@Query(() => UserModel, { nullable: true })
@UseGuards(JwtAuthGuard)
async getUser(@Args() getUserArgs: GetUserArgs): Promise<User> {
...
}
}結果
実装の途中で、2 か所でつまずいた。
- Strategy のクラスに
@Injectableを付けないと、正しく動かなかった。 signInの引数名(signInInput)は、GqlAuthGuardのgetRequestでctx.getArgs().signInInputと書いた部分と一致させないと動かなかった。