feat: setup account page for customer and centeralize the otps and phone usages to use the iam user

This commit is contained in:
Nathnael
2026-07-16 12:08:45 +00:00
parent 318af79962
commit f71bbbf782
26 changed files with 1057 additions and 62 deletions

View File

@@ -4,6 +4,8 @@ import {
Injectable,
Logger,
} from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { randomUUID } from 'node:crypto';
import { Readable } from 'stream';
import { insertWithGeneratedReference } from '@edr/api-common';
@@ -102,8 +104,41 @@ export class ContractTransitionService {
private readonly notifier: ContractNotifierService,
private readonly contractTemplates: ContractTemplatesService,
private readonly clearanceFeeService: ClearanceFeeService,
@InjectDataSource()
private readonly dataSource: DataSource,
) {}
/**
* The phone the signing OTP is sent to and verified against: the signer's own
* IAM account number.
*
* H12(b): resolved server-side from the authenticated user id, never from the
* request body — a caller-supplied number would let an attacker point the code
* at their own phone. Ownership is already gated separately by
* {@link ContractsService.assertCustomerCanAccessContract}, so this binds the
* signature to the *person* signing rather than to a company landline that may
* be shared, stale, or imported from eTrade.
*/
private async resolveSignerPhone(signerUserId?: string): Promise<string> {
if (!signerUserId) {
// Unreachable in practice (the ownership gate rejects a missing user
// first), but never fall back to another number if it ever changes.
throw new BadRequestException('Authentication required to sign');
}
const rows: Array<{ phone_number: string | null }> =
await this.dataSource.query(
`SELECT phone_number FROM iam.users WHERE id = $1 AND is_active = true`,
[signerUserId],
);
const phone = rows[0]?.phone_number?.trim();
if (!phone) {
throw new BadRequestException(
'Your account has no registered phone number. Add one in Settings → Account before signing.',
);
}
return phone;
}
/** Customer submits the contract for approval → SUBMITTED; freeze unit rates. */
async submit(contractId: string): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
@@ -796,10 +831,10 @@ export class ContractTransitionService {
}
/**
* Send the sudo-mode signing OTP to the CONTRACT COMPANY's registered phone —
* the same number {@link sign} verifies against. The client never picks the
* number (that is the H12(b) trust property): it only asks us to send, and we
* resolve the phone from the contract. Returns a masked hint so the UI can
* Send the sudo-mode signing OTP to the SIGNER's own registered phone — the
* same number {@link sign} verifies against. The client never picks the number
* (that is the H12(b) trust property): it only asks us to send, and we resolve
* the phone from the authenticated user id. Returns a masked hint so the UI can
* say where the code went without exposing the full number.
*/
async sendSigningOtp(
@@ -815,14 +850,9 @@ export class ContractTransitionService {
);
assertContractStatus(contract, ['CONTRACT_READY']);
const companyPhone = contract.company?.phone?.trim();
if (!companyPhone) {
throw new BadRequestException(
'The contract company has no registered phone on file to send the signing OTP to',
);
}
await this.otpService.sendOtp({ phone: companyPhone });
return { sentTo: maskPhone(companyPhone) };
const signerPhone = await this.resolveSignerPhone(options.signerUserId);
await this.otpService.sendOtp({ phone: signerPhone });
return { sentTo: maskPhone(signerPhone) };
}
/** Customer signs the ready contract → SIGNED_CUSTOMER. */
@@ -848,20 +878,18 @@ export class ContractTransitionService {
throw new BadRequestException('Customer has already signed this contract');
}
// Sudo-mode gate: a fresh, single-use OTP must be verified before the
// signature is applied. H12(b): verify against the CONTRACT COMPANY's
// registered phone — never the caller-supplied dto.otpPhone, which an
// attacker could point at their own phone to sign someone else's
// contract. The OTP is issued to the company's registered number.
const companyPhone = contract.company?.phone?.trim();
if (!companyPhone) {
throw new BadRequestException(
'The contract company has no registered phone on file to verify the signing OTP against',
);
}
// signature is applied. H12(b): verify against the SIGNER's own registered
// phone, resolved server-side from the authenticated user id — never a
// caller-supplied number, which an attacker could point at their own
// phone. Ownership is already asserted above, so this proves the specific
// person holding the account is present, not merely that someone reached a
// shared company line. Must resolve identically to sendSigningOtp, or send
// and verify would target different numbers.
const signerPhone = await this.resolveSignerPhone(options.signerUserId);
if (!dto.otp) {
throw new BadRequestException('OTP verification is required to sign the contract');
}
await this.otpService.verifyOtpForAction({ phone: companyPhone }, dto.otp);
await this.otpService.verifyOtpForAction({ phone: signerPhone }, dto.otp);
await this.applySignature(contract, dto, options);
await this.contractsRepository.update(contractId, {
status: 'SIGNED_CUSTOMER',