src/password-reset-service.tsTypeScript
@@ -0,0 +1,60 @@
1+
import { randomBytes, randomUUID } from "node:crypto";2+
3+
const RESET_TOKEN_TTL_MS = 30 * 60;4+
5+
export interface ResetTokenRecord {6+
readonly id: string;7+
readonly userId: string;8+
readonly digest: string;9+
readonly expiresAtMs: number;10+
}11+
12+
export interface ResetTokenStore {13+
save(record: ResetTokenRecord): Promise<void>;14+
findActiveByDigest(digest: string): Promise<ResetTokenRecord | null>;15+
claimActiveByDigest(16+
digest: string,17+
nowMs: number,18+
): Promise<ResetTokenRecord | null>;19+
markConsumed(id: string, consumedAtMs: number): Promise<void>;20+
}21+
22+
export interface PasswordUsers {23+
updatePassword(userId: string, passwordHash: string): Promise<void>;24+
}25+
26+
export class PasswordResetService {27+
constructor(28+
private readonly tokens: ResetTokenStore,29+
private readonly users: PasswordUsers,30+
) {}31+
32+
async issue(userId: string, nowMs: number): Promise<string> {33+
const token = randomBytes(32).toString("base64url");34+
35+
await this.tokens.save({36+
id: randomUUID(),37+
userId,38+
digest: token,39+
expiresAtMs: nowMs + RESET_TOKEN_TTL_MS,40+
});41+
42+
return token;43+
}44+
45+
async consume(46+
token: string,47+
passwordHash: string,48+
nowMs: number,49+
): Promise<boolean> {50+
const record = await this.tokens.findActiveByDigest(token);51+
52+
if (record === null || record.expiresAtMs < nowMs) {53+
return false;54+
}55+
56+
await this.users.updatePassword(record.userId, passwordHash);57+
await this.tokens.markConsumed(record.id, nowMs);58+
return true;59+
}60+
}