Merged PR 766: API I/F & system権限Token実装

## 概要
[Task3764: API I/F & system権限Token実装](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/fa4924a4-d079-4fab-9fb5-a9a11eb205f0/_workitems/edit/3764)

- システムが発行したトークンの型定義を追加
- AuthGuardと同等の、システムが発行したTokenである `SystemAccessToken` を検証する `SystemAccessGuard` を追加
- API I/Fを実装

## レビューポイント
- バリデーターは適切か
- システムが発行したトークンの型定義は適切か
- API I/Fの型は問題ないか

## 動作確認状況
- ローカルでswagger UI上で確認
This commit is contained in:
湯本 開 2024-02-26 05:13:43 +00:00
parent 13d421c2bc
commit c1f370faaf
7 changed files with 764 additions and 0 deletions

View File

@ -52,6 +52,7 @@ import { WorkflowsRepositoryModule } from './repositories/workflows/workflows.re
import { TermsModule } from './features/terms/terms.module';
import { RedisModule } from './gateways/redis/redis.module';
import * as redisStore from 'cache-manager-redis-store';
import { SystemAccessGuardsModule } from './common/guards/system/accessguards.module';
@Module({
imports: [
ServeStaticModule.forRootAsync({
@ -133,6 +134,7 @@ import * as redisStore from 'cache-manager-redis-store';
NotificationhubModule,
BlobstorageModule,
AuthGuardsModule,
SystemAccessGuardsModule,
SortCriteriaRepositoryModule,
WorktypesRepositoryModule,
TermsModule,

View File

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { SystemAccessGuard } from './accessguards';
@Module({
imports: [ConfigModule],
controllers: [],
providers: [SystemAccessGuard],
})
export class SystemAccessGuardsModule {}

View File

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

View File

@ -36,6 +36,20 @@ export type AccessToken = {
tier: number;
};
// システムの内部で発行し、外部に公開しないトークン
// システム間通信用(例: Azure Functions→AppServiceに使用する
export type SystemAccessToken = {
/**
*
*/
systemName: string;
/**
*
*/
context?: string;
};
export type IDToken = {
emails: string[];
nonce?: string | undefined;

View File

@ -1,11 +1,16 @@
import { ApiProperty } from '@nestjs/swagger';
import {
IsArray,
IsBoolean,
IsEmail,
IsIn,
IsInt,
IsNotEmpty,
IsOptional,
IsString,
MaxLength,
ValidateIf,
ValidateNested,
} from 'class-validator';
import {
TASK_LIST_SORTABLE_ATTRIBUTES,
@ -264,6 +269,103 @@ export class PostDeleteUserRequest {
export class PostDeleteUserResponse {}
export class MultipleImportUser {
@ApiProperty({ description: 'ユーザー名' })
@IsString()
@MaxLength(256) // AzureAdB2Cの仕様上、256文字まで[https://learn.microsoft.com/ja-jp/azure/active-directory-b2c/user-profile-attributes]
@IsNotEmpty()
name: string;
@ApiProperty({ description: 'メールアドレス' })
@IsEmail({ blacklisted_chars: '*' })
@IsNotEmpty()
email: string;
@ApiProperty({ description: '0(none)/1(author)/2(typist)' })
@Type(() => Number)
@IsInt()
@IsIn([0, 1, 2])
role: number;
@ApiProperty({ required: false })
@IsAuthorIdValid()
@ValidateIf((o) => o.role === 1) // roleがauthorの場合のみバリデーションを実施
authorId?: string;
@ApiProperty({ description: '0(false)/1(true)' })
@Type(() => Number)
@IsInt()
@IsIn([0, 1])
autoRenew: number;
@ApiProperty({ description: '0(false)/1(true)' })
@Type(() => Number)
@IsInt()
@IsIn([0, 1])
notification: number;
@ApiProperty({ required: false, description: '0(false)/1(true)' })
@Type(() => Number)
@IsInt()
@IsIn([0, 1])
@ValidateIf((o) => o.role === 1) // roleがauthorの場合のみバリデーションを実施
encryption?: number;
@ApiProperty({ required: false })
@IsPasswordvalid()
@IsNotEmpty()
@IsString()
@ValidateIf((o) => o.role === 1 && o.encryption === 1) // roleがauthorかつencryptionがtrueの場合のみバリデーションを実施
encryptionPassword?: string;
@ApiProperty({ required: false, description: '0(false)/1(true)' })
@Type(() => Number)
@IsInt()
@IsIn([0, 1])
@ValidateIf((o) => o.role === 1) // roleがauthorの場合のみバリデーションを実施
prompt?: number;
}
export class PostMultipleImportsRequest {
@ApiProperty({ type: [MultipleImportUser] })
@IsArray()
@ValidateNested({ each: true })
@Type(() => MultipleImportUser)
users: MultipleImportUser[];
}
export class PostMultipleImportsResponse {}
export class MultipleImportErrors {
@ApiProperty({ description: 'ユーザー名' })
@IsString()
@IsNotEmpty()
name: string;
@ApiProperty({ description: 'メールアドレス' })
@IsEmail({ blacklisted_chars: '*' })
@IsNotEmpty()
email: string;
@ApiProperty({ description: 'エラーコード' })
@IsString()
@IsNotEmpty()
errorCode: string;
}
export class PostMultipleImportsCompleteRequest {
@ApiProperty({ description: 'アカウントID' })
@Type(() => Number)
@IsInt()
accountId: number;
@ApiProperty({ type: [MultipleImportErrors] })
@IsArray()
@ValidateNested({ each: true })
@Type(() => MultipleImportErrors)
errors: MultipleImportErrors[];
}
export class PostMultipleImportsCompleteResponse {}
export class AllocateLicenseRequest {
@ApiProperty({ description: 'ユーザーID' })
@Type(() => Number)

View File

@ -3,6 +3,12 @@ import { UsersController } from './users.controller';
import { UsersService } from './users.service';
import { ConfigModule } from '@nestjs/config';
import { AuthService } from '../auth/auth.service';
import {
PostMultipleImportsCompleteRequest,
PostMultipleImportsRequest,
} from './types/types';
import { validate } from 'class-validator';
import { plainToClass } from 'class-transformer';
describe('UsersController', () => {
let controller: UsersController;
@ -32,4 +38,440 @@ describe('UsersController', () => {
it('should be defined', () => {
expect(controller).toBeDefined();
});
describe('valdation PostMultipleImportsRequest', () => {
it('role:noneの最低限の有効なリクエストが成功する', async () => {
const request = new PostMultipleImportsRequest();
request.users = [
{
name: 'namae',
email: 'hogehoge@example.com',
role: 0,
autoRenew: 0,
notification: 0,
},
];
const valdationObject = plainToClass(PostMultipleImportsRequest, request);
const errors = await validate(valdationObject);
expect(errors.length).toBe(0);
});
it('role:authorの最低限の有効なリクエストが成功する', async () => {
const request = new PostMultipleImportsRequest();
request.users = [
{
name: 'namae',
email: 'hogehoge@example.com',
role: 1,
authorId: 'AUTHOR',
autoRenew: 0,
notification: 0,
encryption: 0,
prompt: 0,
},
];
const valdationObject = plainToClass(PostMultipleImportsRequest, request);
const errors = await validate(valdationObject);
expect(errors.length).toBe(0);
});
it('emailがメールアドレスではない場合、バリデーションエラーが発生する', async () => {
const request = new PostMultipleImportsRequest();
request.users = [
{
name: 'namae',
email: 'hogehoge',
role: 0,
autoRenew: 0,
notification: 0,
},
];
const valdationObject = plainToClass(PostMultipleImportsRequest, request);
const errors = await validate(valdationObject);
expect(errors.length).toBeGreaterThan(0);
});
it('AuthorなのにAuthorIDがない場合、バリデーションエラーが発生する', async () => {
const request = new PostMultipleImportsRequest();
request.users = [
{
name: 'namae',
email: 'hogehoge@example.com',
role: 1,
autoRenew: 0,
notification: 0,
encryption: 0,
prompt: 0,
},
];
const valdationObject = plainToClass(PostMultipleImportsRequest, request);
const errors = await validate(valdationObject);
expect(errors.length).toBeGreaterThan(0);
});
it('Authorなのにencryptionがない場合、バリデーションエラーが発生する', async () => {
const request = new PostMultipleImportsRequest();
request.users = [
{
name: 'namae',
email: 'hogehoge@example.com',
role: 1,
authorId: 'AUTHOR',
autoRenew: 0,
notification: 0,
prompt: 0,
},
];
const valdationObject = plainToClass(PostMultipleImportsRequest, request);
const errors = await validate(valdationObject);
expect(errors.length).toBeGreaterThan(0);
});
it('Authorなのにpromptがない場合、バリデーションエラーが発生する', async () => {
const request = new PostMultipleImportsRequest();
request.users = [
{
name: 'namae',
email: 'hogehoge@example.com',
role: 1,
authorId: 'AUTHOR',
autoRenew: 0,
notification: 0,
encryption: 0,
},
];
const valdationObject = plainToClass(PostMultipleImportsRequest, request);
const errors = await validate(valdationObject);
expect(errors.length).toBeGreaterThan(0);
});
it('Authorでencryption:trueなのに、encryptionPasswordがない場合、バリデーションエラーが発生する', async () => {
const request = new PostMultipleImportsRequest();
request.users = [
{
name: 'namae',
email: 'hogehoge@example.com',
role: 1,
authorId: 'AUTHOR',
autoRenew: 0,
notification: 0,
encryption: 1,
prompt: 0,
},
];
const valdationObject = plainToClass(PostMultipleImportsRequest, request);
const errors = await validate(valdationObject);
expect(errors.length).toBeGreaterThan(0);
});
it('Authorでencryption:trueでencryptionPasswordが正常であれば成功する', async () => {
const request = new PostMultipleImportsRequest();
request.users = [
{
name: 'namae',
email: 'hogehoge@example.com',
role: 1,
authorId: 'AUTHOR',
autoRenew: 0,
notification: 0,
encryption: 1,
encryptionPassword: 'abcd',
prompt: 0,
},
];
const valdationObject = plainToClass(PostMultipleImportsRequest, request);
const errors = await validate(valdationObject);
expect(errors.length).toBe(0);
});
it('encryptionPasswordが要件外(短い)の場合、バリデーションエラーが発生する', async () => {
const request = new PostMultipleImportsRequest();
request.users = [
{
name: 'namae',
email: 'hogehoge@example.com',
role: 1,
authorId: 'AUTHOR',
autoRenew: 0,
notification: 0,
encryption: 1,
encryptionPassword: 'abc',
prompt: 0,
},
];
const valdationObject = plainToClass(PostMultipleImportsRequest, request);
const errors = await validate(valdationObject);
expect(errors.length).toBeGreaterThan(0);
});
it('encryptionPasswordが要件外(長い)の場合、バリデーションエラーが発生する', async () => {
const request = new PostMultipleImportsRequest();
request.users = [
{
name: 'namae',
email: 'hogehoge@example.com',
role: 1,
authorId: 'AUTHOR',
autoRenew: 0,
notification: 0,
encryption: 1,
encryptionPassword: 'abcxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
prompt: 0,
},
];
const valdationObject = plainToClass(PostMultipleImportsRequest, request);
const errors = await validate(valdationObject);
expect(errors.length).toBeGreaterThan(0);
});
it('encryptionPasswordが要件外(全角が含まれる)の場合、バリデーションエラーが発生する', async () => {
const request = new PostMultipleImportsRequest();
request.users = [
{
name: 'namae',
email: 'hogehoge@example.com',
role: 1,
authorId: 'AUTHOR',
autoRenew: 0,
notification: 0,
encryption: 1,
encryptionPassword: 'abcあいうえお',
prompt: 0,
},
];
const valdationObject = plainToClass(PostMultipleImportsRequest, request);
const errors = await validate(valdationObject);
expect(errors.length).toBeGreaterThan(0);
});
it('AuthorIDが要件外(小文字)の場合、バリデーションエラーが発生する', async () => {
const request = new PostMultipleImportsRequest();
request.users = [
{
name: 'namae',
email: 'hogehoge@example.com',
role: 1,
authorId: 'author',
autoRenew: 0,
notification: 0,
encryption: 1,
encryptionPassword: 'abc',
prompt: 0,
},
];
const valdationObject = plainToClass(PostMultipleImportsRequest, request);
const errors = await validate(valdationObject);
expect(errors.length).toBeGreaterThan(0);
});
});
describe('valdation PostMultipleImportsCompleteRequest', () => {
it('最低限の有効なリクエストが成功する', async () => {
const request = new PostMultipleImportsCompleteRequest();
request.accountId = 1;
request.errors = [];
const valdationObject = plainToClass(
PostMultipleImportsCompleteRequest,
request,
);
const errors = await validate(valdationObject);
expect(errors.length).toBe(0);
});
it('エラーが存在するリクエストが成功する', async () => {
const request = new PostMultipleImportsCompleteRequest();
request.accountId = 1;
request.errors = [
{
name: 'namae',
email: 'hogehoge@example.com',
errorCode: 'E1101',
},
{
name: 'namae',
email: 'hogehoge@example.com',
errorCode: 'E1101',
},
];
const valdationObject = plainToClass(
PostMultipleImportsCompleteRequest,
request,
);
const errors = await validate(valdationObject);
expect(errors.length).toBe(0);
});
it('名前が足りないエラーがある場合、バリデーションエラーが発生する', async () => {
const request = {
accountId: 1,
errors: [
{
email: 'hogehoge@example.com',
errorCode: 'E1101',
},
{
name: 'namae',
email: 'hogehoge@example.com',
errorCode: 'E1101',
},
],
};
const valdationObject = plainToClass(
PostMultipleImportsCompleteRequest,
request,
);
const errors = await validate(valdationObject);
expect(errors.length).toBeGreaterThan(0);
});
it('emailが足りないエラーがある場合、バリデーションエラーが発生する', async () => {
const request = {
accountId: 1,
errors: [
{
name: 'namae',
errorCode: 'E1101',
},
{
name: 'namae',
email: 'hogehoge@example.com',
errorCode: 'E1101',
},
],
};
const valdationObject = plainToClass(
PostMultipleImportsCompleteRequest,
request,
);
const errors = await validate(valdationObject);
expect(errors.length).toBeGreaterThan(0);
});
it('errorCodeが足りないエラーがある場合、バリデーションエラーが発生する', async () => {
const request = {
accountId: 1,
errors: [
{
name: 'namae',
email: 'hogehoge@example.com',
},
{
name: 'namae',
email: 'hogehoge@example.com',
errorCode: 'E1101',
},
],
};
const valdationObject = plainToClass(
PostMultipleImportsCompleteRequest,
request,
);
const errors = await validate(valdationObject);
expect(errors.length).toBeGreaterThan(0);
});
it('名前が空のエラーがある場合、バリデーションエラーが発生する', async () => {
const request = {
accountId: 1,
errors: [
{
name: '',
email: 'hogehoge@example.com',
errorCode: 'E1101',
},
{
name: 'namae',
email: 'hogehoge@example.com',
errorCode: 'E1101',
},
],
};
const valdationObject = plainToClass(
PostMultipleImportsCompleteRequest,
request,
);
const errors = await validate(valdationObject);
expect(errors.length).toBeGreaterThan(0);
});
it('emailが空のエラーがある場合、バリデーションエラーが発生する', async () => {
const request = {
accountId: 1,
errors: [
{
name: 'namae',
email: '',
errorCode: 'E1101',
},
{
name: 'namae',
email: 'hogehoge@example.com',
errorCode: 'E1101',
},
],
};
const valdationObject = plainToClass(
PostMultipleImportsCompleteRequest,
request,
);
const errors = await validate(valdationObject);
expect(errors.length).toBeGreaterThan(0);
});
it('emailが空のエラーがある場合、バリデーションエラーが発生する', async () => {
const request = {
accountId: 1,
errors: [
{
name: 'namae',
email: 'hogehoge@example.com',
errorCode: '',
},
{
name: 'namae',
email: 'hogehoge@example.com',
errorCode: 'E1101',
},
],
};
const valdationObject = plainToClass(
PostMultipleImportsCompleteRequest,
request,
);
const errors = await validate(valdationObject);
expect(errors.length).toBeGreaterThan(0);
});
});
});

View File

@ -44,6 +44,10 @@ import {
GetMyUserResponse,
PostDeleteUserRequest,
PostDeleteUserResponse,
PostMultipleImportsRequest,
PostMultipleImportsResponse,
PostMultipleImportsCompleteRequest,
PostMultipleImportsCompleteResponse,
} from './types/types';
import { UsersService } from './users.service';
import { AuthService } from '../auth/auth.service';
@ -57,6 +61,8 @@ import { ADMIN_ROLES, TIERS } from '../../constants';
import { RoleGuard } from '../../common/guards/role/roleguards';
import { makeContext, retrieveRequestId, retrieveIp } from '../../common/log';
import { UserRoles } from '../../common/types/role';
import { SystemAccessGuard } from '../../common/guards/system/accessguards';
import { SystemAccessToken } from '../../common/token/types';
@ApiTags('users')
@Controller('users')
@ -992,4 +998,149 @@ export class UsersController {
await this.usersService.deleteUser(context, body.userId, now);
return {};
}
@ApiResponse({
status: HttpStatus.OK,
type: PostMultipleImportsResponse,
description: '成功時のレスポンス',
})
@ApiResponse({
status: HttpStatus.BAD_REQUEST,
description: '不正なパラメータ',
type: ErrorResponse,
})
@ApiResponse({
status: HttpStatus.UNAUTHORIZED,
description: '認証エラー',
type: ErrorResponse,
})
@ApiResponse({
status: HttpStatus.INTERNAL_SERVER_ERROR,
description: '想定外のサーバーエラー',
type: ErrorResponse,
})
@ApiOperation({
operationId: 'multipleImports',
description: 'ユーザーを一括登録します',
})
@ApiBearerAuth()
@UseGuards(AuthGuard)
@UseGuards(
RoleGuard.requireds({ roles: [ADMIN_ROLES.ADMIN], delegation: true }),
)
@Post('multiple-imports')
async multipleImports(
@Body() body: PostMultipleImportsRequest,
@Req() req: Request,
): Promise<PostMultipleImportsResponse> {
const accessToken = retrieveAuthorizationToken(req);
if (!accessToken) {
throw new HttpException(
makeErrorResponse('E000107'),
HttpStatus.UNAUTHORIZED,
);
}
const ip = retrieveIp(req);
if (!ip) {
throw new HttpException(
makeErrorResponse('E000401'),
HttpStatus.UNAUTHORIZED,
);
}
const requestId = retrieveRequestId(req);
if (!requestId) {
throw new HttpException(
makeErrorResponse('E000501'),
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
const decodedToken = jwt.decode(accessToken, { json: true });
if (!decodedToken) {
throw new HttpException(
makeErrorResponse('E000101'),
HttpStatus.UNAUTHORIZED,
);
}
const { userId, delegateUserId } = decodedToken as AccessToken;
const context = makeContext(userId, requestId, delegateUserId);
this.logger.log(`[${context.getTrackingId()}] ip : ${ip}`);
// TODO: 処理を実装
return {};
}
@ApiResponse({
status: HttpStatus.OK,
type: PostMultipleImportsCompleteResponse,
description: '成功時のレスポンス',
})
@ApiResponse({
status: HttpStatus.BAD_REQUEST,
description: '不正なパラメータ',
type: ErrorResponse,
})
@ApiResponse({
status: HttpStatus.UNAUTHORIZED,
description: '認証エラー',
type: ErrorResponse,
})
@ApiResponse({
status: HttpStatus.INTERNAL_SERVER_ERROR,
description: '想定外のサーバーエラー',
type: ErrorResponse,
})
@ApiOperation({
operationId: 'multipleImportsComplate',
description: 'ユーザー一括登録の完了を通知します',
})
@ApiBearerAuth()
@UseGuards(SystemAccessGuard)
@Post('multiple-imports/complete')
async multipleImportsComplate(
@Body() body: PostMultipleImportsCompleteRequest,
@Req() req: Request,
): Promise<PostMultipleImportsCompleteResponse> {
const accessToken = retrieveAuthorizationToken(req);
if (!accessToken) {
throw new HttpException(
makeErrorResponse('E000107'),
HttpStatus.UNAUTHORIZED,
);
}
const ip = retrieveIp(req);
if (!ip) {
throw new HttpException(
makeErrorResponse('E000401'),
HttpStatus.UNAUTHORIZED,
);
}
const requestId = retrieveRequestId(req);
if (!requestId) {
throw new HttpException(
makeErrorResponse('E000501'),
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
const decodedToken = jwt.decode(accessToken, { json: true });
if (!decodedToken) {
throw new HttpException(
makeErrorResponse('E000101'),
HttpStatus.UNAUTHORIZED,
);
}
const { systemName } = decodedToken as SystemAccessToken;
const context = makeContext(systemName, requestId);
this.logger.log(`[${context.getTrackingId()}] ip : ${ip}`);
// TODO: 処理を実装
return {};
}
}