mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 07:38:10 +00:00
feat: otp double sending
This commit is contained in:
@@ -73,6 +73,27 @@ function maskPhone(phone: string): string {
|
||||
return `${'•'.repeat(trimmed.length - 4)}${trimmed.slice(-4)}`;
|
||||
}
|
||||
|
||||
/** Email counterpart of {@link maskPhone} (`jane@x.com` → `j•••@x.com`). */
|
||||
function maskEmail(email: string): string {
|
||||
const [local, domain] = email.trim().split('@');
|
||||
if (!domain) return email.trim();
|
||||
return `${local.slice(0, 1)}${'•'.repeat(Math.max(local.length - 1, 1))}@${domain}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the signing code went, for the "we sent a code to …" line in the UI.
|
||||
* Both contacts are listed when both were used — a signer who only watches their
|
||||
* handset otherwise has no idea the email carries the same code.
|
||||
*/
|
||||
function maskSignerContacts(contacts: { phone?: string; email?: string }): string {
|
||||
return [
|
||||
contacts.email ? maskEmail(contacts.email) : null,
|
||||
contacts.phone ? maskPhone(contacts.phone) : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' and ');
|
||||
}
|
||||
|
||||
/** Status-machine guard mirroring booking-status.util. */
|
||||
function assertContractStatus(contract: Contract, allowed: string[]): void {
|
||||
if (!allowed.includes(contract.status)) {
|
||||
@@ -109,34 +130,39 @@ export class ContractTransitionService {
|
||||
) {}
|
||||
|
||||
/**
|
||||
* The phone the signing OTP is sent to and verified against: the signer's own
|
||||
* IAM account number.
|
||||
* The contacts the signing OTP is sent to and verified against: the signer's
|
||||
* own IAM account phone AND email. One code goes to both and either delivery
|
||||
* verifies it, so a signer whose SMS is delayed can still complete from their
|
||||
* inbox instead of abandoning a ready contract.
|
||||
*
|
||||
* 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
|
||||
* request body — caller-supplied contacts would let an attacker point the code
|
||||
* at their own phone or mailbox. 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> {
|
||||
private async resolveSignerContacts(
|
||||
signerUserId?: string,
|
||||
): Promise<{ phone?: string; email?: 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.
|
||||
// first), but never fall back to another account if it ever changes.
|
||||
throw new BadRequestException('Authentication required to sign');
|
||||
}
|
||||
const rows: Array<{ phone_number: string | null }> =
|
||||
const rows: Array<{ phone_number: string | null; email: string | null }> =
|
||||
await this.dataSource.query(
|
||||
`SELECT phone_number FROM iam.users WHERE id = $1 AND is_active = true`,
|
||||
`SELECT phone_number, email FROM iam.users WHERE id = $1 AND is_active = true`,
|
||||
[signerUserId],
|
||||
);
|
||||
const phone = rows[0]?.phone_number?.trim();
|
||||
if (!phone) {
|
||||
const email = rows[0]?.email?.trim();
|
||||
if (!phone && !email) {
|
||||
throw new BadRequestException(
|
||||
'Your account has no registered phone number. Add one in Settings → Account before signing.',
|
||||
'Your account has no registered phone number or email. Add one in Settings → Account before signing.',
|
||||
);
|
||||
}
|
||||
return phone;
|
||||
return { ...(phone ? { phone } : {}), ...(email ? { email } : {}) };
|
||||
}
|
||||
|
||||
/** Customer submits the contract for approval → SUBMITTED; freeze unit rates. */
|
||||
@@ -839,11 +865,11 @@ export class ContractTransitionService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Send the sudo-mode signing OTP to the SIGNER's own registered phone and
|
||||
* email — the same contacts {@link sign} verifies against. The client never
|
||||
* picks them (that is the H12(b) trust property): it only asks us to send, and
|
||||
* we resolve them from the authenticated user id. Returns a masked hint so the
|
||||
* UI can say where the code went without exposing the full values.
|
||||
*/
|
||||
async sendSigningOtp(
|
||||
contractId: string,
|
||||
@@ -858,9 +884,9 @@ export class ContractTransitionService {
|
||||
);
|
||||
assertContractStatus(contract, ['CONTRACT_READY']);
|
||||
|
||||
const signerPhone = await this.resolveSignerPhone(options.signerUserId);
|
||||
await this.otpService.sendOtp({ phone: signerPhone });
|
||||
return { sentTo: maskPhone(signerPhone) };
|
||||
const signerContacts = await this.resolveSignerContacts(options.signerUserId);
|
||||
await this.otpService.sendOtp(signerContacts);
|
||||
return { sentTo: maskSignerContacts(signerContacts) };
|
||||
}
|
||||
|
||||
/** Customer signs the ready contract → SIGNED_CUSTOMER. */
|
||||
@@ -887,17 +913,17 @@ export class ContractTransitionService {
|
||||
}
|
||||
// Sudo-mode gate: a fresh, single-use OTP must be verified before the
|
||||
// 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);
|
||||
// contacts, resolved server-side from the authenticated user id — never
|
||||
// caller-supplied ones, which an attacker could point at their own phone
|
||||
// or mailbox. 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 contacts.
|
||||
const signerContacts = await this.resolveSignerContacts(options.signerUserId);
|
||||
if (!dto.otp) {
|
||||
throw new BadRequestException('OTP verification is required to sign the contract');
|
||||
}
|
||||
await this.otpService.verifyOtpForAction({ phone: signerPhone }, dto.otp);
|
||||
await this.otpService.verifyOtpForAction(signerContacts, dto.otp);
|
||||
await this.applySignature(contract, dto, options);
|
||||
await this.contractsRepository.update(contractId, {
|
||||
status: 'SIGNED_CUSTOMER',
|
||||
|
||||
Reference in New Issue
Block a user