Merged PR 530: API実装(代行操作用トークン生成API)

## 概要
[Task2905: API実装(代行操作用トークン生成API)](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/fa4924a4-d079-4fab-9fb5-a9a11eb205f0/_workitems/edit/2905)

- 代行操作用トークン生成APIとテストを実装しました。

## レビューポイント
- リポジトリの処理は適切か
  - アカウントの取得⇒管理者ユーザ取得としているためUsersリポジトリ配下に配置していますが構成として問題ないでしょうか。
- テストケースは適切か
- アクセストークン生成は既存と別に代行操作用のメソッドを用意していますが想定とあっていますでしょうか。

## UIの変更
- なし

## 動作確認状況
- ローカルで確認
This commit is contained in:
makabe.t 2023-10-30 00:58:46 +00:00
parent b314fe4b46
commit e6da791406
12 changed files with 568 additions and 74 deletions

View File

@ -38,6 +38,7 @@ export const ErrorCodes = [
'E010401', // PONumber重複エラー
'E010501', // アカウント不在エラー
'E010502', // アカウント情報変更不可エラー
'E010503', // 代行操作不許可エラー
'E010601', // タスク変更不可エラー(タスクが変更できる状態でない、またはタスクが存在しない)
'E010602', // タスク変更権限不足エラー
'E010603', // タスク不在エラー

View File

@ -27,6 +27,7 @@ export const errors: Errors = {
E010401: 'This PoNumber already used Error',
E010501: 'Account not Found Error.',
E010502: 'Account information cannot be changed Error.',
E010503: 'Delegation not allowed Error.',
E010601: 'Task is not Editable Error',
E010602: 'No task edit permissions Error',
E010603: 'Task not found Error.',

View File

@ -1,7 +1,11 @@
import { Context } from './types';
export const makeContext = (externalId: string): Context => {
export const makeContext = (
externalId: string,
delegationId?: string,
): Context => {
return {
trackingId: externalId,
delegationId: delegationId,
};
};

View File

@ -3,4 +3,8 @@ export class Context {
* APIの操作ユーザーを追跡するためのID
*/
trackingId: string;
/**
* APIの代行操作ユーザーを追跡するためのID
*/
delegationId?: string | undefined;
}

View File

@ -1,4 +1,8 @@
export type RefreshToken = {
/**
* ()
*/
delegateUserId?: string | undefined;
/**
*
*/
@ -14,6 +18,10 @@ export type RefreshToken = {
};
export type AccessToken = {
/**
* ()
*/
delegateUserId?: string | undefined;
/**
*
*/

View File

@ -31,6 +31,8 @@ import { Request } from 'express';
import { AuthGuard } from '../../common/guards/auth/authguards';
import { RoleGuard } from '../../common/guards/role/roleguards';
import { ADMIN_ROLES, TIERS } from '../../constants';
import jwt from 'jsonwebtoken';
import { AccessToken } from '../../common/token';
@ApiTags('auth')
@Controller('auth')
@ -183,18 +185,35 @@ export class AuthController {
@Body() body: DelegationTokenRequest,
): Promise<DelegationTokenResponse> {
const { delegatedAccountId } = body;
const refreshToken = retrieveAuthorizationToken(req);
const token = retrieveAuthorizationToken(req);
if (!refreshToken) {
if (!token) {
throw new HttpException(
makeErrorResponse('E000107'),
HttpStatus.UNAUTHORIZED,
);
}
const decodedAccessToken = jwt.decode(token, { json: true });
if (!decodedAccessToken) {
throw new HttpException(
makeErrorResponse('E000101'),
HttpStatus.UNAUTHORIZED,
);
}
const { userId } = decodedAccessToken as AccessToken;
const context = makeContext(uuidv4());
const context = makeContext(userId);
const refreshToken = await this.authService.generateDelegationRefreshToken(
context,
userId,
delegatedAccountId,
);
const accessToken = await this.authService.generateDelegationAccessToken(
context,
refreshToken,
);
return { accessToken: '', refreshToken: '' };
return { accessToken, refreshToken };
}
@Post('delegation/access-token')

View File

@ -13,6 +13,9 @@ import { makeTestAccount } from '../../common/test/utility';
import { AuthService } from './auth.service';
import { createTermInfo } from './test/utility';
import { v4 as uuidv4 } from 'uuid';
import { TIERS, USER_ROLES } from '../../constants';
import { decode, isVerifyError } from '../../common/jwt';
import { RefreshToken, AccessToken } from '../../common/token';
describe('AuthService', () => {
it('IDトークンの検証とペイロードの取得に成功する', async () => {
@ -276,6 +279,239 @@ describe('checkIsAcceptedLatestVersion', () => {
});
});
describe('generateDelegationRefreshToken', () => {
let source: DataSource | null = null;
beforeEach(async () => {
source = new DataSource({
type: 'sqlite',
database: ':memory:',
logging: false,
entities: [__dirname + '/../../**/*.entity{.ts,.js}'],
synchronize: true, // trueにすると自動的にmigrationが行われるため注意
});
return source.initialize();
});
afterEach(async () => {
if (!source) return;
await source.destroy();
source = null;
});
it('代行操作が許可されたパートナーの代行操作用リフレッシュトークンを取得できること', async () => {
if (!source) fail();
const module = await makeTestingModule(source);
if (!module) fail();
const service = module.get<AuthService>(AuthService);
const { admin: parentAdmin, account: parentAccount } =
await makeTestAccount(source, {
tier: 4,
});
const { admin: partnerAdmin, account: partnerAccount } =
await makeTestAccount(
source,
{
tier: 5,
parent_account_id: parentAccount.id,
delegation_permission: true,
},
{ role: USER_ROLES.NONE },
);
const context = makeContext(parentAdmin.external_id);
const delegationRefreshToken = await service.generateDelegationRefreshToken(
context,
parentAdmin.external_id,
partnerAccount.id,
);
// 取得できた代行操作用リフレッシュトークンをデコード
const decodeToken = decode<RefreshToken>(delegationRefreshToken);
if (isVerifyError(decodeToken)) {
fail();
}
expect(decodeToken.role).toBe('none admin');
expect(decodeToken.tier).toBe(TIERS.TIER5);
expect(decodeToken.userId).toBe(partnerAdmin.external_id);
expect(decodeToken.delegateUserId).toBe(parentAdmin.external_id);
});
it('代行操作が許可されていない場合、400エラーとなること', async () => {
if (!source) fail();
const module = await makeTestingModule(source);
if (!module) fail();
const service = module.get<AuthService>(AuthService);
const { admin: parentAdmin, account: parentAccount } =
await makeTestAccount(source, {
tier: 4,
});
const { account: partnerAccount } = await makeTestAccount(
source,
{
tier: 5,
parent_account_id: parentAccount.id,
delegation_permission: false,
},
{ role: USER_ROLES.NONE },
);
const context = makeContext(parentAdmin.external_id);
try {
await service.generateDelegationRefreshToken(
context,
parentAdmin.external_id,
partnerAccount.id,
);
fail();
} catch (e) {
if (e instanceof HttpException) {
expect(e.getStatus()).toEqual(HttpStatus.BAD_REQUEST);
expect(e.getResponse()).toEqual(makeErrorResponse('E010503'));
} else {
fail();
}
}
});
it('代行操作対象が存在しない場合、400エラーとなること', async () => {
if (!source) fail();
const module = await makeTestingModule(source);
if (!module) fail();
const service = module.get<AuthService>(AuthService);
const { admin: parentAdmin, account: parentAccount } =
await makeTestAccount(source, {
tier: 4,
});
await makeTestAccount(
source,
{
tier: 5,
parent_account_id: parentAccount.id,
delegation_permission: false,
},
{ role: USER_ROLES.NONE },
);
const context = makeContext(parentAdmin.external_id);
try {
await service.generateDelegationRefreshToken(
context,
parentAdmin.external_id,
9999,
);
fail();
} catch (e) {
if (e instanceof HttpException) {
expect(e.getStatus()).toEqual(HttpStatus.BAD_REQUEST);
expect(e.getResponse()).toEqual(makeErrorResponse('E010501'));
} else {
fail();
}
}
});
});
describe('generateDelegationAccessToken', () => {
let source: DataSource | null = null;
beforeEach(async () => {
source = new DataSource({
type: 'sqlite',
database: ':memory:',
logging: false,
entities: [__dirname + '/../../**/*.entity{.ts,.js}'],
synchronize: true, // trueにすると自動的にmigrationが行われるため注意
});
return source.initialize();
});
afterEach(async () => {
if (!source) return;
await source.destroy();
source = null;
});
it('代行操作用リフレッシュトークンから代行操作用アクセストークンを取得できること', async () => {
if (!source) fail();
const module = await makeTestingModule(source);
if (!module) fail();
const service = module.get<AuthService>(AuthService);
const { admin: parentAdmin, account: parentAccount } =
await makeTestAccount(source, {
tier: 4,
});
const { admin: partnerAdmin, account: partnerAccount } =
await makeTestAccount(
source,
{
tier: 5,
parent_account_id: parentAccount.id,
delegation_permission: true,
},
{ role: USER_ROLES.NONE },
);
const context = makeContext(parentAdmin.external_id);
const delegationRefreshToken = await service.generateDelegationRefreshToken(
context,
parentAdmin.external_id,
partnerAccount.id,
);
// 取得できた代行操作用リフレッシュトークンをデコード
const decodeRefreshToken = decode<RefreshToken>(delegationRefreshToken);
if (isVerifyError(decodeRefreshToken)) {
fail();
}
expect(decodeRefreshToken.role).toBe('none admin');
expect(decodeRefreshToken.tier).toBe(TIERS.TIER5);
expect(decodeRefreshToken.userId).toBe(partnerAdmin.external_id);
expect(decodeRefreshToken.delegateUserId).toBe(parentAdmin.external_id);
const delegationAccessToken = await service.generateDelegationAccessToken(
context,
delegationRefreshToken,
);
// 取得できた代行操作用アクセストークンをデコード
const decodeAccessToken = decode<AccessToken>(delegationAccessToken);
if (isVerifyError(decodeAccessToken)) {
fail();
}
expect(decodeAccessToken.role).toBe('none admin');
expect(decodeAccessToken.tier).toBe(TIERS.TIER5);
expect(decodeAccessToken.userId).toBe(partnerAdmin.external_id);
expect(decodeAccessToken.delegateUserId).toBe(parentAdmin.external_id);
});
it('代行操作用リフレッシュトークンの形式が不正な場合、エラーとなること', async () => {
if (!source) fail();
const module = await makeTestingModule(source);
if (!module) fail();
const service = module.get<AuthService>(AuthService);
const { admin: parentAdmin } = await makeTestAccount(source, {
tier: 4,
});
const context = makeContext(parentAdmin.external_id);
try {
await service.generateDelegationAccessToken(context, 'invalid token');
fail();
} catch (e) {
if (e instanceof HttpException) {
expect(e.getStatus()).toEqual(HttpStatus.UNAUTHORIZED);
expect(e.getResponse()).toEqual(makeErrorResponse('E000101'));
} else {
fail();
}
}
});
});
const idTokenPayload = {
exp: 9000000000,
nbf: 1000000000,

View File

@ -19,9 +19,14 @@ import {
} from '../../common/token';
import { ADMIN_ROLES, TIERS, USER_ROLES } from '../../constants';
import { AdB2cService } from '../../gateways/adb2c/adb2c.service';
import { User } from '../../repositories/users/entity/user.entity';
import { UsersRepositoryService } from '../../repositories/users/users.repository.service';
import { Context } from '../../common/log';
import {
AccountNotFoundError,
AdminUserNotFoundError,
} from '../../repositories/accounts/errors/types';
import { DelegationNotAllowedError } from '../../repositories/users/errors/types';
import { RoleUnexpectedError, TierUnexpectedError } from './errors/types';
@Injectable()
export class AuthService {
@ -76,88 +81,77 @@ export class AuthService {
`[IN] [${context.trackingId}] ${this.generateRefreshToken.name}`,
);
let user: User;
// ユーザー情報とユーザーが属しているアカウント情報を取得
try {
user = await this.usersRepository.findUserByExternalId(idToken.sub);
const user = await this.usersRepository.findUserByExternalId(idToken.sub);
if (!user.account) {
throw new Error('Account information not found');
}
// Tierのチェック
const minTier = 1;
const maxTier = 5;
const userTier = user.account.tier;
if (userTier < minTier || userTier > maxTier) {
throw new TierUnexpectedError(
`Tier from DB is unexpected value. tier=${user.account.tier}`,
);
}
// 要求された環境用トークンの寿命を決定
const refreshTokenLifetime =
type === 'web'
? this.refreshTokenLifetimeWeb
: this.refreshTokenLifetimeDefault;
const privateKey = getPrivateKey(this.configService);
// ユーザーのロールを設定
const role = this.getUserRole(user.role);
const token = sign<RefreshToken>(
{
//ユーザーの属しているアカウントの管理者にユーザーが設定されていればadminをセットする
role: `${role} ${
user.account.primary_admin_user_id === user.id ||
user.account.secondary_admin_user_id === user.id
? ADMIN_ROLES.ADMIN
: ADMIN_ROLES.STANDARD
}`,
tier: user.account.tier,
userId: idToken.sub,
},
refreshTokenLifetime,
privateKey,
);
return token;
} catch (e) {
this.logger.error(`error=${e}`);
this.logger.log(
`[OUT] [${context.trackingId}] ${this.generateRefreshToken.name}`,
);
if (e instanceof Error) {
switch (e.constructor) {
case TierUnexpectedError:
throw new HttpException(
makeErrorResponse('E010206'),
HttpStatus.INTERNAL_SERVER_ERROR,
);
case RoleUnexpectedError:
throw new HttpException(
makeErrorResponse('E010205'),
HttpStatus.INTERNAL_SERVER_ERROR,
);
default:
break;
}
}
throw new HttpException(
makeErrorResponse('E009999'),
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
// Tierのチェック
const minTier = 1;
const maxTier = 5;
const userTier = user.account.tier;
if (userTier < minTier || userTier > maxTier) {
this.logger.error(
`Tier from DB is unexpected value. tier=${user.account.tier}`,
);
} finally {
this.logger.log(
`[OUT] [${context.trackingId}] ${this.generateRefreshToken.name}`,
);
throw new HttpException(
makeErrorResponse('E010206'),
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
// 要求された環境用トークンの寿命を決定
const refreshTokenLifetime =
type === 'web'
? this.refreshTokenLifetimeWeb
: this.refreshTokenLifetimeDefault;
const privateKey = getPrivateKey(this.configService);
// ユーザーのロールを設定
// 万一不正なRoleが登録されていた場合、そのままDBの値を使用すると不正なロールのリフレッシュトークンが発行されるため、
// ロールの設定値はDBに保存したRoleの値を直接トークンに入れないように定数で設定する
// ※none/author/typist以外はロールに設定されない
let role = '';
if (user.role === USER_ROLES.NONE) {
role = USER_ROLES.NONE;
} else if (user.role === USER_ROLES.AUTHOR) {
role = USER_ROLES.AUTHOR;
} else if (user.role === USER_ROLES.TYPIST) {
role = USER_ROLES.TYPIST;
} else {
this.logger.error(`Role from DB is unexpected value. role=${user.role}`);
this.logger.log(
`[OUT] [${context.trackingId}] ${this.generateRefreshToken.name}`,
);
throw new HttpException(
makeErrorResponse('E010205'),
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
const token = sign<RefreshToken>(
{
//ユーザーの属しているアカウントの管理者にユーザーが設定されていればadminをセットする
role: `${role} ${
user.account.primary_admin_user_id === user.id ||
user.account.secondary_admin_user_id === user.id
? ADMIN_ROLES.ADMIN
: ADMIN_ROLES.STANDARD
}`,
tier: user.account.tier,
userId: idToken.sub,
},
refreshTokenLifetime,
privateKey,
);
this.logger.log(
`[OUT] [${context.trackingId}] ${this.generateRefreshToken.name}`,
);
return token;
}
/**
@ -203,6 +197,137 @@ export class AuthService {
);
return accessToken;
}
/**
*
* @param context
* @param delegateUserExternalId
* @param originAccountId ID
* @returns delegation refresh token
*/
async generateDelegationRefreshToken(
context: Context,
delegateUserExternalId: string,
originAccountId: number,
): Promise<string> {
this.logger.log(
`[IN] [${context.trackingId}] ${this.generateDelegationRefreshToken.name} | params: { ` +
`delegateUserExternalId: ${delegateUserExternalId}, ` +
`originAccountId: ${originAccountId}, };`,
);
// ユーザー情報とユーザーが属しているアカウント情報を取得
try {
const user = await this.usersRepository.findUserByExternalId(
delegateUserExternalId,
);
// 代行操作対象アカウントの管理者ユーザーを取得
const adminUser = await this.usersRepository.findDelegateUser(
user.account_id,
originAccountId,
);
// 要求された環境用トークンの寿命を決定
const refreshTokenLifetime = this.refreshTokenLifetimeWeb;
const privateKey = getPrivateKey(this.configService);
// ユーザーのロールを設定
const role = this.getUserRole(adminUser.role);
const token = sign<RefreshToken>(
{
role: `${role} ${ADMIN_ROLES.ADMIN}`,
tier: TIERS.TIER5,
userId: adminUser.external_id,
delegateUserId: delegateUserExternalId,
},
refreshTokenLifetime,
privateKey,
);
return token;
} catch (e) {
this.logger.error(`error=${e}`);
if (e instanceof Error) {
switch (e.constructor) {
case AccountNotFoundError:
case AdminUserNotFoundError:
throw new HttpException(
makeErrorResponse('E010501'),
HttpStatus.BAD_REQUEST,
);
case DelegationNotAllowedError:
throw new HttpException(
makeErrorResponse('E010503'),
HttpStatus.BAD_REQUEST,
);
case RoleUnexpectedError:
throw new HttpException(
makeErrorResponse('E010205'),
HttpStatus.INTERNAL_SERVER_ERROR,
);
default:
break;
}
}
throw new HttpException(
makeErrorResponse('E009999'),
HttpStatus.INTERNAL_SERVER_ERROR,
);
} finally {
this.logger.log(
`[OUT] [${context.trackingId}] ${this.generateDelegationRefreshToken.name}`,
);
}
}
/**
*
* @param context
* @param refreshToken
* @returns delegation access token
*/
async generateDelegationAccessToken(
context: Context,
refreshToken: string,
): Promise<string> {
this.logger.log(
`[IN] [${context.trackingId}] ${this.generateDelegationAccessToken.name}`,
);
const privateKey = getPrivateKey(this.configService);
const pubkey = getPublicKey(this.configService);
const token = verify<RefreshToken>(refreshToken, pubkey);
if (isVerifyError(token)) {
this.logger.error(`${token.reason} | ${token.message}`);
this.logger.log(
`[OUT] [${context.trackingId}] ${this.generateDelegationAccessToken.name}`,
);
throw new HttpException(
makeErrorResponse('E000101'),
HttpStatus.UNAUTHORIZED,
);
}
const accessToken = sign<AccessToken>(
{
role: token.role,
tier: token.tier,
userId: token.userId,
delegateUserId: token.delegateUserId,
},
this.accessTokenlifetime,
privateKey,
);
this.logger.log(
`[OUT] [${context.trackingId}] ${this.generateDelegationAccessToken.name}`,
);
return accessToken;
}
/**
* Gets id token
* @param token
@ -340,6 +465,26 @@ export class AuthService {
HttpStatus.UNAUTHORIZED,
);
};
/**
*
*/
getUserRole = (role: string): string => {
// ユーザーのロールを設定
// 万一不正なRoleが登録されていた場合、そのままDBの値を使用すると不正なロールのリフレッシュトークンが発行されるため、
// ロールの設定値はDBに保存したRoleの値を直接トークンに入れないように定数で設定する
// ※none/author/typist以外はロールに設定されない
if (role === USER_ROLES.NONE) {
return USER_ROLES.NONE;
} else if (role === USER_ROLES.AUTHOR) {
return USER_ROLES.AUTHOR;
} else if (role === USER_ROLES.TYPIST) {
return USER_ROLES.TYPIST;
} else {
throw new RoleUnexpectedError(
`Role from DB is unexpected value. role=${role}`,
);
}
};
/**
*

View File

@ -0,0 +1,4 @@
// Role文字列想定外エラー
export class RoleUnexpectedError extends Error {}
// Tier範囲想定外エラー
export class TierUnexpectedError extends Error {}

View File

@ -1,4 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsInt } from 'class-validator';
export class TokenRequest {
@ApiProperty()
@ -28,6 +29,7 @@ export type TermsCheckInfo = {
export class DelegationTokenRequest {
@ApiProperty({ description: '代行操作対象のアカウントID' })
@IsInt()
delegatedAccountId: number;
}
export class DelegationTokenResponse {

View File

@ -12,3 +12,5 @@ export class EncryptionPasswordNeedError extends Error {}
export class TermInfoNotFoundError extends Error {}
// 利用規約バージョンパラメータ不在エラー
export class UpdateTermsVersionNotSetError extends Error {}
// 代行操作不許可エラー
export class DelegationNotAllowedError extends Error {}

View File

@ -14,6 +14,7 @@ import {
EncryptionPasswordNeedError,
TermInfoNotFoundError,
UpdateTermsVersionNotSetError,
DelegationNotAllowedError,
} from './errors/types';
import {
LICENSE_ALLOCATED_STATUS,
@ -27,7 +28,11 @@ import { License } from '../licenses/entity/license.entity';
import { NewTrialLicenseExpirationDate } from '../../features/licenses/types/types';
import { Term } from '../terms/entity/term.entity';
import { TermsCheckInfo } from '../../features/auth/types/types';
import { AccountNotFoundError } from '../accounts/errors/types';
import {
AccountNotFoundError,
AdminUserNotFoundError,
} from '../accounts/errors/types';
import { Account } from '../accounts/entity/account.entity';
@Injectable()
export class UsersRepositoryService {
@ -533,4 +538,67 @@ export class UsersRepositoryService {
await userRepo.update({ id: user.id }, user);
});
}
/**
*
* @param delegateAccountId ID
* @param originAccountId ID
* @returns delegate accounts
*/
async findDelegateUser(
delegateAccountId: number,
originAccountId: number,
): Promise<User> {
return await this.dataSource.transaction(async (entityManager) => {
const accountRepo = entityManager.getRepository(Account);
// 代行操作対象のアカウントを取得 ※親アカウントが代行操作者のアカウントIDと一致すること
const account = await accountRepo.findOne({
where: {
id: originAccountId,
parent_account_id: delegateAccountId,
tier: TIERS.TIER5,
},
});
if (!account) {
throw new AccountNotFoundError(
`Account is not found. originAccountId: ${originAccountId}, delegateAccountId: ${delegateAccountId}`,
);
}
// 代行操作が許可されていない場合はエラー
if (!account.delegation_permission) {
throw new DelegationNotAllowedError(
`Delegation is not allowed. id: ${originAccountId}`,
);
}
const adminUserId = account.primary_admin_user_id;
// 運用上、代行操作対象アカウントの管理者ユーザーがいないことはあり得ないが、プログラム上発生しうるのでエラーとして処理
if (!adminUserId) {
throw new Error(`Admin user is not found. id: ${originAccountId}`);
}
// 代行操作対象のアカウントの管理者ユーザーを取得
const userRepo = entityManager.getRepository(User);
const primaryUser = await userRepo.findOne({
where: {
account_id: originAccountId,
id: adminUserId,
},
relations: {
account: true,
},
});
// 運用上、代行操作対象アカウントの管理者ユーザーがいないことはあり得ないが、プログラム上発生しうるのでエラーとして処理
if (!primaryUser) {
throw new Error(`Admin user is not found. id: ${originAccountId}`);
}
return primaryUser;
});
}
}