NestJSでロールベースの認証を実装する

2022/11/12に公開

この記事では NestJS で ROLE ベースの認証を実装していきます。

ユーザーに Admin や User などのロールをもたせて、

「Admin ロールを持ってるならこの API を叩ける」

「User ロールを持ってるならこの API を叩ける」

というような、ロールごとに叩ける API を設定していきます。

プロジェクトの作成と準備

プロジェクトを作成します。

npx @nestjs/cli new role-based-athorization

起動してみます。

cd role-based-athorization
npm run start:dev

必要なライブラリをインストールします。

npm install --save @nestjs/passport @nestjs/jwt passport passport-local passport-jwt
npm install --save-dev @types/passport-local @types/passport-jwt

NestJS CLI を使って必要なモジュールを生成します。

npx @nestjs/cli g module auth --no-spec
npx @nestjs/cli g module users --no-spec
npx @nestjs/cli g service auth --no-spec
npx @nestjs/cli g service users --no-spec

User のエンティティを作成する。

users ディレクトリ配下に User のエンティティを作成します。
この記事のサンプルでは OR マッパーは使わないので、特にデコレータを貼ることもなく、普通に interface を定義しているだけです。

src/users/user.entity.ts
export enum Role {
  User = 'user',
  Admin = 'admin',
}

export interface User {
  userId: number;
  username: string;
  password: string;
  roles: Role[];
}

users/users.service.ts を修正する

ユーザー情報は Repository 経由でデータベースに保存するのが一般的かと思いますが、今回はサンプルなのでユーザー情報はメモリ上に配列で定義します。

src/users/users.service.ts
import { Injectable } from '@nestjs/common';
import { Role, User } from './user.entity';

@Injectable()
export class UsersService {
  private readonly users: User[] = [
    {
      userId: 1,
      username: 'naruto',
      password: '12345',
      roles: [Role.User],
    },
    {
      userId: 2,
      username: 'sasuke',
      password: '12345',
      roles: [Role.Admin],
    },
        {
      userId: 3,
      username: 'sakura',
      password: '12345',
      roles: [Role.Admin, Role.User],
    },
  ];

  async findOne(username: string): Promise<User | undefined> {
    return this.users.find((user) => user.username === username);
  }
}

上のコードを作成したら、 UsersModuleexports を追加します。
UsersService はあとで AuthService で使うので、 exports に定義してモジュールの外で使えるようにするのです。

src/users/users.module.ts
import { Module } from '@nestjs/common';
import { UsersService } from './users.service';

@Module({
  providers: [UsersService],
  exports: [UsersService],
})
export class UsersModule {}

auth/auth.service.ts を修正する。

認証に関する機能は AuthService で作っていきます。

src/auth/auth.service.ts
import { Injectable } from '@nestjs/common';
import { UsersService } from '../users/users.service';
import { JwtService } from '@nestjs/jwt';
import { Payload } from './payload.interface';
import { User } from '../users/user.entity';

@Injectable()
export class AuthService {
  constructor(
    private usersService: UsersService,
    private jwtService: JwtService,
  ) {}

  async validateUser(username: string, pass: string): Promise<any> {
    const user = await this.usersService.findOne(username);
    if (user && user.password === pass) {
      const { password, ...result } = user;
      return result;
    }
    return null;
  }

  async login(user: User) {
    const payload: Payload = {
      username: user.username,
      sub: user.userId,
      roles: user.roles,
    };
    return {
      access_token: this.jwtService.sign(payload),
    };
  }
}

AuthServicevalidateUser はユーザー名とパスワードを認証する LocalAuthGuard から呼ばれます。
ユーザー名とパスワードが一致した場合は req.user にユーザーの情報がセットされます。

LocalStrategyvalidate を通したリクエストに対して、ユーザーデータを req.user として生やすのは Passport の仕様のようです。

how to change return req.user object name of passport-local?

AuthServicelogin には LocalStrategy を通して、作られた req.user が渡されます。

渡された User から usernamesubroles を持つ PAYLOAD を含む JWT トークンを作って返します。

JWT トークンとは eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6Im5hcnV0byIsInN1YiI6MSwicm9sZXMiOlsidXNlciJdLCJpYXQiOjE2NjgyMzg5MDYsImV4cCI6MTY2ODI0MjUwNn0.tsfKbae4gU-DVNlU3JF2sxsIDyyKx0BrtsLS11zLEMQのような文字列です。

この文字列はデコードできます( jwt.io などに貼り付けるだけで OK)
デコードすると、ペイロードと呼ばれる部分に以下のように username,sub, roles と一緒にトークンの有効期限が含まれていることがわかります。

{
  "username": "naruto",
  "sub": 1,
  "roles": ["user"],
  "iat": 1668238906,
  "exp": 1668242506
}

AuthService で使っている Payload は以下のように定義します。

src/auth/payload.interface.ts
import { Role } from '../users/user.entity';

export interface Payload {
  username: string;
  sub: number;
  roles: Role[];
}

local.strategy.ts の作成

ユーザー名とパスワードでログインできるように、 LocalStrategy を作ります。

中では AuthServicevalidateUser を呼び出しています。
ユーザー名とパスワードが一致しているときは true を返して、存在しない場合は null で返ってきます。

src/auth/local.strategy.ts
import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-local';
import { AuthService } from './auth.service';
import { Injectable, UnauthorizedException } from '@nestjs/common';

@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
  constructor(private authService: AuthService) {
    super();
  }

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

LocalAuthGuard も作っておきます。
コントローラにデコレーションを設定するときに AuthGuard('local') と書かなくても済むようにするためです。

src/auth/local-auth.guard.ts
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class LocalAuthGuard extends AuthGuard('local') {}

jwt.strategy.ts の作成

JwtStrategy を作成します。

src/auth/jwt.strategy.ts
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { jwtConstants } from './constants';
import { Payload } from './payload.interface';

export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor() {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      secretOrKey: jwtConstants.secret,
    });
  }

  async validate(payload: Payload) {
    return {
      userId: payload.sub,
      username: payload.username,
      roles: payload.roles,
    };
  }
}

秘密鍵は jwtConstants というクラスにベタ書きしていますが、プロダクトでは環境変数から読み込んでください。

src/auth/constants.ts
export const jwtConstants = {
  secret: 'secretKey',
};

AuthModule に JWT モジュールを登録します。
また、 providersLocalStrategyJwtStrategy を設定します。

src/auth/auth.module.ts
import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { UsersModule } from '../users/users.module';
import { PassportModule } from '@nestjs/passport';
import { LocalStrategy } from './local.strategy';
import { JwtModule } from '@nestjs/jwt';
import { jwtConstants } from './constants';
import { JwtStrategy } from './jwt.strategy';

@Module({
  imports: [
    UsersModule,
    PassportModule,
    JwtModule.register({
      secret: jwtConstants.secret,
      signOptions: { expiresIn: '1h' },
    }),
  ],
  providers: [AuthService, LocalStrategy, JwtStrategy],
  exports: [AuthService],
})
export class AuthModule {}

ユーザーのロールを確認するためのデコレータを作成する

コントローラのメソッドに @HasRoles(Role.Admin) のようなデコレータを付けて、ロールを持っているユーザー以外は値を取得できないようにします。
まずはユーザーが持つ Role[] を受け取るデコレータを作ります。

src/auth/has-roles.decorator.ts
import { SetMetadata } from "@nestjs/common";
import { Role } from "../users/user.entity";

export const HasRoles = (...roles: Role[]) => SetMetadata("roles", roles);

次に Guard を作ります。
上の has-roles.decorator.tsSetMetadata("roles", roles) のように書きました。

HasRoles というデコレータのメタデータは roles となります。
メタデータの rolesRole[] が格納されます。

src/auth/roles.guard.ts
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Role } from '../users/user.entity';

@Injectable()
export class RolesGuard implements CanActivate {
  constructor(private reflector: Reflector) {}

  canActivate(context: ExecutionContext): boolean {
    const requiredRoles = this.reflector.getAllAndOverride<Role[]>('roles', [
      context.getHandler(),
      context.getClass(),
    ]);
    if (!requiredRoles) {
      return true;
    }

    const { user } = context.switchToHttp().getRequest();
    return requiredRoles.some((role) => user?.roles?.includes(role));
  }
}

以下の部分でメソッドに付与された hasRoles デコレータに渡された Role[] を取り出しています。
Role[]を取り出して、「必要な Role 」としているわけです。

const requiredRoles = this.reflector.getAllAndOverride<Role[]>("roles", [
  context.getHandler(),
  context.getClass(),
]);

AppController にガードされたエンドポイントを設定してみる

src/app.controller.ts
import { Controller, Get, Post, UseGuards, Request } from '@nestjs/common';
import { AuthService } from './auth/auth.service';
import { HasRoles } from './auth/has-roles.decorator';
import { RolesGuard } from './auth/roles.guard';
import { LocalAuthGuard } from './auth/local-auth.guard';
import { JwtAuthGuard } from './auth/jwt-auth.guard';
import { Role } from './users/user.entity';

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

  @UseGuards(LocalAuthGuard)
  @Post('auth/login')
  async login(@Request() req) {
    return this.authService.login(req.user);
  }

  @UseGuards(JwtAuthGuard)
  @Get('profile')
  getProfile(@Request() req) {
    return req.user;
  }

  @HasRoles(Role.Admin)
  @UseGuards(JwtAuthGuard, RolesGuard)
  @Get('admin')
  onlyAdmin(@Request() req) {
    return req.user;
  }

  @HasRoles(Role.User)
  @UseGuards(JwtAuthGuard, RolesGuard)
  @Get('user')
  onlyUser(@Request() req) {
    return req.user;
  }

  @HasRoles(Role.User)
  @UseGuards(JwtAuthGuard, RolesGuard)
  @Post('post')
  post(@Request() req) {
    return req.user;
  }
}

動作確認してみる

npm run start:dev

ROLE が「USER」のユーザーでログインして、アクセストークンを取得します。

curl -X POST http://localhost:3000/auth/login \
-H "Content-Type: application/json" \
--data-raw '{
"username": "naruto",
"password": "12345"
}'

# {"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6Im5hcnV0byIsInN1YiI6MSwicm9sZXMiOlsidXNlciJdLCJpYXQiOjE2NjgyMzg5MDYsImV4cCI6MTY2ODI0MjUwNn0.tsfKbae4gU-DVNlU3JF2sxsIDyyKx0BrtsLS11zLEMQ"}

jwt.ioで受け取った access_token をデコードすると、 Payload には以下のような情報が入っていることがわかります。

{
  "username": "naruto",
  "sub": 1,
  "roles": ["user"],
  "iat": 1668238906,
  "exp": 1668242506
}

JWT トークンがあれば叩ける getProfile という API にリクエストを投げてみます。

  @UseGuards(JwtAuthGuard)
  @Get('profile')
  getProfile(@Request() req) {
    return req.user;
  }
curl http://localhost:3000/profile -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6Im5hcnV0byIsInN1YiI6MSwicm9sZXMiOlsidXNlciJdLCJpYXQiOjE2NjgyMzg5MDYsImV4cCI6MTY2ODI0MjUwNn0.tsfKbae4gU-DVNlU3JF2sxsIDyyKx0BrtsLS11zLEMQ"

# {"userId":1,"username":"naruto","roles":["user"]}%

では Admin ロールでないと叩けない以下の API にリクエストを投げたらどうなるでしょうか?

  @HasRoles(Role.Admin)
  @UseGuards(JwtAuthGuard, RolesGuard)
  @Get('admin')
  onlyAdmin(@Request() req) {
    return req.user;
  }

403 Forbidden Error が返ってきました。

curl http://localhost:3000/admin -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6Im5hcnV0byIsInN1YiI6MSwicm9sZXMiOlsidXNlciJdLCJpYXQiOjE2NjgyMzg5MDYsImV4cCI6MTY2ODI0MjUwNn0.tsfKbae4gU-DVNlU3JF2sxsIDyyKx0BrtsLS11zLEMQ"

# {"statusCode":403,"message":"Forbidden resource","error":"Forbidden"}%

Admin ロールを持つユーザーのアクセストークンを取得してみましょう。

curl -X POST http://localhost:3000/auth/login \
-H "Content-Type: application/json" \
--data-raw '{
"username": "sasuke",
"password": "12345"
}'

# {"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InNhc3VrZSIsInN1YiI6Miwicm9sZXMiOlsiYWRtaW4iXSwiaWF0IjoxNjY4MjM5MTg4LCJleHAiOjE2NjgyNDI3ODh9.9h2c5btALoGA7qsHcshOy7fRjhs-foYQm6RyPCVmSlE"}

Admin ロールのユーザーならしっかりとリクエストが返ってきました。

curl http://localhost:3000/admin -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InNhc3VrZSIsInN1YiI6Miwicm9sZXMiOlsiYWRtaW4iXSwiaWF0IjoxNjY4MjM5MTg4LCJleHAiOjE2NjgyNDI3ODh9.9h2c5btALoGA7qsHcshOy7fRjhs-foYQm6RyPCVmSlE"

# {"userId":2,"username":"sasuke","roles":["admin"]}%

参考

GitHubで編集を提案

Discussion

ログインするとコメントできます