diff --git a/dictation_server/src/features/auth/auth.controller.ts b/dictation_server/src/features/auth/auth.controller.ts index 0418584..a07b666 100644 --- a/dictation_server/src/features/auth/auth.controller.ts +++ b/dictation_server/src/features/auth/auth.controller.ts @@ -32,7 +32,7 @@ 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'; +import { AccessToken, RefreshToken } from '../../common/token'; @ApiTags('auth') @Controller('auth') @@ -248,9 +248,23 @@ export class AuthController { 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 }; } } diff --git a/dictation_server/src/features/auth/auth.service.spec.ts b/dictation_server/src/features/auth/auth.service.spec.ts index bcb5324..1be5e40 100644 --- a/dictation_server/src/features/auth/auth.service.spec.ts +++ b/dictation_server/src/features/auth/auth.service.spec.ts @@ -9,9 +9,13 @@ import { import { DataSource } from 'typeorm'; import { makeContext } from '../../common/log'; 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 { createTermInfo } from './test/utility'; +import { + createTermInfo, + deleteAccount, + updateAccountDelegationPermission, +} from './test/utility'; import { v4 as uuidv4 } from 'uuid'; import { TIERS, USER_ROLES } from '../../constants'; 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); + 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(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(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); + 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(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); + 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(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 = { exp: 9000000000, nbf: 1000000000, diff --git a/dictation_server/src/features/auth/auth.service.ts b/dictation_server/src/features/auth/auth.service.ts index 3e2a9cc..23ba89b 100644 --- a/dictation_server/src/features/auth/auth.service.ts +++ b/dictation_server/src/features/auth/auth.service.ts @@ -25,8 +25,15 @@ import { AccountNotFoundError, AdminUserNotFoundError, } from '../../repositories/accounts/errors/types'; -import { DelegationNotAllowedError } from '../../repositories/users/errors/types'; -import { RoleUnexpectedError, TierUnexpectedError } from './errors/types'; +import { + DelegationNotAllowedError, + UserNotFoundError, +} from '../../repositories/users/errors/types'; +import { + InvalidTokenFormatError, + RoleUnexpectedError, + TierUnexpectedError, +} from './errors/types'; @Injectable() export class AuthService { @@ -328,6 +335,116 @@ export class AuthService { 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 { + 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, 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( + { + 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 * @param token diff --git a/dictation_server/src/features/auth/errors/types.ts b/dictation_server/src/features/auth/errors/types.ts index 5c12d36..969d13a 100644 --- a/dictation_server/src/features/auth/errors/types.ts +++ b/dictation_server/src/features/auth/errors/types.ts @@ -2,3 +2,5 @@ export class RoleUnexpectedError extends Error {} // Tier範囲想定外エラー export class TierUnexpectedError extends Error {} +// トークン形式不正エラー +export class InvalidTokenFormatError extends Error {} diff --git a/dictation_server/src/features/auth/test/utility.ts b/dictation_server/src/features/auth/test/utility.ts index c5c2c51..fbbc0e4 100644 --- a/dictation_server/src/features/auth/test/utility.ts +++ b/dictation_server/src/features/auth/test/utility.ts @@ -1,5 +1,7 @@ import { DataSource } from 'typeorm'; 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 ( datasource: DataSource, @@ -16,3 +18,21 @@ export const createTermInfo = async ( }); identifiers.pop() as Term; }; + +export const updateAccountDelegationPermission = async ( + dataSource: DataSource, + id: number, + delegationPermission: boolean, +): Promise => { + await dataSource + .getRepository(Account) + .update({ id: id }, { delegation_permission: delegationPermission }); +}; + +export const deleteAccount = async ( + dataSource: DataSource, + id: number, +): Promise => { + await dataSource.getRepository(User).delete({ account_id: id }); + await dataSource.getRepository(Account).delete({ id: id }); +}; diff --git a/dictation_server/src/features/files/files.service.ts b/dictation_server/src/features/files/files.service.ts index 96ac698..44dff8f 100644 --- a/dictation_server/src/features/files/files.service.ts +++ b/dictation_server/src/features/files/files.service.ts @@ -1,6 +1,5 @@ import { HttpException, HttpStatus, Injectable, Logger } from '@nestjs/common'; import { makeErrorResponse } from '../../common/error/makeErrorResponse'; -import { AccessToken } from '../../common/token'; import { UsersRepositoryService } from '../../repositories/users/users.repository.service'; import { TasksRepositoryService } from '../../repositories/tasks/tasks.repository.service'; import { BlobstorageService } from '../../gateways/blobstorage/blobstorage.service'; diff --git a/dictation_server/src/repositories/users/users.repository.service.ts b/dictation_server/src/repositories/users/users.repository.service.ts index 7f92ae3..8a43aad 100644 --- a/dictation_server/src/repositories/users/users.repository.service.ts +++ b/dictation_server/src/repositories/users/users.repository.service.ts @@ -601,4 +601,47 @@ export class UsersRepositoryService { return primaryUser; }); } + + /** + * 代行操作対象のユーザーの所属するアカウントの代行操作が許可されているか + * @param delegateAccountId 代行操作者のアカウントID + * @param originAccountId 代行操作対象のアカウントID + * @returns delegate accounts + */ + async isAllowDelegationPermission( + delegateAccountId: number, + originUserExternalId: string, + ): Promise { + 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; + }); + } }