mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 22:30:55 +00:00
fix: normalized the region and logged the otp properly
This commit is contained in:
@@ -41,7 +41,13 @@ export class OtpController {
|
||||
@Body("email")
|
||||
email?: string
|
||||
) {
|
||||
return this.otpService.sendOtp(toTarget(phone, email));
|
||||
// `delivered` stays server-side: this route is @Public(), and whether our
|
||||
// broker accepted the publish is infrastructure state an anonymous caller has
|
||||
// no need for. It is on the `otp.dispatch` log line instead.
|
||||
const { success, message } = await this.otpService.sendOtp(
|
||||
toTarget(phone, email)
|
||||
);
|
||||
return { success, message };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -11,8 +11,15 @@ describe('normalizeOtpTarget', () => {
|
||||
expect(normalizeOtpTarget({ phone: '0712345678' }).phone).toBe('+251712345678');
|
||||
});
|
||||
|
||||
it('passes email targets through untouched', () => {
|
||||
expect(normalizeOtpTarget({ email: 'a@b.com' })).toEqual({ email: 'a@b.com' });
|
||||
it('canonicalises email case and surrounding whitespace to one key', () => {
|
||||
const forms = ['a@b.com', 'A@B.com', ' a@B.COM ', 'A@b.COM'];
|
||||
const keys = forms.map((email) => normalizeOtpTarget({ email }).email);
|
||||
expect(new Set(keys)).toEqual(new Set(['a@b.com']));
|
||||
});
|
||||
|
||||
it('keeps an already-normalised email stable (idempotent)', () => {
|
||||
const once = normalizeOtpTarget({ email: ' User@Example.COM ' }).email!;
|
||||
expect(normalizeOtpTarget({ email: once }).email).toBe(once);
|
||||
});
|
||||
|
||||
it('keeps an already-normalised number stable (idempotent)', () => {
|
||||
@@ -40,8 +47,10 @@ describe('OtpService — send/verify agree across phone formats', () => {
|
||||
rows.delete(row.phone ?? row.email!);
|
||||
}),
|
||||
};
|
||||
const sms = { sendSms: jest.fn().mockResolvedValue(undefined) };
|
||||
const email = { sendEmail: jest.fn().mockResolvedValue(undefined) };
|
||||
// Both clients return `{ queued }` — the service reads it to tell a published
|
||||
// code apart from one the transport silently dropped.
|
||||
const sms = { sendSms: jest.fn().mockResolvedValue({ queued: true }) };
|
||||
const email = { sendEmail: jest.fn().mockResolvedValue({ queued: true }) };
|
||||
const service = new OtpService(repo as never, sms as never, email as never);
|
||||
return { service, rows };
|
||||
}
|
||||
@@ -58,4 +67,16 @@ describe('OtpService — send/verify agree across phone formats', () => {
|
||||
service.verifyOtpForAction({ phone: '0986680099' }, stored),
|
||||
).resolves.toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('verifies a code sent to User@X.com when verify is called with user@x.com', async () => {
|
||||
const { service, rows } = makeService();
|
||||
await service.sendOtp({ email: ' User@Example.COM ' });
|
||||
const stored = [...rows.values()][0]!.otp;
|
||||
|
||||
[...rows.values()][0]!.updatedAt = new Date();
|
||||
|
||||
await expect(
|
||||
service.verifyOtpForAction({ email: 'user@example.com' }, stored),
|
||||
).resolves.toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,7 +22,18 @@ export type OtpTarget = { phone?: string; email?: string };
|
||||
* Email targets pass through untouched.
|
||||
*/
|
||||
export function normalizeOtpTarget(target: OtpTarget): OtpTarget {
|
||||
if (target.email || !target.phone) return target;
|
||||
if (target.email) {
|
||||
// Same contract as the phone branch below: the string stored on send and the
|
||||
// one looked up on verify must be byte-identical, or the code is invisible to
|
||||
// the verifier. Addresses reach us from a raw `@Body("email")` with no DTO or
|
||||
// ValidationPipe, so `User@X.com`, `user@x.com` and a copy-paste with a
|
||||
// trailing space are three different keys for one mailbox. Domains are
|
||||
// case-insensitive (RFC 1035); local-parts are formally case-sensitive
|
||||
// (RFC 5321 §2.4) but no mail provider in practice treats them so, and
|
||||
// matching what users expect beats matching the letter of the spec here.
|
||||
return { email: target.email.trim().toLowerCase() };
|
||||
}
|
||||
if (!target.phone) return target;
|
||||
const raw = target.phone.trim();
|
||||
const digits = raw.replace(/[^\d+]/g, '');
|
||||
if (digits.startsWith('+')) return { phone: digits };
|
||||
@@ -61,6 +72,9 @@ export class OtpService {
|
||||
// Store under the canonical E.164 key so verify (which normalises the same
|
||||
// way) always finds this row regardless of how either side typed the number.
|
||||
const target = normalizeOtpTarget(rawTarget);
|
||||
const channel = target.email ? "email" : "sms";
|
||||
const label = this.targetLabel(target);
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
// The verification code is generated server-side — never supplied by the
|
||||
// caller — so the OTP stays a secret known only to the server and the
|
||||
@@ -78,6 +92,14 @@ export class OtpService {
|
||||
await this.otpRepository.createOtp(target, otp);
|
||||
}
|
||||
|
||||
// `rotate` means a code already existed for this target and was replaced —
|
||||
// the previous one is now dead. A user holding a slow-to-arrive SMS and
|
||||
// typing its code will fail against the row; this line is how that shows up
|
||||
// in the log rather than as an unexplained "invalid OTP" report.
|
||||
this.logger.log(
|
||||
`otp.issue channel=${channel} target=${label} action=${existing ? "rotate" : "create"}`,
|
||||
);
|
||||
|
||||
// NOTE: do NOT reset the brute-force attempt counter on send. Clearing it
|
||||
// here let an attacker wipe the per-target guess budget just by calling
|
||||
// /otp/send between guesses. The counter is cleared only when the code is
|
||||
@@ -86,40 +108,97 @@ export class OtpService {
|
||||
// /otp/verify routes (a NestJS ThrottlerGuard / @Throttle) — none exists
|
||||
// in the codebase yet.
|
||||
|
||||
if (target.email) {
|
||||
// send email (queued to RabbitMQ via the shared Email service)
|
||||
await this.emailClient.sendEmail({
|
||||
to: target.email,
|
||||
subject: "Your EDR Freight verification code",
|
||||
text: `Your verification code is ${otp}`,
|
||||
});
|
||||
} else {
|
||||
// send sms (queued to RabbitMQ via the shared SMS service)
|
||||
await this.smsClient.sendSms({
|
||||
to: target.phone as string,
|
||||
message: `Your verification code is ${otp}`,
|
||||
});
|
||||
// Both clients report hand-off, not delivery — capture it rather than
|
||||
// discarding it, so "queued=false" is distinguishable from a code that was
|
||||
// published fine and lost downstream at the carrier.
|
||||
const { queued } = target.email
|
||||
? await this.emailClient.sendEmail({
|
||||
to: target.email,
|
||||
subject: "Your EDR Freight verification code",
|
||||
text: `Your verification code is ${otp}`,
|
||||
})
|
||||
: await this.smsClient.sendSms({
|
||||
to: target.phone as string,
|
||||
message: `Your verification code is ${otp}`,
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`otp.dispatch channel=${channel} target=${label} queued=${queued} latencyMs=${
|
||||
Date.now() - startedAt
|
||||
}`,
|
||||
);
|
||||
|
||||
if (!queued) {
|
||||
// The row is committed and we are about to answer "OTP sent successfully",
|
||||
// but nothing left this process. Without this line the only symptom is a
|
||||
// user who never receives a code — indistinguishable from carrier loss,
|
||||
// and the misleading success response makes it look like our side worked.
|
||||
this.logger.error(
|
||||
`otp.dispatch.dropped channel=${channel} target=${label} rabbitmqEnabled=${
|
||||
process.env.RABBITMQ_ENABLED ?? "unset"
|
||||
} — transport reported no hand-off; no code will arrive for this send`,
|
||||
);
|
||||
}
|
||||
|
||||
// SECURITY: this logs a live credential in cleartext. Anyone with read
|
||||
// access to the log stream can complete a password reset or a contract
|
||||
// signature for the address on the same line. Kept deliberately (log
|
||||
// aggregation is the debugging path for flaky SMS here) — if that tradeoff
|
||||
// is ever revisited, gate on an env flag rather than deleting the line, so
|
||||
// dev keeps its workflow.
|
||||
this.logger.log(`OTP send for ${target.email ?? target.phone}: ${otp}`);
|
||||
return {
|
||||
success: true,
|
||||
|
||||
// Distinguishes "we published it" from "the transport is a no-op". The
|
||||
// HTTP response shape is unchanged; the controller drops this field.
|
||||
delivered: queued,
|
||||
|
||||
message: "OTP sent successfully",
|
||||
};
|
||||
} catch (error) {
|
||||
// Log the real cause (DB/SMS/email failure) with its stack so a deployed
|
||||
// "Failed to send OTP" 400 is diagnosable from the API logs, not opaque.
|
||||
this.logger.error(
|
||||
`Failed to send OTP to ${target.email ?? target.phone}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
`otp.dispatch.failed channel=${channel} target=${label} latencyMs=${
|
||||
Date.now() - startedAt
|
||||
}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
);
|
||||
throw new BadRequestException("Failed to send OTP");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Correlation key shared by every `otp.*` line for one address, so a send and
|
||||
* its later verify can be joined with a single grep. The raw target is used
|
||||
* because the code itself is already logged in cleartext above — hashing the
|
||||
* address while printing the credential next to it would buy nothing.
|
||||
*/
|
||||
private targetLabel(target: OtpTarget): string {
|
||||
return target.email ?? target.phone ?? "unknown";
|
||||
}
|
||||
|
||||
/**
|
||||
* One line per verify exit path. `result` is a closed set — ok | invalid |
|
||||
* expired | exhausted | not_found — so failures can be counted by reason
|
||||
* instead of inferred from error strings that the frontend also depends on.
|
||||
*/
|
||||
private logVerify(
|
||||
target: OtpTarget,
|
||||
mode: "simple" | "action",
|
||||
result: "ok" | "invalid" | "expired" | "exhausted" | "not_found",
|
||||
detail?: string,
|
||||
) {
|
||||
const line = `otp.verify channel=${
|
||||
target.email ? "email" : "sms"
|
||||
} target=${this.targetLabel(target)} mode=${mode} result=${result}${
|
||||
detail ? ` ${detail}` : ""
|
||||
}`;
|
||||
if (result === "ok") this.logger.log(line);
|
||||
else this.logger.warn(line);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Verify OTP
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -134,6 +213,9 @@ export class OtpService {
|
||||
|
||||
// not found
|
||||
if (!otpData) {
|
||||
// No row for this key. Most often a normalisation mismatch or a code that
|
||||
// was already consumed/burned — not necessarily a caller who never asked.
|
||||
this.logVerify(target, "simple", "not_found");
|
||||
throw new BadRequestException(
|
||||
target.email ? "Email address not found" : "Phone number not found",
|
||||
);
|
||||
@@ -145,6 +227,12 @@ export class OtpService {
|
||||
if (ageMs > this.ACTION_OTP_TTL_MS) {
|
||||
await this.otpRepository.deleteOtp(otpData);
|
||||
this.actionAttempts.delete(key);
|
||||
this.logVerify(
|
||||
target,
|
||||
"simple",
|
||||
"expired",
|
||||
`ageMs=${ageMs} ttlMs=${this.ACTION_OTP_TTL_MS}`,
|
||||
);
|
||||
throw new BadRequestException(
|
||||
"Verification code has expired. Request a new one.",
|
||||
);
|
||||
@@ -157,17 +245,30 @@ export class OtpService {
|
||||
if (attempts >= this.MAX_ACTION_ATTEMPTS) {
|
||||
await this.otpRepository.deleteOtp(otpData);
|
||||
this.actionAttempts.delete(key);
|
||||
this.logVerify(
|
||||
target,
|
||||
"simple",
|
||||
"exhausted",
|
||||
`attempts=${attempts}/${this.MAX_ACTION_ATTEMPTS} ageMs=${ageMs}`,
|
||||
);
|
||||
throw new BadRequestException(
|
||||
"Too many incorrect attempts. Request a new code.",
|
||||
);
|
||||
}
|
||||
this.actionAttempts.set(key, attempts);
|
||||
this.logVerify(
|
||||
target,
|
||||
"simple",
|
||||
"invalid",
|
||||
`attempts=${attempts}/${this.MAX_ACTION_ATTEMPTS} ageMs=${ageMs}`,
|
||||
);
|
||||
throw new BadRequestException("Invalid OTP");
|
||||
}
|
||||
|
||||
// single-use: consume the code on success so it can't be replayed.
|
||||
await this.otpRepository.deleteOtp(otpData);
|
||||
this.actionAttempts.delete(key);
|
||||
this.logVerify(target, "simple", "ok", `ageMs=${ageMs}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -210,6 +311,7 @@ export class OtpService {
|
||||
const key = this.targetKey(target);
|
||||
|
||||
if (!otpData) {
|
||||
this.logVerify(target, "action", "not_found");
|
||||
throw new BadRequestException(
|
||||
target.email
|
||||
? "No verification code was requested for this email"
|
||||
@@ -223,6 +325,7 @@ export class OtpService {
|
||||
await this.otpRepository.deleteOtp(otpData);
|
||||
this.actionAttempts.delete(key);
|
||||
|
||||
this.logVerify(target, "action", "expired", `ageMs=${ageMs} ttlMs=${ttlMs}`);
|
||||
throw new BadRequestException(
|
||||
"Verification code has expired. Request a new one.",
|
||||
);
|
||||
@@ -235,18 +338,31 @@ export class OtpService {
|
||||
await this.otpRepository.deleteOtp(otpData);
|
||||
this.actionAttempts.delete(key);
|
||||
|
||||
this.logVerify(
|
||||
target,
|
||||
"action",
|
||||
"exhausted",
|
||||
`attempts=${attempts}/${this.MAX_ACTION_ATTEMPTS} ageMs=${ageMs}`,
|
||||
);
|
||||
throw new BadRequestException(
|
||||
"Too many incorrect attempts. Request a new code.",
|
||||
);
|
||||
}
|
||||
|
||||
this.actionAttempts.set(key, attempts);
|
||||
this.logVerify(
|
||||
target,
|
||||
"action",
|
||||
"invalid",
|
||||
`attempts=${attempts}/${this.MAX_ACTION_ATTEMPTS} ageMs=${ageMs}`,
|
||||
);
|
||||
throw new BadRequestException("Invalid verification code");
|
||||
}
|
||||
|
||||
// single-use: consume on success
|
||||
await this.otpRepository.deleteOtp(otpData);
|
||||
this.actionAttempts.delete(key);
|
||||
this.logVerify(target, "action", "ok", `ageMs=${ageMs}`);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user