Merged PR 82: API実装(メール認証)

## 概要
[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のエラー発生時のロジックが十分であるか

## 動作確認状況
- ローカルで確認
This commit is contained in:
maruyama.t 2023-05-11 07:45:30 +00:00
parent 94ab0e5b0d
commit d297301212
8 changed files with 444 additions and 4 deletions

View File

@ -0,0 +1,3 @@
import { makePassword } from './password';
export { makePassword };

View File

@ -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;
};

View File

@ -2,21 +2,113 @@ import { Test, TestingModule } from '@nestjs/testing';
import { UsersService } from '../users.service'; import { UsersService } from '../users.service';
import { UsersRepositoryService } from '../../../repositories/users/users.repository.service'; import { UsersRepositoryService } from '../../../repositories/users/users.repository.service';
import { CryptoService } from '../../../gateways/crypto/crypto.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 = { export type CryptoMockValue = {
getPublicKey: string | Error; getPublicKey: string | Error;
getPrivateKey: string | Error;
}; };
export type UsersRepositoryMockValue = { export type UsersRepositoryMockValue = {
updateUserVerified: undefined | Error; 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<Promise<void>, []>()
.mockRejectedValue(createMailContentFromEmailConfirm)
: jest
.fn<Promise<{ subject: string; text: string; html: string }>, []>()
.mockResolvedValue(createMailContentFromEmailConfirm),
sendMail:
sendMail instanceof Error
? jest.fn<Promise<void>, []>().mockRejectedValue(sendMail)
: jest.fn<Promise<void>, []>().mockResolvedValue(sendMail),
};
};
export const makeAdB2cServiceMock = (value: AdB2cMockValue) => {
const { getMetaData, getSignKeySets, changePassword } = value;
return {
getMetaData:
getMetaData instanceof Error
? jest.fn<Promise<void>, []>().mockRejectedValue(getMetaData)
: jest.fn<Promise<B2cMetadata>, []>().mockResolvedValue(getMetaData),
getSignKeySets:
getSignKeySets instanceof Error
? jest.fn<Promise<void>, []>().mockRejectedValue(getSignKeySets)
: jest
.fn<Promise<JwkSignKey[]>, []>()
.mockResolvedValue(getSignKeySets),
changePassword:
changePassword instanceof Error
? jest.fn<Promise<void>, []>().mockRejectedValue(changePassword)
: jest
.fn<Promise<{ sub: string }>, []>()
.mockResolvedValue(changePassword),
};
}; };
export const makeUsersServiceMock = async ( export const makeUsersServiceMock = async (
cryptoMockValue: CryptoMockValue, cryptoMockValue: CryptoMockValue,
usersRepositoryMockValue: UsersRepositoryMockValue, usersRepositoryMockValue: UsersRepositoryMockValue,
adB2cMockValue: AdB2cMockValue,
sendGridMockValue: SendGridMockValue,
): Promise<UsersService> => { ): Promise<UsersService> => {
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
providers: [UsersService], providers: [UsersService],
imports: [
ConfigModule.forRoot({
ignoreEnvFile: true,
ignoreEnvVars: true,
}),
],
}) })
.useMocker((token) => { .useMocker((token) => {
switch (token) { switch (token) {
@ -24,6 +116,12 @@ export const makeUsersServiceMock = async (
return makeCryptoServiceMock(cryptoMockValue); return makeCryptoServiceMock(cryptoMockValue);
case UsersRepositoryService: case UsersRepositoryService:
return makeUsersRepositoryMock(usersRepositoryMockValue); return makeUsersRepositoryMock(usersRepositoryMockValue);
case AdB2cService:
return makeAdB2cServiceMock(adB2cMockValue);
case ConfigService:
return {};
case SendGridService:
return makeSendGridServiceMock(sendGridMockValue);
} }
}) })
.compile(); .compile();
@ -32,24 +130,33 @@ export const makeUsersServiceMock = async (
}; };
export const makeCryptoServiceMock = (value: CryptoMockValue) => { export const makeCryptoServiceMock = (value: CryptoMockValue) => {
const { getPublicKey } = value; const { getPublicKey, getPrivateKey } = value;
return { return {
getPublicKey: getPublicKey:
getPublicKey instanceof Error getPublicKey instanceof Error
? jest.fn<Promise<void>, []>().mockRejectedValue(getPublicKey) ? jest.fn<Promise<void>, []>().mockRejectedValue(getPublicKey)
: jest.fn<Promise<string>, []>().mockResolvedValue(getPublicKey), : jest.fn<Promise<string>, []>().mockResolvedValue(getPublicKey),
getPrivateKey:
getPrivateKey instanceof Error
? jest.fn<Promise<void>, []>().mockRejectedValue(getPrivateKey)
: jest.fn<Promise<string>, []>().mockResolvedValue(getPrivateKey),
}; };
}; };
export const makeUsersRepositoryMock = (value: UsersRepositoryMockValue) => { export const makeUsersRepositoryMock = (value: UsersRepositoryMockValue) => {
const { updateUserVerified } = value; const { updateUserVerified } = value;
const { findUserById } = value;
return { return {
updateUserVerified: updateUserVerified:
updateUserVerified instanceof Error updateUserVerified instanceof Error
? jest.fn<Promise<void>, []>().mockRejectedValue(updateUserVerified) ? jest.fn<Promise<void>, []>().mockRejectedValue(updateUserVerified)
: jest.fn<Promise<void>, []>().mockResolvedValue(updateUserVerified), : jest.fn<Promise<void>, []>().mockResolvedValue(updateUserVerified),
findUserById:
findUserById instanceof Error
? jest.fn<Promise<User>, []>().mockRejectedValue(findUserById)
: jest.fn<Promise<User>, []>().mockResolvedValue(findUserById),
}; };
}; };
@ -66,6 +173,36 @@ export const makeDefaultCryptoMockValue = (): CryptoMockValue => {
'OQIDAQAB', 'OQIDAQAB',
'-----END PUBLIC KEY-----', '-----END PUBLIC KEY-----',
].join('\n'), ].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 => { (): UsersRepositoryMockValue => {
return { return {
updateUserVerified: undefined, updateUserVerified: undefined,
findUserById: undefined,
}; };
}; };
export const makeSendGridServiceMockValue = (): SendGridMockValue => {
return {
createMailContentFromEmailConfirm: { subject: '', text: '', html: '' },
sendMail: undefined,
};
};

View File

@ -65,6 +65,7 @@ export class UsersController {
@Body() body: ConfirmRequest, @Body() body: ConfirmRequest,
): Promise<ConfirmResponse> { ): Promise<ConfirmResponse> {
console.log(body); console.log(body);
await this.usersService.confirmUserAndInitPassword(body.token);
return {}; return {};
} }

View File

@ -3,9 +3,18 @@ import { CryptoModule } from '../../gateways/crypto/crypto.module';
import { UsersController } from './users.controller'; import { UsersController } from './users.controller';
import { UsersService } from './users.service'; import { UsersService } from './users.service';
import { UsersRepositoryModule } from '../../repositories/users/users.repository.module'; 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({ @Module({
imports: [CryptoModule, UsersRepositoryModule], imports: [
CryptoModule,
UsersRepositoryModule,
AdB2cModule,
SendGridModule,
ConfigModule,
],
controllers: [UsersController], controllers: [UsersController],
providers: [UsersService], providers: [UsersService],
}) })

View File

@ -4,6 +4,8 @@ import {
makeDefaultCryptoMockValue, makeDefaultCryptoMockValue,
makeDefaultUsersRepositoryMockValue, makeDefaultUsersRepositoryMockValue,
makeUsersServiceMock, makeUsersServiceMock,
makeDefaultAdB2cMockValue,
makeSendGridServiceMockValue,
} from './test/users.service.mock'; } from './test/users.service.mock';
import { EmailAlreadyVerifiedError } from '../../repositories/users/users.repository.service'; import { EmailAlreadyVerifiedError } from '../../repositories/users/users.repository.service';
@ -11,21 +13,58 @@ describe('UsersService', () => {
it('ユーザの仮登録時に払い出されるトークンにより、未認証のユーザが認証済みになる', async () => { it('ユーザの仮登録時に払い出されるトークンにより、未認証のユーザが認証済みになる', async () => {
const cryptoMockValue = makeDefaultCryptoMockValue(); const cryptoMockValue = makeDefaultCryptoMockValue();
const usersRepositoryMockValue = makeDefaultUsersRepositoryMockValue(); const usersRepositoryMockValue = makeDefaultUsersRepositoryMockValue();
const adb2cParam = makeDefaultAdB2cMockValue();
const sendGridMockValue = makeSendGridServiceMockValue();
const service = await makeUsersServiceMock( const service = await makeUsersServiceMock(
cryptoMockValue, cryptoMockValue,
usersRepositoryMockValue, usersRepositoryMockValue,
adb2cParam,
sendGridMockValue,
); );
const token = const token =
'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhY2NvdW50SWQiOjEsInVzZXJJZCI6MiwiZW1haWwiOiJ4eHhAeHh4Lnh4eCIsImlhdCI6MTAwMDAwMDAwMCwiZXhwIjo5MDAwMDAwMDAwfQ.26L6BdNg-3TbyKT62PswlJ6RPMkcTtHzlDXW2Uo9XbMPVSrl2ObcuS6EcXjFFN2DEfNTKbqX_zevIWMpHOAdLNgGhk528nLrBrNvPASqtTjvW9muxMXpjUdjRVkmVbOylBHWW3YpWL9JEbJQ7rAzWDfaIdPhMovdaxumnZt_UwnlnrdaVPLACW7tkH_laEcAU507iSiM4mqxxG8FuTs34t6PEdwRuzZAQPN2IOPYNSvGNdJYryPacSeSNZ_z1xeBYXLOLQfOBZzyTReYDOhXdikhrNUbxjgnZQlSXBCVMlZ9PH42bHfp-LJIeJzW0yqnF6oLklvJP-fo8eW0k5iDOw'; 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhY2NvdW50SWQiOjEsInVzZXJJZCI6MiwiZW1haWwiOiJ4eHhAeHh4Lnh4eCIsImlhdCI6MTAwMDAwMDAwMCwiZXhwIjo5MDAwMDAwMDAwfQ.26L6BdNg-3TbyKT62PswlJ6RPMkcTtHzlDXW2Uo9XbMPVSrl2ObcuS6EcXjFFN2DEfNTKbqX_zevIWMpHOAdLNgGhk528nLrBrNvPASqtTjvW9muxMXpjUdjRVkmVbOylBHWW3YpWL9JEbJQ7rAzWDfaIdPhMovdaxumnZt_UwnlnrdaVPLACW7tkH_laEcAU507iSiM4mqxxG8FuTs34t6PEdwRuzZAQPN2IOPYNSvGNdJYryPacSeSNZ_z1xeBYXLOLQfOBZzyTReYDOhXdikhrNUbxjgnZQlSXBCVMlZ9PH42bHfp-LJIeJzW0yqnF6oLklvJP-fo8eW0k5iDOw';
expect(await service.confirmUser(token)).toEqual(undefined); expect(await service.confirmUser(token)).toEqual(undefined);
}); });
it('トークンの形式が不正な場合、形式不正エラーとなる。', async () => { it('ユーザーが発行されたパスワードでログインできるようにする', async () => {
const cryptoMockValue = makeDefaultCryptoMockValue(); const cryptoMockValue = makeDefaultCryptoMockValue();
const usersRepositoryMockValue = makeDefaultUsersRepositoryMockValue(); 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( const service = await makeUsersServiceMock(
cryptoMockValue, cryptoMockValue,
usersRepositoryMockValue, 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'; const token = 'invalid.id.token';
await expect(service.confirmUser(token)).rejects.toEqual( 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 () => { it('ユーザが既に認証済みだった場合、認証済みユーザエラーとなる。', async () => {
const cryptoMockValue = makeDefaultCryptoMockValue(); const cryptoMockValue = makeDefaultCryptoMockValue();
const usersRepositoryMockValue = makeDefaultUsersRepositoryMockValue(); const usersRepositoryMockValue = makeDefaultUsersRepositoryMockValue();
const adb2cParam = makeDefaultAdB2cMockValue();
const sendGridMockValue = makeSendGridServiceMockValue();
usersRepositoryMockValue.updateUserVerified = usersRepositoryMockValue.updateUserVerified =
new EmailAlreadyVerifiedError(); new EmailAlreadyVerifiedError();
const service = await makeUsersServiceMock( const service = await makeUsersServiceMock(
cryptoMockValue, cryptoMockValue,
usersRepositoryMockValue, usersRepositoryMockValue,
adb2cParam,
sendGridMockValue,
); );
const token = const token =
'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhY2NvdW50SWQiOjEsInVzZXJJZCI6MiwiZW1haWwiOiJ4eHhAeHh4Lnh4eCIsImlhdCI6MTAwMDAwMDAwMCwiZXhwIjo5MDAwMDAwMDAwfQ.26L6BdNg-3TbyKT62PswlJ6RPMkcTtHzlDXW2Uo9XbMPVSrl2ObcuS6EcXjFFN2DEfNTKbqX_zevIWMpHOAdLNgGhk528nLrBrNvPASqtTjvW9muxMXpjUdjRVkmVbOylBHWW3YpWL9JEbJQ7rAzWDfaIdPhMovdaxumnZt_UwnlnrdaVPLACW7tkH_laEcAU507iSiM4mqxxG8FuTs34t6PEdwRuzZAQPN2IOPYNSvGNdJYryPacSeSNZ_z1xeBYXLOLQfOBZzyTReYDOhXdikhrNUbxjgnZQlSXBCVMlZ9PH42bHfp-LJIeJzW0yqnF6oLklvJP-fo8eW0k5iDOw'; 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhY2NvdW50SWQiOjEsInVzZXJJZCI6MiwiZW1haWwiOiJ4eHhAeHh4Lnh4eCIsImlhdCI6MTAwMDAwMDAwMCwiZXhwIjo5MDAwMDAwMDAwfQ.26L6BdNg-3TbyKT62PswlJ6RPMkcTtHzlDXW2Uo9XbMPVSrl2ObcuS6EcXjFFN2DEfNTKbqX_zevIWMpHOAdLNgGhk528nLrBrNvPASqtTjvW9muxMXpjUdjRVkmVbOylBHWW3YpWL9JEbJQ7rAzWDfaIdPhMovdaxumnZt_UwnlnrdaVPLACW7tkH_laEcAU507iSiM4mqxxG8FuTs34t6PEdwRuzZAQPN2IOPYNSvGNdJYryPacSeSNZ_z1xeBYXLOLQfOBZzyTReYDOhXdikhrNUbxjgnZQlSXBCVMlZ9PH42bHfp-LJIeJzW0yqnF6oLklvJP-fo8eW0k5iDOw';
@ -50,15 +120,50 @@ describe('UsersService', () => {
new HttpException(makeErrorResponse('E010202'), HttpStatus.BAD_REQUEST), 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 () => { it('DBネットワークエラーとなる場合、エラーとなる。', async () => {
const cryptoMockValue = makeDefaultCryptoMockValue(); const cryptoMockValue = makeDefaultCryptoMockValue();
const usersRepositoryMockValue = makeDefaultUsersRepositoryMockValue(); const usersRepositoryMockValue = makeDefaultUsersRepositoryMockValue();
const adb2cParam = makeDefaultAdB2cMockValue();
const sendGridMockValue = makeSendGridServiceMockValue();
usersRepositoryMockValue.updateUserVerified = new Error('DB error'); usersRepositoryMockValue.updateUserVerified = new Error('DB error');
const service = await makeUsersServiceMock( const service = await makeUsersServiceMock(
cryptoMockValue, cryptoMockValue,
usersRepositoryMockValue, usersRepositoryMockValue,
adb2cParam,
sendGridMockValue,
); );
const token = const token =
'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhY2NvdW50SWQiOjEsInVzZXJJZCI6MiwiZW1haWwiOiJ4eHhAeHh4Lnh4eCIsImlhdCI6MTAwMDAwMDAwMCwiZXhwIjo5MDAwMDAwMDAwfQ.26L6BdNg-3TbyKT62PswlJ6RPMkcTtHzlDXW2Uo9XbMPVSrl2ObcuS6EcXjFFN2DEfNTKbqX_zevIWMpHOAdLNgGhk528nLrBrNvPASqtTjvW9muxMXpjUdjRVkmVbOylBHWW3YpWL9JEbJQ7rAzWDfaIdPhMovdaxumnZt_UwnlnrdaVPLACW7tkH_laEcAU507iSiM4mqxxG8FuTs34t6PEdwRuzZAQPN2IOPYNSvGNdJYryPacSeSNZ_z1xeBYXLOLQfOBZzyTReYDOhXdikhrNUbxjgnZQlSXBCVMlZ9PH42bHfp-LJIeJzW0yqnF6oLklvJP-fo8eW0k5iDOw'; '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,
),
);
});
}); });

View File

@ -6,12 +6,19 @@ import {
EmailAlreadyVerifiedError, EmailAlreadyVerifiedError,
} from '../../repositories/users/users.repository.service'; } from '../../repositories/users/users.repository.service';
import { makeErrorResponse } from '../../common/error/makeErrorResponse'; 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() @Injectable()
export class UsersService { export class UsersService {
constructor( constructor(
private readonly cryptoService: CryptoService, private readonly cryptoService: CryptoService,
private readonly usersRepository: UsersRepositoryService, private readonly usersRepository: UsersRepositoryService,
private readonly adB2cService: AdB2cService,
private readonly configService: ConfigService,
private readonly sendgridService: SendGridService,
) {} ) {}
private readonly logger = new Logger(UsersService.name); 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<void> {
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<string>('MAIL_FROM') ?? '';
// XXX ODMS側が正式にメッセージを決めるまで仮のメール内容とする
const subject = 'A temporary password has been issued.';
const text = 'temporary password: ' + ramdomPassword;
const domains = this.configService.get<string>('APP_DOMAIN');
const path = '/';
const html = `<p>OMDS TOP PAGE URL.<p><a href="${domains}${path}">${domains}${path}}"</a>`;
// メールを送信
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,
);
}
}
}
}
} }

View File

@ -137,4 +137,32 @@ export class AdB2cService {
this.logger.log(`[OUT] ${this.getSignKeySets.name}`); 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}`);
}
}
} }