コンテンツにスキップ

NestJS で認証する

NestJS で passport を使う。

passport | NestJS - A progressive Node.js framework

パッケージをインストールする

1
2
$ npm i @nestjs/passport passport passport-local
$ npm i -D @types/passport-local

パッケージをインストールする

1
2
$ npm i @nestjs/jwt passport-jwt
$ npm i -D @types/passport-jwt
1
2
$ npm i bcrypt
$ npm i -D @types/bcrypt

認証リソースを作成する

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
$ npx nest g resource
? What name would you like to use for this resource (plural, e.g., "users")? auth
? What transport layer do you use? REST API
? Would you like to generate CRUD entry points? No
CREATE src/auth/auth.controller.spec.ts (556 bytes)
CREATE src/auth/auth.controller.ts (204 bytes)
CREATE src/auth/auth.module.ts (241 bytes)
CREATE src/auth/auth.service.spec.ts (446 bytes)
CREATE src/auth/auth.service.ts (88 bytes)
UPDATE src/app.module.ts (1693 bytes)

Service

Service でアカウント名パスワードの確認を行い、ユーザーを返す。

src/auth/auth.service.ts
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
import { Injectable } from "@nestjs/common";
import { JwtService } from "@nestjs/jwt";
import { InjectRepository } from "@nestjs/typeorm";
import * as bcrypt from "bcrypt";
import { User } from "src/users/entities/user.entity";
import { UsersService } from "src/users/users.service";
import { Repository } from "typeorm";
import { CreateAuthDto } from "./dto/create-auth.dto";
import { UpdatePasswordDto } from "./dto/update-password.dto";
import { Auth } from "./entities/auth.entity";
import { AuthNotFoundError } from "./errors/auth-not-found-error";
import { PasswordIncorrectError } from "./errors/password-incorrect-error";
import { LogInResult } from "./interfaces/log-in-result";
import { Payload } from "./interfaces/payload";

// ログイン処理を行う

@Injectable()
export class AuthService {
  private readonly saltOrRounds = 10;

  constructor(
    private readonly jwtService: JwtService,
    @InjectRepository(Auth)
    private readonly authRepository: Repository<Auth>,
    private readonly usersService: UsersService
  ) {}

  async validate(accountName: string, password: string): Promise<User> {
    const auth = await this.findOne(accountName);
    if (!(await bcrypt.compare(password, auth.hashed))) {
      throw new PasswordIncorrectError();
    }
    return this.usersService.findOne(auth.userId);
  }

  async logIn(user: User): Promise<LogInResult> {
    const payload: Payload = { userId: user.id };
    return {
      access_token: this.jwtService.sign(payload),
    };
  }

  async create(createAuthDto: CreateAuthDto): Promise<Auth> {
    const hashed = await this.getHashed(createAuthDto.password);
    await this.authRepository.save({
      accountName: createAuthDto.accountName,
      hashed,
      userId: createAuthDto.userId,
    });
    const auth = await this.authRepository.findOneBy({
      accountName: createAuthDto.accountName,
    });
    return auth;
  }

  async findOne(accountName: string): Promise<Auth> {
    const auth = await this.authRepository.findOneBy({ accountName });
    if (!auth) {
      throw new AuthNotFoundError();
    }
    return auth;
  }

  async updatePassword(
    accountName: string,
    updatePasswordDto: UpdatePasswordDto
  ): Promise<void> {
    const auth = await this.authRepository.findOneBy({ accountName });
    if (!auth) {
      throw new AuthNotFoundError();
    }
    if (
      !(await bcrypt.compare(updatePasswordDto.currentPassword, auth.hashed))
    ) {
      throw new PasswordIncorrectError();
    }
    const hashed = await this.getHashed(updatePasswordDto.password);
    await this.authRepository.update(accountName, { hashed });
  }

  async remove(accountName: string): Promise<void> {
    const auth = await this.authRepository.findOneBy({ accountName });
    if (!auth) {
      throw new AuthNotFoundError();
    }
    await this.authRepository.delete(accountName);
  }

  private getHashed(password: string): Promise<string> {
    return bcrypt.hash(password, this.saltOrRounds);
  }
}
src/auth/interfaces/log-in-result.ts
1
2
3
export interface LogInResult {
  access_token: string;
}
src/auth/interfaces/payload.ts
1
2
3
export interface Payload {
  userId: string;
}

LocalStrategy

src/auth/local.strategy.ts
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { Injectable, UnauthorizedException } from "@nestjs/common";
import { PassportStrategy } from "@nestjs/passport";
import { Strategy } from "passport-local";
import { User } from "src/users/entities/user.entity";
import { AuthService } from "./auth.service";

@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
  constructor(private authService: AuthService) {
    // Passport.jsは認証に使うフィールド名がusername、passwordなので変える
    super({ usernameField: "accountName", passwordField: "password" });
  }

  async validate(accountName: string, password: string): Promise<User> {
    try {
      const user = await this.authService.validate(accountName, password);
      return user;
    } catch (error) {
      throw new UnauthorizedException();
    }
  }
}

Module

src/auth/auth.module.ts
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import { Module } from "@nestjs/common";
import { JwtModule } from "@nestjs/jwt";
import { PassportModule } from "@nestjs/passport";
import { TypeOrmModule } from "@nestjs/typeorm";
import { UsersModule } from "src/users/users.module";
import { jwtConstants } from "./auth.constants";
import { AuthController } from "./auth.controller";
import { AuthService } from "./auth.service";
import { Auth } from "./entities/auth.entity";
import { JwtStrategy } from "./jwt.strategy";
import { LocalStrategy } from "./local.strategy";

@Module({
  imports: [
    TypeOrmModule.forFeature([Auth]),
    UsersModule,
    PassportModule,
    JwtModule.register({
      secret: jwtConstants.secret,
      signOptions: { expiresIn: parseInt(process.env.JWT_EXPIRES_IN) },
    }),
  ],
  controllers: [AuthController],
  providers: [AuthService, LocalStrategy, JwtStrategy],
  exports: [AuthService],
})
export class AuthModule {}

Controller

認証の入口を作成する。

src/auth/local-auth.guard.ts
1
2
3
4
5
import { Injectable } from "@nestjs/common";
import { AuthGuard } from "@nestjs/passport";

@Injectable()
export class LocalAuthGuard extends AuthGuard("local") {}
src/auth/auth.controller.ts
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
import { Controller, Post, Request, UseGuards } from "@nestjs/common";
import { User } from "src/users/entities/user.entity";
import { AuthService } from "./auth.service";
import { LocalAuthGuard } from "./local-auth.guard";

@Controller("auth")
export class AuthController {
  constructor(private readonly authService: AuthService) {}

  // LocalAuthGuard経由でLocalStrategy.validate()が実行される。
  // AuthServiceでアカウント名とパスワードの一致を確認し、reqオブジェクトにuserが返ってくる。
  @UseGuards(LocalAuthGuard)
  @Post("log-in")
  async login(@Request() req) {
    const user: User = req.user;
    return this.authService.logIn(user);
  }
}

ここまでで認証できるはず。以降でリクエストに乗ってきた JWT を検証する。

JWT 生成のシークレットは公開しないように環境変数を参照するようにする。

src/auth/auth.constants.ts
1
2
3
export const jwtConstants = {
  secret: process.env.JWT_SECRET,
};
src/auth/jwt.strategy.ts
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import { Injectable } from "@nestjs/common";
import { PassportStrategy } from "@nestjs/passport";
import { ExtractJwt, Strategy } from "passport-jwt";
import { jwtConstants } from "./auth.constants";
import { Payload } from "./interfaces/payload";

// クライアントから受け取ったJWTが有効かを確認する

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor() {
    super({
      // JWTの抽出方法
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      // Passport.jsにJWTの期限切れを確認させる
      ignoreExpiration: false,
      // シークレット
      secretOrKey: jwtConstants.secret,
    });
  }

  // Payloadのバリデーション処理をここに書く
  async validate(payload: Payload): Promise<Payload> {
    return { userId: payload.userId };
  }
}
src/auth/jwt-auth.guard.ts
1
2
3
4
5
import { Injectable } from "@nestjs/common";
import { AuthGuard } from "@nestjs/passport";

@Injectable()
export class JwtAuthGuard extends AuthGuard("jwt") {}

Controller のメソッドに@UseGuards(JwtAuthGuard)を付与することで認証するようになる。

JWT について

JWT はヘッダ、ペイロード、署名で構成されている。それぞれは.で結合されており、ヘッダ.ペイロード.署名という具合である。

JWT の実例を以下に挙げる。

1
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiJhNzM3YzNhZS02MDc1LTQzNzQtYWNiZS03MDc5MTAzZmNkZTYiLCJpYXQiOjE3MjIyMTQzNjcsImV4cCI6MTcyMjIxNDk2N30.N2sCZDnn2_1yUmbawqBjOW6VS9hXFrodMvr1gDMmvu4

ペイロードには JavaScript のオブジェクトを含めることができる。

アクセストークンはユーザーがリソースにアクセスするために使われるトークンのこと。このアクセストークンに JWT が採用されることが多い。

環境変数で認証をスキップさせる

Guard のcanActivate()をオーバーライドし、環境変数が有効の場合はtrueを返却させれば強制的に認証 OK にできる。環境変数が無効の場合は親クラスのcanActivate()を呼び出すようにする。

src/auth/jwt-auth.guard.ts
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
@Injectable()
export class JwtAuthGuard extends AuthGuard("jwt") {
  canActivate(
    context: ExecutionContext
  ): boolean | Promise<boolean> | Observable<boolean> {
    if (process.env.SKIP_AUTH === "true") {
      console.log("Skip auth");
      return true;
    }
    return super.canActivate(context);
  }
}