forked from codechefPesuecc/CodeChef-PESUECC-Chapter
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathreset.ts
More file actions
110 lines (97 loc) · 3.35 KB
/
Copy pathreset.ts
File metadata and controls
110 lines (97 loc) · 3.35 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
import crypto from "node:crypto";
import { eq, sql } from "drizzle-orm";
import { getDb } from "@/server/db";
import { passwordResets, users } from "@/server/db/schema";
import { hashPassword } from "@/server/auth/password";
import { sendEmail, canRevealSecretInResponse } from "@/server/email";
import { resetEmailHtml } from "@/server/emailTemplates";
/**
* Password reset via a single-use emailed link. The token is a 256-bit random
* value; only its SHA-256 hash is stored, so a DB read can't reset anyone. One
* active token per user, 30-minute expiry, consumed on use.
*/
const RESET_TTL_MS = 30 * 60 * 1000; // 30 minutes
function hashToken(token: string): string {
return crypto.createHash("sha256").update(token).digest("hex");
}
export interface ForgotResult {
ok: boolean;
/** Present only under the dev console transport, so testing needs no inbox. */
devLink?: string;
}
/**
* Issues a reset token for the email (if it maps to an account) and emails the
* link. Always resolves ok — it never reveals whether an account exists.
*/
export async function createPasswordReset(
email: string,
origin: string,
): Promise<ForgotResult> {
const db = getDb();
const rows = await db
.select()
.from(users)
.where(eq(users.email, email))
.limit(1);
const user = rows[0];
if (!user) return { ok: true };
const now = Date.now();
const token = crypto.randomBytes(32).toString("base64url");
await db.delete(passwordResets).where(eq(passwordResets.userId, user.id));
await db.insert(passwordResets).values({
id: crypto.randomUUID(),
userId: user.id,
tokenHash: hashToken(token),
expiresAt: now + RESET_TTL_MS,
createdAt: now,
});
const link = `${origin}/reset?token=${token}`;
await sendEmail({
to: email,
subject: "Reset your CodeChef PESUECC Arena password",
text: `Reset your password with this link (valid for 30 minutes):\n\n${link}\n\nIf you didn't request this, you can safely ignore this email.\n\n— CodeChef PESUECC Chapter`,
html: resetEmailHtml(link),
});
return { ok: true, devLink: canRevealSecretInResponse() ? link : undefined };
}
export interface ResetResult {
ok: boolean;
error?: string;
}
/** Consumes a reset token and sets the new password. */
export async function resetPassword(
token: string,
password: string,
): Promise<ResetResult> {
if (password.length < 8) {
return { ok: false, error: "Password must be at least 8 characters." };
}
const now = Date.now();
const db = getDb();
const rows = await db
.select()
.from(passwordResets)
.where(eq(passwordResets.tokenHash, hashToken(token)))
.limit(1);
const row = rows[0];
if (!row) {
return { ok: false, error: "This reset link is invalid or already used." };
}
if (now > row.expiresAt) {
await db.delete(passwordResets).where(eq(passwordResets.id, row.id));
return { ok: false, error: "This reset link has expired — request a new one." };
}
// Set the new password AND bump the session epoch in one statement, so every
// session issued before this reset is immediately invalidated.
await db
.update(users)
.set({
passwordHash: hashPassword(password),
sessionEpoch: sql`${users.sessionEpoch} + 1`,
})
.where(eq(users.id, row.userId));
await db
.delete(passwordResets)
.where(eq(passwordResets.userId, row.userId));
return { ok: true };
}