From d2973012120f995979419f572a75a4baa5f07750 Mon Sep 17 00:00:00 2001 From: "maruyama.t" Date: Thu, 11 May 2023 07:45:30 +0000 Subject: [PATCH] =?UTF-8?q?Merged=20PR=2082:=20API=E5=AE=9F=E8=A3=85?= =?UTF-8?q?=EF=BC=88=E3=83=A1=E3=83=BC=E3=83=AB=E8=AA=8D=E8=A8=BC=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 概要 [Task1594: API実装(メール認証)](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/fa4924a4-d079-4fab-9fb5-a9a11eb205f0/_workitems/edit/1594) - メール認証APIを作成 - src/api/common/にpasswordを追加(ランダムパスワード発行ロジック) - src/fuatures/gateway/adb2c.service.tsにユーザのパスワードを変更するメソッドchangePasswordを追加 - user.service.spec.tsにメール認証と仮パスワード発行のテストケースを追加 - 影響範囲 (user.service.spec.tsで行っていた既存のテストケース) ## レビューポイント - commonにpasswordを追加したが、配置として適切かどうか - user.service.tsのエラー発生時のロジックが十分であるか ## 動作確認状況 - ローカルで確認 --- dictation_server/src/common/password/index.ts | 3 + .../src/common/password/password.ts | 45 ++++++ .../features/users/test/users.service.mock.ts | 147 +++++++++++++++++- .../src/features/users/users.controller.ts | 1 + .../src/features/users/users.module.ts | 11 +- .../src/features/users/users.service.spec.ts | 143 ++++++++++++++++- .../src/features/users/users.service.ts | 70 +++++++++ .../src/gateways/adb2c/adb2c.service.ts | 28 ++++ 8 files changed, 444 insertions(+), 4 deletions(-) create mode 100644 dictation_server/src/common/password/index.ts create mode 100644 dictation_server/src/common/password/password.ts diff --git a/dictation_server/src/common/password/index.ts b/dictation_server/src/common/password/index.ts new file mode 100644 index 0000000..8960a26 --- /dev/null +++ b/dictation_server/src/common/password/index.ts @@ -0,0 +1,3 @@ +import { makePassword } from './password'; + +export { makePassword }; diff --git a/dictation_server/src/common/password/password.ts b/dictation_server/src/common/password/password.ts new file mode 100644 index 0000000..b54580f --- /dev/null +++ b/dictation_server/src/common/password/password.ts @@ -0,0 +1,45 @@ +export const makePassword = (): string => { + // パスワードの文字数を決定 + const passLength = 8; + + // パスワードに使用可能な文字を決定(今回はアルファベットの大文字と小文字 + 数字 + symbolsの記号) + const lowerCase = 'abcdefghijklmnopqrstuvwxyz'; + const upperCase = lowerCase.toLocaleUpperCase(); + const numbers = '0123456789'; + const symbols = "!@#$%^&*()+-={}[]:;'<>,./?_∼\\"; + const chars = lowerCase + upperCase + numbers + symbols; + + // 英字の大文字、英字の小文字、アラビア数字、記号(!@#$%^&*()+-={}[]:;'<>,./?_∼\)から2種類以上組み合わせ + const charaTypePattern = + /^((?=.*[a-z])(?=.*[A-Z])|(?=.*[a-z])(?=.*[\d])|(?=.*[a-z])(?=.*[!@#$%^&*()+-={}:;'<>,./?_~[\\\]])|(?=.*[A-Z])(?=.*[\d])|(?=.*[A-Z])(?=.*[!@#$%^&*()+-={}:;'<>,./?_~[\\\]])|(?=.*[\d])(?=.*[!@#$%^&*()+-={}:;'<>,./?_~[\\\]]))[a-zA-Z\d!@#$%^&*()+-={}:;'<>,./?_~[\\\]]/; + + // 同じ文字の3連続は禁止 + const repeatPattern = /(.)\1{2,}/; + + // 特定文字列は禁止 + const unavailableCharaPattern = + /password|passwd|test|admin|administrator|sysadmin|0123|1234|2345|3456|4567|5678|6789|9876|8765|7654|6543|5432|4321|3210/; + + // autoGeneratedPasswordが以上の条件を満たせばvalidがtrueになる + let valid = false; + let autoGeneratedPassword: string; + + while (!valid) { + autoGeneratedPassword = ''; + // パスワードをランダムに決定 + while (autoGeneratedPassword.length < passLength) { + // 上で決定したcharsの中からランダムに1文字ずつ追加 + const index = Math.floor(Math.random() * chars.length); + autoGeneratedPassword += chars[index]; + } + + // パスワードが上で決定した条件をすべて満たしているかチェック + // 条件を満たすまでループ + valid = + autoGeneratedPassword.length == passLength && + charaTypePattern.test(autoGeneratedPassword) && + !repeatPattern.test(autoGeneratedPassword) && + !unavailableCharaPattern.test(autoGeneratedPassword); + } + return autoGeneratedPassword; +}; diff --git a/dictation_server/src/features/users/test/users.service.mock.ts b/dictation_server/src/features/users/test/users.service.mock.ts index 0266c59..5f081e7 100644 --- a/dictation_server/src/features/users/test/users.service.mock.ts +++ b/dictation_server/src/features/users/test/users.service.mock.ts @@ -2,21 +2,113 @@ import { Test, TestingModule } from '@nestjs/testing'; import { UsersService } from '../users.service'; import { UsersRepositoryService } from '../../../repositories/users/users.repository.service'; import { CryptoService } from '../../../gateways/crypto/crypto.service'; +import { AdB2cService } from '../../../gateways/adb2c/adb2c.service'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { SendGridService } from '../../../gateways/sendgrid/sendgrid.service'; +import { JwkSignKey, B2cMetadata } from '../../../common/token'; +import { User } from 'src/repositories/users/entity/user.entity'; export type CryptoMockValue = { getPublicKey: string | Error; + getPrivateKey: string | Error; }; export type UsersRepositoryMockValue = { updateUserVerified: undefined | Error; + findUserById: User | Error; +}; + +export type AdB2cMockValue = { + getMetaData: B2cMetadata | Error; + getSignKeySets: JwkSignKey[] | Error; + changePassword: { sub: string } | Error; +}; + +export type SendGridMockValue = { + createMailContentFromEmailConfirm: { + subject: string; + text: string; + html: string; + }; + sendMail: undefined | Error; +}; + +export const makeDefaultAdB2cMockValue = (): AdB2cMockValue => { + return { + getMetaData: { + issuer: 'issuer', + }, + getSignKeySets: [ + { + kid: 'kid', + nbf: 1111111111, + use: 'sig', + kty: 'RSA', + e: 'e', + n: 'n', + }, + ], + changePassword: { + sub: 'TEST9999', + }, + }; +}; + +export const makeSendGridServiceMock = (value: SendGridMockValue) => { + const { createMailContentFromEmailConfirm, sendMail } = value; + return { + createMailContentFromEmailConfirm: + createMailContentFromEmailConfirm instanceof Error + ? jest + .fn, []>() + .mockRejectedValue(createMailContentFromEmailConfirm) + : jest + .fn, []>() + .mockResolvedValue(createMailContentFromEmailConfirm), + sendMail: + sendMail instanceof Error + ? jest.fn, []>().mockRejectedValue(sendMail) + : jest.fn, []>().mockResolvedValue(sendMail), + }; +}; + +export const makeAdB2cServiceMock = (value: AdB2cMockValue) => { + const { getMetaData, getSignKeySets, changePassword } = value; + + return { + getMetaData: + getMetaData instanceof Error + ? jest.fn, []>().mockRejectedValue(getMetaData) + : jest.fn, []>().mockResolvedValue(getMetaData), + getSignKeySets: + getSignKeySets instanceof Error + ? jest.fn, []>().mockRejectedValue(getSignKeySets) + : jest + .fn, []>() + .mockResolvedValue(getSignKeySets), + changePassword: + changePassword instanceof Error + ? jest.fn, []>().mockRejectedValue(changePassword) + : jest + .fn, []>() + .mockResolvedValue(changePassword), + }; }; export const makeUsersServiceMock = async ( cryptoMockValue: CryptoMockValue, usersRepositoryMockValue: UsersRepositoryMockValue, + adB2cMockValue: AdB2cMockValue, + sendGridMockValue: SendGridMockValue, ): Promise => { const module: TestingModule = await Test.createTestingModule({ providers: [UsersService], + imports: [ + ConfigModule.forRoot({ + ignoreEnvFile: true, + ignoreEnvVars: true, + }), + ], }) .useMocker((token) => { switch (token) { @@ -24,6 +116,12 @@ export const makeUsersServiceMock = async ( return makeCryptoServiceMock(cryptoMockValue); case UsersRepositoryService: return makeUsersRepositoryMock(usersRepositoryMockValue); + case AdB2cService: + return makeAdB2cServiceMock(adB2cMockValue); + case ConfigService: + return {}; + case SendGridService: + return makeSendGridServiceMock(sendGridMockValue); } }) .compile(); @@ -32,24 +130,33 @@ export const makeUsersServiceMock = async ( }; export const makeCryptoServiceMock = (value: CryptoMockValue) => { - const { getPublicKey } = value; + const { getPublicKey, getPrivateKey } = value; return { getPublicKey: getPublicKey instanceof Error ? jest.fn, []>().mockRejectedValue(getPublicKey) : jest.fn, []>().mockResolvedValue(getPublicKey), + getPrivateKey: + getPrivateKey instanceof Error + ? jest.fn, []>().mockRejectedValue(getPrivateKey) + : jest.fn, []>().mockResolvedValue(getPrivateKey), }; }; export const makeUsersRepositoryMock = (value: UsersRepositoryMockValue) => { const { updateUserVerified } = value; + const { findUserById } = value; return { updateUserVerified: updateUserVerified instanceof Error ? jest.fn, []>().mockRejectedValue(updateUserVerified) : jest.fn, []>().mockResolvedValue(updateUserVerified), + findUserById: + findUserById instanceof Error + ? jest.fn, []>().mockRejectedValue(findUserById) + : jest.fn, []>().mockResolvedValue(findUserById), }; }; @@ -66,6 +173,36 @@ export const makeDefaultCryptoMockValue = (): CryptoMockValue => { 'OQIDAQAB', '-----END PUBLIC KEY-----', ].join('\n'), + // XXX メール認証API実装時に用意したが使っていないので不要なら削除 + getPrivateKey: [ + '-----BEGIN RSA PRIVATE KEY-----', + 'MIIEowIBAAKCAQEA5IZZNgDew9eGmuFTezwdHYLSaJvUPPIKYoiOeVLD1paWNI51', + '7Vkaoh0ngprcKOdv6T1N07V4igK7mOim2zY3yCTR6wcWR3PfFJrl9vh5SOo79koZ', + 'oJb27YiM4jtxfx2dezzp0T2GoNR5rRolPUbWFJXnDe0DVXYXpJLb4LAlF2XAyYX0', + 'SYKUVUsJnzm5k4xbXtnwPwVbpm0EdswBE6qSfiL9zWk9dvHoKzSnfSDzDFoFcEoV', + 'chawzYXf/MM1YR4wo5XyzECc6Q5Ah4z522//mBNNaDHv83Yuw3mGShT73iJ0JQdk', + 'Tturshv2Ecma38r6ftrIwNYXw4VVatJM8+GOOQIDAQABAoIBADrwp7u097+dK/tw', + 'WD61n3DIGAqg/lmFt8X4IH8MKLSE/FKr16CS1bqwOEuIM3ZdUtDeXd9Xs7IsyEPE', + '5ZwuXK7DSF0M4+Mj8Ip49Q0Aww9aUoLQU9HGfgN/r4599GTrt31clZXA/6Mlighq', + 'cOZgCcEfdItz8OMu5SQuOIW4CKkCuaWnPOP26UqZocaXNZfpZH0iFLATMMH/TT8x', + 'ay9ToHTQYE17ijdQ/EOLSwoeDV1CU1CIE3P4YfLJjvpKptly5dTevriHEzBi70Jx', + '/KEPUn9Jj2gZafrUxRVhmMbm1zkeYxL3gsqRuTzRjEeeILuZhSJyCkQZyUNARxsg', + 'QY4DZfECgYEA+YLKUtmYTx60FS6DJ4s31TAsXY8kwhq/lB9E3GBZKDd0DPayXEeK', + '4UWRQDTT6MI6fedW69FOZJ5sFLp8HQpcssb4Weq9PCpDhNTx8MCbdH3Um5QR3vfW', + 'aKq/1XM8MDUnx5XcNYd87Aw3azvJAvOPr69as8IPnj6sKaRR9uQjbYUCgYEA6nfV', + '5j0qmn0EJXZJblk4mvvjLLoWSs17j9YlrZJlJxXMDFRYtgnelv73xMxOMvcGoxn5', + 'ifs7dpaM2x5EmA6jVU5sYaB/beZGEPWqPYGyjIwXPvUGAAv8Gbnvpp+xlSco/Dum', + 'Iq0w+43ry5/xWh6CjfrvKV0J2bDOiJwPEdu/8iUCgYEAnBBSvL+dpN9vhFAzeOh7', + 'Y71eAqcmNsLEUcG9MJqTKbSFwhYMOewF0iHRWHeylEPokhfBJn8kqYrtz4lVWFTC', + '5o/Nh3BsLNXCpbMMIapXkeWiti1HgE9ErPMgSkJpwz18RDpYIqM8X+jEQS6D7HSr', + 'yxfDg+w+GJza0rEVE3hfMIECgYBw+KZ2VfhmEWBjEHhXE+QjQMR3s320MwebCUqE', + 'NCpKx8TWF/naVC0MwfLtvqbbBY0MHyLN6d//xpA9r3rLbRojqzKrY2KiuDYAS+3n', + 'zssRzxoQOozWju+8EYu30/ADdqfXyIHG6X3VZs87AGiQzGyJLmP3oR1y5y7MQa09', + 'JI16hQKBgHK5uwJhGa281Oo5/FwQ3uYLymbNwSGrsOJXiEu2XwJEXwVi2ELOKh4/', + '03pBk3Kva3fIwEK+vCzDNnxShIQqBE76/2I1K1whOfoUehhYvKHGaXl2j70Zz9Ks', + 'rkGW1cx7p+yDqATDrwHBHTHFh5bUTTn8dN40n0e0W/llurpbBkJM', + '-----END RSA PRIVATE KEY-----', + ].join('\n'), }; }; @@ -74,5 +211,13 @@ export const makeDefaultUsersRepositoryMockValue = (): UsersRepositoryMockValue => { return { updateUserVerified: undefined, + findUserById: undefined, }; }; + +export const makeSendGridServiceMockValue = (): SendGridMockValue => { + return { + createMailContentFromEmailConfirm: { subject: '', text: '', html: '' }, + sendMail: undefined, + }; +}; diff --git a/dictation_server/src/features/users/users.controller.ts b/dictation_server/src/features/users/users.controller.ts index c5cab28..1cba98a 100644 --- a/dictation_server/src/features/users/users.controller.ts +++ b/dictation_server/src/features/users/users.controller.ts @@ -65,6 +65,7 @@ export class UsersController { @Body() body: ConfirmRequest, ): Promise { console.log(body); + await this.usersService.confirmUserAndInitPassword(body.token); return {}; } diff --git a/dictation_server/src/features/users/users.module.ts b/dictation_server/src/features/users/users.module.ts index f52b278..e907e15 100644 --- a/dictation_server/src/features/users/users.module.ts +++ b/dictation_server/src/features/users/users.module.ts @@ -3,9 +3,18 @@ import { CryptoModule } from '../../gateways/crypto/crypto.module'; import { UsersController } from './users.controller'; import { UsersService } from './users.service'; import { UsersRepositoryModule } from '../../repositories/users/users.repository.module'; +import { AdB2cModule } from '../../gateways/adb2c/adb2c.module'; +import { SendGridModule } from '../../gateways/sendgrid/sendgrid.module'; +import { ConfigModule } from '@nestjs/config'; @Module({ - imports: [CryptoModule, UsersRepositoryModule], + imports: [ + CryptoModule, + UsersRepositoryModule, + AdB2cModule, + SendGridModule, + ConfigModule, + ], controllers: [UsersController], providers: [UsersService], }) diff --git a/dictation_server/src/features/users/users.service.spec.ts b/dictation_server/src/features/users/users.service.spec.ts index e27bc3f..90132aa 100644 --- a/dictation_server/src/features/users/users.service.spec.ts +++ b/dictation_server/src/features/users/users.service.spec.ts @@ -4,6 +4,8 @@ import { makeDefaultCryptoMockValue, makeDefaultUsersRepositoryMockValue, makeUsersServiceMock, + makeDefaultAdB2cMockValue, + makeSendGridServiceMockValue, } from './test/users.service.mock'; import { EmailAlreadyVerifiedError } from '../../repositories/users/users.repository.service'; @@ -11,21 +13,58 @@ describe('UsersService', () => { it('ユーザの仮登録時に払い出されるトークンにより、未認証のユーザが認証済みになる', async () => { const cryptoMockValue = makeDefaultCryptoMockValue(); const usersRepositoryMockValue = makeDefaultUsersRepositoryMockValue(); + const adb2cParam = makeDefaultAdB2cMockValue(); + const sendGridMockValue = makeSendGridServiceMockValue(); const service = await makeUsersServiceMock( cryptoMockValue, usersRepositoryMockValue, + adb2cParam, + sendGridMockValue, ); const token = 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhY2NvdW50SWQiOjEsInVzZXJJZCI6MiwiZW1haWwiOiJ4eHhAeHh4Lnh4eCIsImlhdCI6MTAwMDAwMDAwMCwiZXhwIjo5MDAwMDAwMDAwfQ.26L6BdNg-3TbyKT62PswlJ6RPMkcTtHzlDXW2Uo9XbMPVSrl2ObcuS6EcXjFFN2DEfNTKbqX_zevIWMpHOAdLNgGhk528nLrBrNvPASqtTjvW9muxMXpjUdjRVkmVbOylBHWW3YpWL9JEbJQ7rAzWDfaIdPhMovdaxumnZt_UwnlnrdaVPLACW7tkH_laEcAU507iSiM4mqxxG8FuTs34t6PEdwRuzZAQPN2IOPYNSvGNdJYryPacSeSNZ_z1xeBYXLOLQfOBZzyTReYDOhXdikhrNUbxjgnZQlSXBCVMlZ9PH42bHfp-LJIeJzW0yqnF6oLklvJP-fo8eW0k5iDOw'; expect(await service.confirmUser(token)).toEqual(undefined); }); - it('トークンの形式が不正な場合、形式不正エラーとなる。', async () => { + it('ユーザーが発行されたパスワードでログインできるようにする', async () => { const cryptoMockValue = makeDefaultCryptoMockValue(); const usersRepositoryMockValue = makeDefaultUsersRepositoryMockValue(); + usersRepositoryMockValue.findUserById = { + id: 1, + external_id: 'TEST9999', + account_id: 1, + role: 'None', + accepted_terms_version: 'string', + email_verified: false, + created_by: 'string;', + created_at: new Date(), + updated_by: 'string;', + updated_at: new Date(), + }; + const adb2cParam = makeDefaultAdB2cMockValue(); + const sendGridMockValue = makeSendGridServiceMockValue(); const service = await makeUsersServiceMock( cryptoMockValue, usersRepositoryMockValue, + adb2cParam, + sendGridMockValue, + ); + + const token = + 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhY2NvdW50SWQiOjEsInVzZXJJZCI6MiwiZW1haWwiOiJ4eHhAeHh4Lnh4eCIsImlhdCI6MTAwMDAwMDAwMCwiZXhwIjo5MDAwMDAwMDAwfQ.26L6BdNg-3TbyKT62PswlJ6RPMkcTtHzlDXW2Uo9XbMPVSrl2ObcuS6EcXjFFN2DEfNTKbqX_zevIWMpHOAdLNgGhk528nLrBrNvPASqtTjvW9muxMXpjUdjRVkmVbOylBHWW3YpWL9JEbJQ7rAzWDfaIdPhMovdaxumnZt_UwnlnrdaVPLACW7tkH_laEcAU507iSiM4mqxxG8FuTs34t6PEdwRuzZAQPN2IOPYNSvGNdJYryPacSeSNZ_z1xeBYXLOLQfOBZzyTReYDOhXdikhrNUbxjgnZQlSXBCVMlZ9PH42bHfp-LJIeJzW0yqnF6oLklvJP-fo8eW0k5iDOw'; + expect(await service.confirmUserAndInitPassword(token)).toEqual(undefined); + }); + + it('トークンの形式が不正な場合、形式不正エラーとなる。', async () => { + const cryptoMockValue = makeDefaultCryptoMockValue(); + const usersRepositoryMockValue = makeDefaultUsersRepositoryMockValue(); + const adb2cParam = makeDefaultAdB2cMockValue(); + const sendGridMockValue = makeSendGridServiceMockValue(); + const service = await makeUsersServiceMock( + cryptoMockValue, + usersRepositoryMockValue, + adb2cParam, + sendGridMockValue, ); const token = 'invalid.id.token'; await expect(service.confirmUser(token)).rejects.toEqual( @@ -33,16 +72,47 @@ describe('UsersService', () => { ); }); + it('トークンの形式が不正な場合、形式不正エラーとなる。(メール認証API)', async () => { + const cryptoMockValue = makeDefaultCryptoMockValue(); + const usersRepositoryMockValue = makeDefaultUsersRepositoryMockValue(); + usersRepositoryMockValue.findUserById = { + id: 1, + external_id: 'TEST9999', + account_id: 1, + role: 'None', + accepted_terms_version: 'string', + email_verified: false, + created_by: 'string;', + created_at: new Date(), + updated_by: 'string;', + updated_at: new Date(), + }; + const adb2cParam = makeDefaultAdB2cMockValue(); + const sendGridMockValue = makeSendGridServiceMockValue(); + const service = await makeUsersServiceMock( + cryptoMockValue, + usersRepositoryMockValue, + adb2cParam, + sendGridMockValue, + ); + const token = 'invalid.id.token'; + await expect(service.confirmUserAndInitPassword(token)).rejects.toEqual( + new HttpException(makeErrorResponse('E000101'), HttpStatus.BAD_REQUEST), + ); + }); it('ユーザが既に認証済みだった場合、認証済みユーザエラーとなる。', async () => { const cryptoMockValue = makeDefaultCryptoMockValue(); const usersRepositoryMockValue = makeDefaultUsersRepositoryMockValue(); - + const adb2cParam = makeDefaultAdB2cMockValue(); + const sendGridMockValue = makeSendGridServiceMockValue(); usersRepositoryMockValue.updateUserVerified = new EmailAlreadyVerifiedError(); const service = await makeUsersServiceMock( cryptoMockValue, usersRepositoryMockValue, + adb2cParam, + sendGridMockValue, ); const token = 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhY2NvdW50SWQiOjEsInVzZXJJZCI6MiwiZW1haWwiOiJ4eHhAeHh4Lnh4eCIsImlhdCI6MTAwMDAwMDAwMCwiZXhwIjo5MDAwMDAwMDAwfQ.26L6BdNg-3TbyKT62PswlJ6RPMkcTtHzlDXW2Uo9XbMPVSrl2ObcuS6EcXjFFN2DEfNTKbqX_zevIWMpHOAdLNgGhk528nLrBrNvPASqtTjvW9muxMXpjUdjRVkmVbOylBHWW3YpWL9JEbJQ7rAzWDfaIdPhMovdaxumnZt_UwnlnrdaVPLACW7tkH_laEcAU507iSiM4mqxxG8FuTs34t6PEdwRuzZAQPN2IOPYNSvGNdJYryPacSeSNZ_z1xeBYXLOLQfOBZzyTReYDOhXdikhrNUbxjgnZQlSXBCVMlZ9PH42bHfp-LJIeJzW0yqnF6oLklvJP-fo8eW0k5iDOw'; @@ -50,15 +120,50 @@ describe('UsersService', () => { new HttpException(makeErrorResponse('E010202'), HttpStatus.BAD_REQUEST), ); }); + it('ユーザが既に認証済みだった場合、認証済みユーザエラーとなる。(メール認証API)', async () => { + const cryptoMockValue = makeDefaultCryptoMockValue(); + const usersRepositoryMockValue = makeDefaultUsersRepositoryMockValue(); + usersRepositoryMockValue.findUserById = { + id: 1, + external_id: 'TEST9999', + account_id: 1, + role: 'None', + accepted_terms_version: 'string', + email_verified: true, + created_by: 'string;', + created_at: new Date(), + updated_by: 'string;', + updated_at: new Date(), + }; + const adb2cParam = makeDefaultAdB2cMockValue(); + const sendGridMockValue = makeSendGridServiceMockValue(); + usersRepositoryMockValue.updateUserVerified = + new EmailAlreadyVerifiedError(); + const service = await makeUsersServiceMock( + cryptoMockValue, + usersRepositoryMockValue, + adb2cParam, + sendGridMockValue, + ); + const token = + 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhY2NvdW50SWQiOjEsInVzZXJJZCI6MiwiZW1haWwiOiJ4eHhAeHh4Lnh4eCIsImlhdCI6MTAwMDAwMDAwMCwiZXhwIjo5MDAwMDAwMDAwfQ.26L6BdNg-3TbyKT62PswlJ6RPMkcTtHzlDXW2Uo9XbMPVSrl2ObcuS6EcXjFFN2DEfNTKbqX_zevIWMpHOAdLNgGhk528nLrBrNvPASqtTjvW9muxMXpjUdjRVkmVbOylBHWW3YpWL9JEbJQ7rAzWDfaIdPhMovdaxumnZt_UwnlnrdaVPLACW7tkH_laEcAU507iSiM4mqxxG8FuTs34t6PEdwRuzZAQPN2IOPYNSvGNdJYryPacSeSNZ_z1xeBYXLOLQfOBZzyTReYDOhXdikhrNUbxjgnZQlSXBCVMlZ9PH42bHfp-LJIeJzW0yqnF6oLklvJP-fo8eW0k5iDOw'; + await expect(service.confirmUserAndInitPassword(token)).rejects.toEqual( + new HttpException(makeErrorResponse('E010202'), HttpStatus.BAD_REQUEST), + ); + }); it('DBネットワークエラーとなる場合、エラーとなる。', async () => { const cryptoMockValue = makeDefaultCryptoMockValue(); const usersRepositoryMockValue = makeDefaultUsersRepositoryMockValue(); + const adb2cParam = makeDefaultAdB2cMockValue(); + const sendGridMockValue = makeSendGridServiceMockValue(); usersRepositoryMockValue.updateUserVerified = new Error('DB error'); const service = await makeUsersServiceMock( cryptoMockValue, usersRepositoryMockValue, + adb2cParam, + sendGridMockValue, ); const token = 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhY2NvdW50SWQiOjEsInVzZXJJZCI6MiwiZW1haWwiOiJ4eHhAeHh4Lnh4eCIsImlhdCI6MTAwMDAwMDAwMCwiZXhwIjo5MDAwMDAwMDAwfQ.26L6BdNg-3TbyKT62PswlJ6RPMkcTtHzlDXW2Uo9XbMPVSrl2ObcuS6EcXjFFN2DEfNTKbqX_zevIWMpHOAdLNgGhk528nLrBrNvPASqtTjvW9muxMXpjUdjRVkmVbOylBHWW3YpWL9JEbJQ7rAzWDfaIdPhMovdaxumnZt_UwnlnrdaVPLACW7tkH_laEcAU507iSiM4mqxxG8FuTs34t6PEdwRuzZAQPN2IOPYNSvGNdJYryPacSeSNZ_z1xeBYXLOLQfOBZzyTReYDOhXdikhrNUbxjgnZQlSXBCVMlZ9PH42bHfp-LJIeJzW0yqnF6oLklvJP-fo8eW0k5iDOw'; @@ -69,4 +174,38 @@ describe('UsersService', () => { ), ); }); + it('DBネットワークエラーとなる場合、エラーとなる。(メール認証API)', async () => { + const cryptoMockValue = makeDefaultCryptoMockValue(); + const usersRepositoryMockValue = makeDefaultUsersRepositoryMockValue(); + usersRepositoryMockValue.findUserById = { + id: 1, + external_id: 'TEST9999', + account_id: 1, + role: 'None', + accepted_terms_version: 'string', + email_verified: false, + created_by: 'string;', + created_at: new Date(), + updated_by: 'string;', + updated_at: new Date(), + }; + const adb2cParam = makeDefaultAdB2cMockValue(); + const sendGridMockValue = makeSendGridServiceMockValue(); + usersRepositoryMockValue.updateUserVerified = new Error('DB error'); + + const service = await makeUsersServiceMock( + cryptoMockValue, + usersRepositoryMockValue, + adb2cParam, + sendGridMockValue, + ); + const token = + 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhY2NvdW50SWQiOjEsInVzZXJJZCI6MiwiZW1haWwiOiJ4eHhAeHh4Lnh4eCIsImlhdCI6MTAwMDAwMDAwMCwiZXhwIjo5MDAwMDAwMDAwfQ.26L6BdNg-3TbyKT62PswlJ6RPMkcTtHzlDXW2Uo9XbMPVSrl2ObcuS6EcXjFFN2DEfNTKbqX_zevIWMpHOAdLNgGhk528nLrBrNvPASqtTjvW9muxMXpjUdjRVkmVbOylBHWW3YpWL9JEbJQ7rAzWDfaIdPhMovdaxumnZt_UwnlnrdaVPLACW7tkH_laEcAU507iSiM4mqxxG8FuTs34t6PEdwRuzZAQPN2IOPYNSvGNdJYryPacSeSNZ_z1xeBYXLOLQfOBZzyTReYDOhXdikhrNUbxjgnZQlSXBCVMlZ9PH42bHfp-LJIeJzW0yqnF6oLklvJP-fo8eW0k5iDOw'; + await expect(service.confirmUserAndInitPassword(token)).rejects.toEqual( + new HttpException( + makeErrorResponse('E009999'), + HttpStatus.INTERNAL_SERVER_ERROR, + ), + ); + }); }); diff --git a/dictation_server/src/features/users/users.service.ts b/dictation_server/src/features/users/users.service.ts index 9a5b7c7..ffc8ffa 100644 --- a/dictation_server/src/features/users/users.service.ts +++ b/dictation_server/src/features/users/users.service.ts @@ -6,12 +6,19 @@ import { EmailAlreadyVerifiedError, } from '../../repositories/users/users.repository.service'; import { makeErrorResponse } from '../../common/error/makeErrorResponse'; +import { makePassword } from '../../common/password/password'; +import { AdB2cService } from '../../gateways/adb2c/adb2c.service'; +import { ConfigService } from '@nestjs/config'; +import { SendGridService } from '../../gateways/sendgrid/sendgrid.service'; @Injectable() export class UsersService { constructor( private readonly cryptoService: CryptoService, private readonly usersRepository: UsersRepositoryService, + private readonly adB2cService: AdB2cService, + private readonly configService: ConfigService, + private readonly sendgridService: SendGridService, ) {} private readonly logger = new Logger(UsersService.name); @@ -64,4 +71,67 @@ export class UsersService { ); } } + + /** + * confirm User And Init Password + * @param token ユーザ仮登録時に払いだされるトークン + */ + async confirmUserAndInitPassword(token: string): Promise { + this.logger.log(`[IN] ${this.confirmUserAndInitPassword.name}`); + + const pubKey = await this.cryptoService.getPublicKey(); + const decodedToken = verify<{ + accountId: number; + userId: number; + email: string; + }>(token, pubKey); + if (isVerifyError(decodedToken)) { + throw new HttpException( + makeErrorResponse('E000101'), + HttpStatus.BAD_REQUEST, + ); + } + + // ランダムなパスワードを生成する + const ramdomPassword = makePassword(); + const { userId, email } = decodedToken; + + try { + // ユーザー情報からAzure AD B2CのIDを特定する + const user = await this.usersRepository.findUserById(userId); + const extarnalId = user.external_id; + // ユーザを認証済みにする + await this.usersRepository.updateUserVerified(userId); + // パスワードを変更する + await this.adB2cService.changePassword(extarnalId, ramdomPassword); + // メールの送信元を取得 + const from = this.configService.get('MAIL_FROM') ?? ''; + + // XXX ODMS側が正式にメッセージを決めるまで仮のメール内容とする + const subject = 'A temporary password has been issued.'; + const text = 'temporary password: ' + ramdomPassword; + const domains = this.configService.get('APP_DOMAIN'); + const path = '/'; + const html = `

OMDS TOP PAGE URL.

${domains}${path}}"`; + + // メールを送信 + await this.sendgridService.sendMail(email, from, subject, text, html); + } catch (e) { + this.logger.error(`error=${e}`); + if (e instanceof Error) { + switch (e.constructor) { + case EmailAlreadyVerifiedError: + throw new HttpException( + makeErrorResponse('E010202'), + HttpStatus.BAD_REQUEST, + ); + default: + throw new HttpException( + makeErrorResponse('E009999'), + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + } + } } diff --git a/dictation_server/src/gateways/adb2c/adb2c.service.ts b/dictation_server/src/gateways/adb2c/adb2c.service.ts index 2e6b297..beaafe3 100644 --- a/dictation_server/src/gateways/adb2c/adb2c.service.ts +++ b/dictation_server/src/gateways/adb2c/adb2c.service.ts @@ -137,4 +137,32 @@ export class AdB2cService { this.logger.log(`[OUT] ${this.getSignKeySets.name}`); } } + + /** + * change Password 特定のADB2Cのユーザのパスワードを変更する + * @param externalId ユーザ情報 + * @param password パスワード + */ + async changePassword( + externalId: string, + password: string, + ): Promise<{ sub: string }> { + this.logger.log(`[IN] ${this.changePassword.name}`); + try { + // ADB2Cのユーザのパスワードを変更する + const changeUser = await this.graphClient + .api(`/users/${externalId}`) + .patch({ + passwordProfile: { + password: password, + }, + }); + return { sub: changeUser.id }; + } catch (e) { + this.logger.error(e); + throw e; + } finally { + this.logger.log(`[OUT] ${this.changePassword.name}`); + } + } }