UNPKG

alepha

Version:

Easy-to-use modern TypeScript framework for building many kind of applications.

287 lines (253 loc) 9.28 kB
import { createHash, randomInt } from "node:crypto"; import { $inject } from "alepha"; import { CryptoProvider } from "alepha/crypto"; import { DateTimeProvider } from "alepha/datetime"; import { $logger } from "alepha/logger"; import { $repository } from "alepha/orm"; import { BadRequestError, NotFoundError } from "alepha/server"; import { type VerificationEntity, verifications, } from "../entities/verifications.ts"; import { VerificationParameters } from "../parameters/VerificationParameters.ts"; import type { RequestVerificationResponse } from "../schemas/requestVerificationCodeResponseSchema.ts"; import type { ValidateVerificationCodeResponse } from "../schemas/validateVerificationCodeResponseSchema.ts"; import type { VerificationTypeEnum } from "../schemas/verificationTypeEnumSchema.ts"; export class VerificationService { protected readonly log = $logger(); protected readonly dateTimeProvider = $inject(DateTimeProvider); protected readonly crypto = $inject(CryptoProvider); protected readonly verificationParameters = $inject(VerificationParameters); protected readonly verificationRepository = $repository(verifications); public async findByEntry( entry: VerificationEntry, ): Promise<VerificationEntity> { this.log.trace("Finding verification by entry", { type: entry.type, target: entry.target, }); const results = await this.verificationRepository.findMany({ limit: 1, // only need the most recent entry orderBy: { column: "createdAt", direction: "desc", }, where: { type: { eq: entry.type }, target: { eq: entry.target }, purpose: { eq: entry.purpose ?? "default" }, }, }); if (results.length === 0) { this.log.debug("Verification entry not found", { type: entry.type, target: entry.target, }); throw new NotFoundError("Verification entry not found"); } this.log.debug("Verification entry found", { id: results[0].id, type: entry.type, target: entry.target, }); return results[0]; } public findRecentsByEntry(entry: VerificationEntry) { this.log.trace("Finding recent verifications by entry", { type: entry.type, target: entry.target, }); return this.verificationRepository.findMany({ orderBy: { column: "createdAt", direction: "desc", }, where: { type: { eq: entry.type }, target: { eq: entry.target }, purpose: { eq: entry.purpose ?? "default" }, createdAt: { gte: this.dateTimeProvider.now().startOf("day").toISOString(), }, }, }); } /** * Creates a verification entry and returns the token. * The caller is responsible for sending notifications with the token. * This allows for context-specific notifications (e.g., password reset vs email verification). */ public async createVerification( entry: VerificationEntry, ): Promise<RequestVerificationResponse> { this.log.trace("Creating verification", { type: entry.type, target: entry.target, }); const settings = this.verificationParameters.get(entry.type); const recents = await this.findRecentsByEntry(entry); if (recents.length >= settings.limitPerDay) { this.log.warn("Daily verification limit reached", { type: entry.type, target: entry.target, limit: settings.limitPerDay, count: recents.length, }); throw new BadRequestError( `Maximum number of verification requests per day reached (${settings.limitPerDay})`, ); } const existingVerification = recents[0]; if (existingVerification) { const nowSec = this.dateTimeProvider.now().unix(); const createdAtSec = this.dateTimeProvider .of(existingVerification.createdAt) .unix(); const diffSec = nowSec - createdAtSec; if (diffSec < settings.verificationCooldown) { const remainingCooldown = Math.floor( settings.verificationCooldown - diffSec, ); this.log.debug("Verification on cooldown", { type: entry.type, target: entry.target, remainingSeconds: remainingCooldown, }); throw new BadRequestError( `Verification is on cooldown for ${remainingCooldown} seconds`, ); } } const token = this.generateToken(entry.type); const verification = await this.verificationRepository.create({ type: entry.type, target: entry.target, purpose: entry.purpose ?? "default", code: this.hashCode(token), createdAt: this.dateTimeProvider.nowISOString(), }); this.log.info("Verification created", { id: verification.id, type: entry.type, target: entry.target, expiresInSeconds: settings.codeExpiration, }); return { token, codeExpiration: settings.codeExpiration, verificationCooldown: settings.verificationCooldown, maxVerificationAttempts: settings.maxAttempts, }; } public async verifyCode( entry: VerificationEntry, code: string, ): Promise<ValidateVerificationCodeResponse> { this.log.trace("Verifying code", { type: entry.type, target: entry.target, }); const settings = this.verificationParameters.get(entry.type); const verification = await this.findByEntry(entry); if (verification.verifiedAt) { // Already verified — but STILL require the submitted code to match the // record. Short-circuiting on `verifiedAt` alone means any code (or a // blank one) passes once the target has been verified, so any flow // re-using verifyCode as an authz gate would accept an attacker's guess. if (verification.code !== this.hashCode(code)) { this.log.warn("Invalid code submitted for already-verified entry", { id: verification.id, type: entry.type, target: entry.target, }); throw new BadRequestError("Invalid verification code"); } this.log.debug("Verification already verified", { id: verification.id, type: entry.type, target: entry.target, verifiedAt: verification.verifiedAt, }); return { ok: true, alreadyVerified: true }; } // DO NOT DELETE THE VERIFICATION WHEN IT IS REJECTED, // or we won't be able to cooldown the verification const now = this.dateTimeProvider.now(); const expirationDate = this.dateTimeProvider .of(verification.createdAt) .add(settings.codeExpiration, "seconds"); if (now > expirationDate) { this.log.warn("Verification code expired", { id: verification.id, type: entry.type, target: entry.target, createdAt: verification.createdAt, expiredAt: expirationDate.toISOString(), }); throw new BadRequestError("Verification code has expired"); } if (verification.attempts >= settings.maxAttempts) { this.log.warn("Verification locked due to max attempts", { id: verification.id, type: entry.type, target: entry.target, attempts: verification.attempts, maxAttempts: settings.maxAttempts, }); throw new BadRequestError( "Maximum number of attempts reached - verification is locked", ); } if (verification.code !== this.hashCode(code)) { const newAttempts = verification.attempts + 1; this.log.warn("Invalid verification code", { id: verification.id, type: entry.type, target: entry.target, attempts: newAttempts, maxAttempts: settings.maxAttempts, }); await this.verificationRepository.updateById(verification.id, { attempts: newAttempts, }); throw new BadRequestError("Invalid verification code"); } await this.verificationRepository.updateById(verification.id, { verifiedAt: this.dateTimeProvider.nowISOString(), }); this.log.info("Verification code verified", { id: verification.id, type: entry.type, target: entry.target, }); return { ok: true }; } public hashCode(code: string): string { return createHash("sha256").update(code).digest("hex"); } public generateToken(type: VerificationTypeEnum): string { if (type === "code") { const settings = this.verificationParameters.get("code"); return randomInt(0, 1_000_000) .toString() .padStart(settings.codeLength, "0"); } else if (type === "link") { return this.crypto.randomUUID(); } throw new BadRequestError(`Invalid verification type: ${type}`); } } export interface VerificationEntry { type: VerificationTypeEnum; target: string; /** * Logical purpose bucket. Cooldown and daily-limit checks are scoped to * `(type, target, purpose)`, so flows that share a `(type, target)` pair * — e.g. email verification and password reset, both `type: "code"` on the * same email — get independent rate-limit windows instead of colliding. * * Defaults to `"default"` when omitted (back-compatible with callers and * rows that predate this field). */ purpose?: string; }