Merged PR 537: API実装(代行操作用トークン更新API)

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

- アクセストークン更新APIとテストを実装しました。

## レビューポイント
- リポジトリのアカウントチェックは適切か
- テストケースは適切か

## UIの変更
- なし

## 動作確認状況
- ローカルで確認
This commit is contained in:
makabe.t 2023-10-31 01:47:00 +00:00
parent f33af7a9cd
commit 01d92b2408
7 changed files with 415 additions and 8 deletions

View File

@ -32,7 +32,7 @@ import { AuthGuard } from '../../common/guards/auth/authguards';
import { RoleGuard } from '../../common/guards/role/roleguards'; import { RoleGuard } from '../../common/guards/role/roleguards';
import { ADMIN_ROLES, TIERS } from '../../constants'; import { ADMIN_ROLES, TIERS } from '../../constants';
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import { AccessToken } from '../../common/token'; import { AccessToken, RefreshToken } from '../../common/token';
@ApiTags('auth') @ApiTags('auth')
@Controller('auth') @Controller('auth')
@ -248,9 +248,23 @@ export class AuthController {
HttpStatus.UNAUTHORIZED, HttpStatus.UNAUTHORIZED,
); );
} }
const decodedRefreshToken = jwt.decode(refreshToken, { json: true });
if (!decodedRefreshToken) {
throw new HttpException(
makeErrorResponse('E000101'),
HttpStatus.UNAUTHORIZED,
);
}
const { userId, delegateUserId } = decodedRefreshToken as RefreshToken;
const context = makeContext(uuidv4()); const context = makeContext(userId);
const accessToken = await this.authService.updateDelegationAccessToken(
context,
delegateUserId,
userId,
refreshToken,
);
return { accessToken: '' }; return { accessToken };
} }
} }

View File

@ -9,9 +9,13 @@ import {
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import { makeContext } from '../../common/log'; import { makeContext } from '../../common/log';
import { makeTestingModule } from '../../common/test/modules'; import { makeTestingModule } from '../../common/test/modules';
import { makeTestAccount } from '../../common/test/utility'; import { getAccount, makeTestAccount } from '../../common/test/utility';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { createTermInfo } from './test/utility'; import {
createTermInfo,
deleteAccount,
updateAccountDelegationPermission,
} from './test/utility';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { TIERS, USER_ROLES } from '../../constants'; import { TIERS, USER_ROLES } from '../../constants';
import { decode, isVerifyError } from '../../common/jwt'; import { decode, isVerifyError } from '../../common/jwt';
@ -512,6 +516,214 @@ describe('generateDelegationAccessToken', () => {
}); });
}); });
describe('updateDelegationAccessToken', () => {
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 token = await service.updateDelegationAccessToken(
context,
decodeRefreshToken.delegateUserId,
decodeRefreshToken.userId,
delegationRefreshToken,
);
// 取得できた代行操作用リフレッシュトークンをデコード
const decodeAccessToken = decode<RefreshToken>(token);
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, 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);
if (decodeRefreshToken.delegateUserId === undefined) {
fail();
}
// 代行操作対象アカウントの代行操作を許可しないように変更
await updateAccountDelegationPermission(source, partnerAccount.id, false);
const account = await getAccount(source, partnerAccount.id);
expect(account?.delegation_permission ?? true).toBeFalsy();
try {
await service.updateDelegationAccessToken(
context,
decodeRefreshToken.delegateUserId,
decodeRefreshToken.userId,
delegationRefreshToken,
);
fail();
} catch (e) {
if (e instanceof HttpException) {
expect(e.getStatus()).toEqual(HttpStatus.UNAUTHORIZED);
expect(e.getResponse()).toEqual(makeErrorResponse('E010503'));
} else {
fail();
}
}
});
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);
if (decodeRefreshToken.delegateUserId === undefined) {
fail();
}
// 代行操作対象アカウントを削除
deleteAccount(source, partnerAccount.id);
try {
await service.updateDelegationAccessToken(
context,
decodeRefreshToken.delegateUserId,
partnerAdmin.external_id,
delegationRefreshToken,
);
fail();
} catch (e) {
if (e instanceof HttpException) {
expect(e.getStatus()).toEqual(HttpStatus.UNAUTHORIZED);
expect(e.getResponse()).toEqual(makeErrorResponse('E010501'));
} else {
fail();
}
}
});
});
const idTokenPayload = { const idTokenPayload = {
exp: 9000000000, exp: 9000000000,
nbf: 1000000000, nbf: 1000000000,

View File

@ -25,8 +25,15 @@ import {
AccountNotFoundError, AccountNotFoundError,
AdminUserNotFoundError, AdminUserNotFoundError,
} from '../../repositories/accounts/errors/types'; } from '../../repositories/accounts/errors/types';
import { DelegationNotAllowedError } from '../../repositories/users/errors/types'; import {
import { RoleUnexpectedError, TierUnexpectedError } from './errors/types'; DelegationNotAllowedError,
UserNotFoundError,
} from '../../repositories/users/errors/types';
import {
InvalidTokenFormatError,
RoleUnexpectedError,
TierUnexpectedError,
} from './errors/types';
@Injectable() @Injectable()
export class AuthService { export class AuthService {
@ -328,6 +335,116 @@ export class AuthService {
return accessToken; return accessToken;
} }
/**
*
* @param context
* @param delegateUserExternalId
* @param originUserExternalId
* @param refreshToken
* @returns delegation access token
*/
async updateDelegationAccessToken(
context: Context,
delegateUserExternalId: string | undefined,
originUserExternalId: string,
refreshToken: string,
): Promise<string> {
this.logger.log(
`[IN] [${context.trackingId}] ${this.updateDelegationAccessToken.name} | params: { ` +
`delegateUserExternalId: ${delegateUserExternalId}, ` +
`originUserExternalId: ${originUserExternalId}, };`,
);
try {
if (!delegateUserExternalId) {
throw new UserNotFoundError('delegateUserExternalId is undefined');
}
const user = await this.usersRepository.findUserByExternalId(
delegateUserExternalId,
);
const privateKey = getPrivateKey(this.configService);
const pubkey = getPublicKey(this.configService);
// トークンの検証
const decodedToken = verify<RefreshToken>(refreshToken, pubkey);
if (isVerifyError(decodedToken)) {
throw new InvalidTokenFormatError(
`Invalid token format. ${decodedToken.reason} | ${decodedToken.message}`,
);
}
// トークンの生成には検証済みトークンの値を使用する
const { userId, delegateUserId, tier, role } = decodedToken;
if (delegateUserId === undefined) {
throw new AdminUserNotFoundError('delegateUserId is undefined');
}
// 代行操作対象アカウントの管理者ユーザーが存在して、アカウントに対して代行操作権限があるか確認
const delegationPermission =
await this.usersRepository.isAllowDelegationPermission(
user.account_id,
userId,
);
if (!delegationPermission) {
throw new DelegationNotAllowedError(
`Delegation is not allowed. delegateUserId=${delegateUserId}, userId=${userId}`,
);
}
const accessToken = sign<AccessToken>(
{
role: role,
tier: tier,
userId: userId,
delegateUserId: delegateUserId,
},
this.accessTokenlifetime,
privateKey,
);
return accessToken;
} catch (e) {
this.logger.error(`error=${e}`);
if (e instanceof HttpException) {
throw e;
}
if (e instanceof Error) {
switch (e.constructor) {
case InvalidTokenFormatError:
throw new HttpException(
makeErrorResponse('E000101'),
HttpStatus.UNAUTHORIZED,
);
case UserNotFoundError:
case AccountNotFoundError:
case AdminUserNotFoundError:
throw new HttpException(
makeErrorResponse('E010501'),
HttpStatus.UNAUTHORIZED,
);
case DelegationNotAllowedError:
throw new HttpException(
makeErrorResponse('E010503'),
HttpStatus.UNAUTHORIZED,
);
default:
break;
}
}
throw new HttpException(
makeErrorResponse('E009999'),
HttpStatus.INTERNAL_SERVER_ERROR,
);
} finally {
this.logger.log(
`[OUT] [${context.trackingId}] ${this.updateDelegationAccessToken.name}`,
);
}
}
/** /**
* Gets id token * Gets id token
* @param token * @param token

View File

@ -2,3 +2,5 @@
export class RoleUnexpectedError extends Error {} export class RoleUnexpectedError extends Error {}
// Tier範囲想定外エラー // Tier範囲想定外エラー
export class TierUnexpectedError extends Error {} export class TierUnexpectedError extends Error {}
// トークン形式不正エラー
export class InvalidTokenFormatError extends Error {}

View File

@ -1,5 +1,7 @@
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import { Term } from '../../../repositories/terms/entity/term.entity'; import { Term } from '../../../repositories/terms/entity/term.entity';
import { Account } from '../../../repositories/accounts/entity/account.entity';
import { User } from '../../../repositories/users/entity/user.entity';
export const createTermInfo = async ( export const createTermInfo = async (
datasource: DataSource, datasource: DataSource,
@ -16,3 +18,21 @@ export const createTermInfo = async (
}); });
identifiers.pop() as Term; identifiers.pop() as Term;
}; };
export const updateAccountDelegationPermission = async (
dataSource: DataSource,
id: number,
delegationPermission: boolean,
): Promise<void> => {
await dataSource
.getRepository(Account)
.update({ id: id }, { delegation_permission: delegationPermission });
};
export const deleteAccount = async (
dataSource: DataSource,
id: number,
): Promise<void> => {
await dataSource.getRepository(User).delete({ account_id: id });
await dataSource.getRepository(Account).delete({ id: id });
};

View File

@ -1,6 +1,5 @@
import { HttpException, HttpStatus, Injectable, Logger } from '@nestjs/common'; import { HttpException, HttpStatus, Injectable, Logger } from '@nestjs/common';
import { makeErrorResponse } from '../../common/error/makeErrorResponse'; import { makeErrorResponse } from '../../common/error/makeErrorResponse';
import { AccessToken } from '../../common/token';
import { UsersRepositoryService } from '../../repositories/users/users.repository.service'; import { UsersRepositoryService } from '../../repositories/users/users.repository.service';
import { TasksRepositoryService } from '../../repositories/tasks/tasks.repository.service'; import { TasksRepositoryService } from '../../repositories/tasks/tasks.repository.service';
import { BlobstorageService } from '../../gateways/blobstorage/blobstorage.service'; import { BlobstorageService } from '../../gateways/blobstorage/blobstorage.service';

View File

@ -601,4 +601,47 @@ export class UsersRepositoryService {
return primaryUser; return primaryUser;
}); });
} }
/**
*
* @param delegateAccountId ID
* @param originAccountId ID
* @returns delegate accounts
*/
async isAllowDelegationPermission(
delegateAccountId: number,
originUserExternalId: string,
): Promise<boolean> {
return await this.dataSource.transaction(async (entityManager) => {
const userRepo = entityManager.getRepository(User);
const primaryUser = await userRepo.findOne({
where: {
external_id: originUserExternalId,
account: {
parent_account_id: delegateAccountId,
tier: TIERS.TIER5,
},
},
relations: {
account: true,
},
});
if (!primaryUser) {
throw new AdminUserNotFoundError(
`Admin user is not found. externalId: ${originUserExternalId}`,
);
}
const originAccount = primaryUser.account;
// 運用上、アカウントがいないことはあり得ないが、プログラム上発生しうるのでエラーとして処理
if (!originAccount) {
throw new Error(`Account is Not Found. id: ${primaryUser.account_id}`);
}
// 代行操作の許可の有無を返却
return originAccount.delegation_permission;
});
}
} }