Merged PR 94: [Sp8-2で絶対着手] 認証・認可を宣言的に扱える仕組みの実装

## 概要
[Task1725: [Sp8-2で絶対着手] 認証・認可を宣言的に扱える仕組みの実装](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/fa4924a4-d079-4fab-9fb5-a9a11eb205f0/_workitems/edit/1725)

- 認証(アクセストークンが正しいか)の認証を `@UseGuards(AuthGuard)` をControllerに追加することで確認できる仕組みを追加
  - 実際の修正は別Task想定
- 権限チェック(アクセストークンに含まれる権限でAPI呼び出し可能か)のチェックを `@UseGuards(RoleGuards.configure({ ... }))` をControllerに追加することで確認できる仕組みを追加
  - 実際の修正は別Task想定
- 具体的な使い方はテスト、あるいはUsersControllerのGET /usersのコメントアウトされたコード参照
- 無駄に重複していたコードを共通化

## レビューポイント
- 使いやすそうか?
- この認証Guardsを使用して認証する時の懸念点はないか
- コードに問題はなさそうか
- テスト容易性のため、公開するべきでないメソッドを公開している事に対して納得できるか

## 動作確認状況
- ローカルで確認
This commit is contained in:
湯本 開 2023-05-22 08:08:02 +00:00
parent 713d587abf
commit 6a8cfd5530
17 changed files with 326 additions and 26 deletions

View File

@ -28,6 +28,7 @@ import { FilesService } from './features/files/files.service';
import { TasksService } from './features/tasks/tasks.service';
import { TasksController } from './features/tasks/tasks.controller';
import { TasksModule } from './features/tasks/tasks.module';
import { AuthGuardsModule } from './common/guards/auth/authguards.module';
import { BlobstorageModule } from './gateways/blobstorage/blobstorage.module';
import { LicensesModule } from './features/licenses/licenses.module';
import { LicensesService } from './features/licenses/licenses.service';
@ -71,6 +72,7 @@ import { LicensesController } from './features/licenses/licenses.controller';
NotificationhubModule,
BlobstorageModule,
LicensesModule,
AuthGuardsModule,
],
controllers: [
HealthController,

View File

@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { AuthGuard } from './authguards';
@Module({
imports: [],
controllers: [],
providers: [AuthGuard],
})
export class AuthGuardsModule {}

View File

@ -0,0 +1,41 @@
import {
Injectable,
CanActivate,
ExecutionContext,
HttpException,
HttpStatus,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Request } from 'express';
import { isVerifyError, verify } from '../../jwt';
import { AccessToken } from '../../token';
import { retrieveAuthorizationToken } from '../../http/helper';
import { makeErrorResponse } from '../../error/makeErrorResponse';
@Injectable()
export class AuthGuard implements CanActivate {
constructor(private readonly configService: ConfigService) {}
canActivate(context: ExecutionContext): boolean | Promise<boolean> {
const pubkey = this.configService
.getOrThrow<string>('JWT_PUBLIC_KEY')
.replace('\\n', '\n');
const req = context.switchToHttp().getRequest<Request>();
const token = retrieveAuthorizationToken(req);
if (!token) {
throw new HttpException(
makeErrorResponse('E000107'),
HttpStatus.UNAUTHORIZED,
);
}
const payload = verify<AccessToken>(token, pubkey);
if (isVerifyError(payload)) {
throw new HttpException(
makeErrorResponse('E000101'),
HttpStatus.UNAUTHORIZED,
);
}
return true;
}
}

View File

@ -0,0 +1,65 @@
import { RoleGuard } from './roleguards';
describe('RoleGuard', () => {
it('1つの許可Roleが設定時、完全に一致するroleを持つ場合、許可される', () => {
const guards = RoleGuard.requireds({ roles: ['author'] });
expect(guards.checkRole('author')).toBeTruthy();
});
it('1つの許可Roleが設定時、その許可roleを含むroleを持つ場合、許可される', () => {
const guards = RoleGuard.requireds({ roles: ['author'] });
// 'author admin'が許可リスト(author)に含まれるので許可
expect(guards.checkRole('author admin')).toBeTruthy();
});
it('author OR adminの許可Roleが設定時、その許可roleを含むroleを持つ場合、許可される', () => {
const guards = RoleGuard.requireds({ roles: ['author', 'admin'] });
// authorが許可リスト([authorまたはadmin])に含まれるので許可
expect(guards.checkRole('author')).toBeTruthy();
// adminが許可リスト([authorまたはadmin])に含まれるので許可
expect(guards.checkRole('admin')).toBeTruthy();
// adminが許可リスト([authorまたはadmin])に含まれるので許可
expect(guards.checkRole('author admin')).toBeTruthy();
});
it('author OR adminの許可Roleが設定時、その許可roleを含むroleを持たない場合、拒否される', () => {
const guards = RoleGuard.requireds({ roles: ['author', 'admin'] });
// typistが許可リスト([authorまたはadmin])に含まれないので拒否
expect(guards.checkRole('typist')).toBeFalsy();
});
it('author AND adminの許可Roleが設定時、その許可roleを含むroleを持つ場合、許可される', () => {
const guards = RoleGuard.requireds({ roles: [['author', 'admin']] });
// 'author admin'が許可リスト([authorかつadmin])に含まれるので許可
expect(guards.checkRole('author admin')).toBeTruthy();
// 'typist author admin'が許可リスト([authorかつadmin])に含まれるので許可
expect(guards.checkRole('typist author admin')).toBeTruthy();
});
it('author AND adminの許可Roleが設定時、その許可roleに合致しないroleを持つ場合、拒否される', () => {
const guards = RoleGuard.requireds({ roles: [['author', 'admin']] });
// authorが許可リスト([authorかつadmin])に含まれないので拒否
expect(guards.checkRole('author')).toBeFalsy();
// adminが許可リスト([authorかつadmin])に含まれないので拒否
expect(guards.checkRole('admin')).toBeFalsy();
// typistが許可リスト([authorかつadmin])に含まれないので拒否
expect(guards.checkRole('typist')).toBeFalsy();
});
it('(author AND admin) OR typistの許可Roleが設定時、その許可roleを含むroleを持つ場合、許可される', () => {
const guards = RoleGuard.requireds({
roles: [['author', 'admin'], 'typist'],
});
// typistが許可リスト(typist)に含まれないので許可
expect(guards.checkRole('typist')).toBeTruthy();
// 'author admin'が許可リスト([authorかつadmin])に含まれるので許可
expect(guards.checkRole('author admin')).toBeTruthy();
// 'typist author admin'が許可リスト([authorかつadmin],typist)に含まれるので許可
expect(guards.checkRole('typist author admin')).toBeTruthy();
});
it('(author AND admin) OR typistの許可Roleが設定時、その許可roleを含むroleを持たない場合、拒否される', () => {
const guards = RoleGuard.requireds({
roles: [['author', 'admin'], 'typist'],
});
// authorが許可リスト([authorかつadmin])に含まれないので拒否
expect(guards.checkRole('author')).toBeFalsy();
// adminが許可リスト([authorかつadmin])に含まれないので拒否
expect(guards.checkRole('admin')).toBeFalsy();
// ""が許可リスト([authorかつadmin])に含まれないので拒否
expect(guards.checkRole('')).toBeFalsy();
});
});

View File

@ -0,0 +1,103 @@
import {
CanActivate,
ExecutionContext,
HttpException,
HttpStatus,
} from '@nestjs/common';
import { isVerifyError, decode } from '../../jwt';
import { AccessToken } from '../../token';
import { Request } from 'express';
import { retrieveAuthorizationToken } from '../../../common/http/helper';
import { makeErrorResponse } from '../../../common/error/makeErrorResponse';
export type RoleType = 'typist' | 'author' | 'none' | 'admin';
export interface RoleSetting {
roles: (RoleType | RoleType[])[];
}
export class RoleGuard implements CanActivate {
settings?: RoleSetting;
// eslint-disable-next-line @typescript-eslint/no-empty-function
private constructor() {}
canActivate(context: ExecutionContext): boolean | Promise<boolean> {
const req = context.switchToHttp().getRequest<Request>();
const token = retrieveAuthorizationToken(req);
if (!token) {
throw new HttpException(
makeErrorResponse('E000101'),
HttpStatus.UNAUTHORIZED,
);
}
const payload = decode<AccessToken>(token);
if (isVerifyError(payload)) {
throw new HttpException(
makeErrorResponse('E000101'),
HttpStatus.UNAUTHORIZED,
);
}
// 設定が空なら通過
if (!this.settings) {
return true;
}
const isValid = this.checkRole(payload.role);
if (isValid) {
return true;
}
// すべての権限セットに合致していなければ例外を送出
throw new HttpException(
makeErrorResponse('E000108'),
HttpStatus.UNAUTHORIZED,
);
}
/**
* publicメソッドとして切り出したもの
*
* @param role roleの値
* @returns true/false
*/
checkRole(role: string): boolean {
const { roles } = this.settings;
const userRoles = role.split(' ');
// Role毎にAccessTokenの権限チェックを行う
for (let i = 0; i < roles.length; i++) {
const role = roles[i];
let isValid = false;
if (Array.isArray(role)) {
isValid = role.every((x) => userRoles.includes(x));
} else {
isValid = userRoles.includes(role);
}
// 一つでも合格したら通過
if (isValid) {
return true;
}
}
return false;
}
/**
* Guardを作成する
* { roles: ['admin', 'author'] } "admin""author"
* { roles: [['admin', 'author']] } "adminかつauthor"
* { roles: ['typist', ['admin', 'author']] } "typist""adminかつauthor"
* @param [settings]
* @returns requireds
*/
static requireds(settings?: RoleSetting): RoleGuard {
const guard = new RoleGuard();
guard.settings = settings;
return guard;
}
}

View File

@ -1,11 +1,13 @@
import { Request } from 'express';
/**
*
* Authorizationヘッダに格納された文字列(jwt)
* @param {Request}
* @return {string | undefined}
*/
export const retrieveAccessToken = (req: Request): string | undefined => {
export const retrieveAuthorizationToken = (
req: Request,
): string | undefined => {
const header = req.header('Authorization');
if (typeof header === 'string') {

View File

@ -1,3 +1,3 @@
import { isVerifyError, sign, verify } from './jwt';
import { isVerifyError, sign, verify, decode } from './jwt';
export { isVerifyError, sign, verify };
export { isVerifyError, sign, verify, decode };

View File

@ -88,3 +88,39 @@ export const verify = <T extends object>(
}
}
};
/**
* tokenから未検証のJWTのpayloadを取得します
* @param {string} token JWT
* @return {T | VerifyError} Payload
*/
export const decode = <T extends object>(token: string): T | VerifyError => {
try {
const payload = jwt.decode(token, {
json: true,
}) as T;
return payload;
} catch (e) {
if (e instanceof jwt.TokenExpiredError) {
return {
reason: 'ExpiredError',
message: e.message,
};
} else if (e instanceof jwt.NotBeforeError) {
return {
reason: 'InvalidTimeStamp',
message: e.message,
};
} else if (e instanceof jwt.JsonWebTokenError) {
return {
reason: 'InvalidToken',
message: e.message,
};
} else {
return {
reason: 'Unknown',
message: e.message,
};
}
}
};

View File

@ -1,10 +1,10 @@
import {
Body,
Controller,
Headers,
HttpException,
HttpStatus,
Post,
Req,
} from '@nestjs/common';
import {
ApiResponse,
@ -20,6 +20,7 @@ import {
TokenRequest,
TokenResponse,
} from './types/types';
import { retrieveAuthorizationToken } from '../../common/http/helper';
@ApiTags('auth')
@Controller('auth')
@ -94,22 +95,18 @@ export class AuthController {
operationId: 'accessToken',
description: 'リフレッシュトークンを元にアクセストークンを再生成します',
})
async accessToken(@Headers() headers): Promise<AccessTokenResponse> {
console.log(headers['authorization']);
const header = headers['authorization'];
if (typeof header === 'string') {
if (header.startsWith('Bearer ')) {
const refreshToken = header.substring('Bearer '.length, header.length);
const accessToken = await this.authService.generateAccessToken(
refreshToken,
);
return { accessToken };
}
async accessToken(@Req() req): Promise<AccessTokenResponse> {
const refreshToken = retrieveAuthorizationToken(req);
if (refreshToken !== undefined) {
throw new HttpException(
makeErrorResponse('E009999'),
HttpStatus.UNAUTHORIZED,
);
}
throw new HttpException(
makeErrorResponse('E009999'),
HttpStatus.UNAUTHORIZED,
const accessToken = await this.authService.generateAccessToken(
refreshToken,
);
return { accessToken };
}
}

View File

@ -1,12 +1,19 @@
import { Test, TestingModule } from '@nestjs/testing';
import { FilesController } from './files.controller';
import { FilesService } from './files.service';
import { ConfigModule } from '@nestjs/config';
describe('FilesController', () => {
let controller: FilesController;
const mockFilesService = {};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [
ConfigModule.forRoot({
envFilePath: ['.env.local', '.env'],
isGlobal: true,
}),
],
controllers: [FilesController],
providers: [FilesService],
})

View File

@ -6,6 +6,7 @@ import {
HttpStatus,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import {
ApiBearerAuth,
@ -27,6 +28,7 @@ import {
TemplateDownloadLocationRequest,
TemplateDownloadLocationResponse,
} from './types/types';
import { AuthGuard } from '../../common/guards/auth/authguards';
@ApiTags('files')
@Controller('files')

View File

@ -1,6 +1,7 @@
import { Test, TestingModule } from '@nestjs/testing';
import { LicensesController } from './licenses.controller';
import { LicensesService } from './licenses.service';
import { ConfigModule } from '@nestjs/config';
describe('LicensesController', () => {
let controller: LicensesController;
@ -8,6 +9,12 @@ describe('LicensesController', () => {
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [
ConfigModule.forRoot({
envFilePath: ['.env.local', '.env'],
isGlobal: true,
}),
],
controllers: [LicensesController],
providers: [LicensesService],
})

View File

@ -1,4 +1,11 @@
import { Body, Controller, HttpStatus, Post, Req } from '@nestjs/common';
import {
Body,
Controller,
HttpStatus,
Post,
Req,
UseGuards,
} from '@nestjs/common';
import {
ApiResponse,
ApiTags,
@ -9,6 +16,7 @@ import { ErrorResponse } from '../../common/error/types/types';
import { LicensesService } from './licenses.service';
import { CreateOrdersResponse, CreateOrdersRequest } from './types/types';
import { Request } from 'express';
import { AuthGuard } from '../../common/guards/auth/authguards';
@ApiTags('licenses')
@Controller('licenses')

View File

@ -1,12 +1,19 @@
import { Test, TestingModule } from '@nestjs/testing';
import { TasksController } from './tasks.controller';
import { TasksService } from './tasks.service';
import { ConfigModule } from '@nestjs/config';
describe('TasksController', () => {
let controller: TasksController;
const mockTaskService = {};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [
ConfigModule.forRoot({
envFilePath: ['.env.local', '.env'],
isGlobal: true,
}),
],
controllers: [TasksController],
providers: [TasksService],
})

View File

@ -6,12 +6,13 @@ import {
Param,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import {
ApiResponse,
ApiOperation,
ApiBearerAuth,
ApiTags,
ApiBearerAuth,
} from '@nestjs/swagger';
import { ErrorResponse } from '../../common/error/types/types';
import { TasksService } from './tasks.service';
@ -23,6 +24,7 @@ import {
TasksRequest,
TasksResponse,
} from './types/types';
import { AuthGuard } from '../../common/guards/auth/authguards';
@ApiTags('tasks')
@Controller('tasks')

View File

@ -2,6 +2,7 @@ import { Test, TestingModule } from '@nestjs/testing';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
import { CryptoService } from '../../gateways/crypto/crypto.service';
import { ConfigModule } from '@nestjs/config';
describe('UsersController', () => {
let controller: UsersController;
@ -10,6 +11,12 @@ describe('UsersController', () => {
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [
ConfigModule.forRoot({
envFilePath: ['.env.local', '.env'],
isGlobal: true,
}),
],
controllers: [UsersController],
providers: [UsersService, CryptoService],
})

View File

@ -2,10 +2,11 @@ import {
Body,
Controller,
Get,
HttpException,
HttpStatus,
Post,
Req,
UseGuards,
HttpException,
} from '@nestjs/common';
import {
ApiBearerAuth,
@ -17,7 +18,7 @@ import { Request } from 'express';
import { confirmPermission } from '../../common/auth/auth';
import { makeErrorResponse } from '../../common/error/makeErrorResponse';
import { ErrorResponse } from '../../common/error/types/types';
import { retrieveAccessToken } from '../../common/http/helper';
import { retrieveAuthorizationToken } from '../../common/http/helper';
import { isVerifyError, verify } from '../../common/jwt/jwt';
import { AccessToken } from '../../common/token';
import { CryptoService } from '../../gateways/crypto/crypto.service';
@ -30,6 +31,8 @@ import {
SignupResponse,
} from './types/types';
import { UsersService } from './users.service';
import { AuthGuard } from '../../common/guards/auth/authguards';
import { RoleGuard } from '../../common/guards/role/roleguards';
@ApiTags('users')
@Controller('users')
@ -103,13 +106,15 @@ export class UsersController {
})
@ApiOperation({ operationId: 'getUsers' })
@ApiBearerAuth()
// @UseGuards(AuthGuard)
// @UseGuards(RoleGuard.requireds({ roles: ['admin', 'author'] }))
@Get()
async getUsers(@Req() req: Request): Promise<GetUsersResponse> {
console.log(req.header('Authorization'));
// アクセストークンにより権限を確認する
const pubKey = await this.cryptoService.getPublicKey();
const accessToken = retrieveAccessToken(req);
const accessToken = retrieveAuthorizationToken(req);
// アクセストークンが存在しない場合のエラー
if (accessToken == undefined) {
@ -180,7 +185,7 @@ export class UsersController {
// アクセストークンにより権限を確認する
const pubKey = await this.cryptoService.getPublicKey();
const accessToken = retrieveAccessToken(req);
const accessToken = retrieveAuthorizationToken(req);
//アクセストークンが存在しない場合のエラー
if (accessToken == undefined) {