mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 09:30:59 +00:00
feat: password reset flow
This commit is contained in:
@@ -50,6 +50,9 @@ export class OtpService {
|
||||
await this.otpRepository.createOtp(target, otp);
|
||||
}
|
||||
|
||||
// A freshly issued code gets a fresh guess budget.
|
||||
this.actionAttempts.delete(this.targetKey(target));
|
||||
|
||||
if (target.email) {
|
||||
// send email (queued to RabbitMQ via the shared Email service)
|
||||
await this.emailClient.sendEmail({
|
||||
@@ -121,24 +124,44 @@ export class OtpService {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Fresh, single-use challenge gating a sensitive action (e.g. applying a
|
||||
// contract signature). Unlike verifyOtp above — which marks a phone verified
|
||||
// and leaves the code in place — this enforces a short TTL and consumes the
|
||||
// code on success so it can never be replayed.
|
||||
// contract signature, resetting a forgotten password). Unlike verifyOtp above
|
||||
// — which marks a target verified and leaves the code in place — this enforces
|
||||
// a TTL and consumes the code on success so it can never be replayed.
|
||||
private readonly ACTION_OTP_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
async verifyOtpForAction(phone: string, otp: string) {
|
||||
const otpData = await this.otpRepository.findByPhone(phone);
|
||||
// Without a cap, a 6-digit code guarding a password reset is brute-forceable
|
||||
// within its own TTL. `otp_verifications` has no attempt column, so the
|
||||
// counter lives here and the code is burned once the budget is spent.
|
||||
// Per-process: it resets on restart and is not shared across replicas — a
|
||||
// persisted counter needs a migration on OtpVerification.
|
||||
private readonly MAX_ACTION_ATTEMPTS = 5;
|
||||
private readonly actionAttempts = new Map<string, number>();
|
||||
|
||||
private targetKey(target: OtpTarget): string {
|
||||
return target.email ? `email:${target.email}` : `phone:${target.phone}`;
|
||||
}
|
||||
|
||||
async verifyOtpForAction(
|
||||
target: OtpTarget,
|
||||
otp: string,
|
||||
ttlMs: number = this.ACTION_OTP_TTL_MS,
|
||||
) {
|
||||
const otpData = await this.otpRepository.findByTarget(target);
|
||||
const key = this.targetKey(target);
|
||||
|
||||
if (!otpData) {
|
||||
throw new BadRequestException(
|
||||
"No verification code was requested for this phone",
|
||||
target.email
|
||||
? "No verification code was requested for this email"
|
||||
: "No verification code was requested for this phone",
|
||||
);
|
||||
}
|
||||
|
||||
const ageMs = Date.now() - new Date(otpData.updatedAt).getTime();
|
||||
|
||||
if (ageMs > this.ACTION_OTP_TTL_MS) {
|
||||
if (ageMs > ttlMs) {
|
||||
await this.otpRepository.deleteOtp(otpData);
|
||||
this.actionAttempts.delete(key);
|
||||
|
||||
throw new BadRequestException(
|
||||
"Verification code has expired. Request a new one.",
|
||||
@@ -146,11 +169,24 @@ export class OtpService {
|
||||
}
|
||||
|
||||
if (otpData.otp !== otp) {
|
||||
const attempts = (this.actionAttempts.get(key) ?? 0) + 1;
|
||||
|
||||
if (attempts >= this.MAX_ACTION_ATTEMPTS) {
|
||||
await this.otpRepository.deleteOtp(otpData);
|
||||
this.actionAttempts.delete(key);
|
||||
|
||||
throw new BadRequestException(
|
||||
"Too many incorrect attempts. Request a new code.",
|
||||
);
|
||||
}
|
||||
|
||||
this.actionAttempts.set(key, attempts);
|
||||
throw new BadRequestException("Invalid verification code");
|
||||
}
|
||||
|
||||
// single-use: consume on success
|
||||
await this.otpRepository.deleteOtp(otpData);
|
||||
this.actionAttempts.delete(key);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user