forked from codechefPesuecc/CodeChef-PESUECC-Chapter
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathverification.ts
More file actions
134 lines (120 loc) · 4.13 KB
/
Copy pathverification.ts
File metadata and controls
134 lines (120 loc) · 4.13 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
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
import crypto from "node:crypto";
import { eq } from "drizzle-orm";
import { getDb } from "@/server/db";
import { emailVerifications, users } from "@/server/db/schema";
import { hashPassword, verifyPassword } from "@/server/auth/password";
import { sendEmail, canRevealSecretInResponse } from "@/server/email";
import { otpEmailHtml } from "@/server/emailTemplates";
/**
* Email OTP verification. Codes are 6 digits, hashed at rest (scrypt, same as
* passwords), single active row per user, expiring in 10 minutes with a capped
* number of attempts and a resend cooldown. Enforcement of "verified before you
* can submit" is gated elsewhere by REQUIRE_EMAIL_VERIFICATION.
*/
const OTP_TTL_MS = 10 * 60 * 1000; // 10 minutes
const MAX_ATTEMPTS = 5;
const RESEND_COOLDOWN_MS = 60 * 1000; // 1 minute between sends
function sixDigitCode(): string {
return String(crypto.randomInt(0, 1_000_000)).padStart(6, "0");
}
export interface CreateOtpResult {
ok: boolean;
error?: string;
cooldownMs?: number;
/** Present only under the dev console transport, so the UI can show the code. */
devCode?: string;
}
/** Generates a fresh OTP for the user, stores its hash, and emails it. */
export async function createAndSendOtp(
userId: string,
email: string,
): Promise<CreateOtpResult> {
const now = Date.now();
const db = getDb();
const existing = await db
.select()
.from(emailVerifications)
.where(eq(emailVerifications.userId, userId))
.limit(1);
if (existing[0] && now - existing[0].createdAt < RESEND_COOLDOWN_MS) {
return {
ok: false,
error: "Please wait a moment before requesting another code.",
cooldownMs: RESEND_COOLDOWN_MS - (now - existing[0].createdAt),
};
}
const code = sixDigitCode();
// One active row per user — replace any previous.
await db
.delete(emailVerifications)
.where(eq(emailVerifications.userId, userId));
await db.insert(emailVerifications).values({
id: crypto.randomUUID(),
userId,
email,
codeHash: hashPassword(code),
expiresAt: now + OTP_TTL_MS,
attempts: 0,
createdAt: now,
});
const sent = await sendEmail({
to: email,
subject: "Your CodeChef PESUECC Arena verification code",
text: `Your verification code is ${code}. It expires in 10 minutes.\n\nIf you didn't request this, you can ignore this email.\n\n— CodeChef PESUECC Chapter`,
html: otpEmailHtml(code),
});
if (!sent.ok) {
return { ok: false, error: sent.error ?? "Could not send the email." };
}
return { ok: true, devCode: canRevealSecretInResponse() ? code : undefined };
}
export interface VerifyResult {
ok: boolean;
error?: string;
}
/** Checks a submitted OTP, marking the user verified on success. */
export async function verifyOtp(
userId: string,
code: string,
): Promise<VerifyResult> {
const now = Date.now();
const db = getDb();
const rows = await db
.select()
.from(emailVerifications)
.where(eq(emailVerifications.userId, userId))
.limit(1);
const row = rows[0];
if (!row) return { ok: false, error: "No pending code — request a new one." };
if (now > row.expiresAt) {
await db
.delete(emailVerifications)
.where(eq(emailVerifications.userId, userId));
return { ok: false, error: "That code has expired — request a new one." };
}
if (row.attempts >= MAX_ATTEMPTS) {
await db
.delete(emailVerifications)
.where(eq(emailVerifications.userId, userId));
return { ok: false, error: "Too many attempts — request a new code." };
}
if (!verifyPassword(code, row.codeHash)) {
await db
.update(emailVerifications)
.set({ attempts: row.attempts + 1 })
.where(eq(emailVerifications.userId, userId));
const left = MAX_ATTEMPTS - (row.attempts + 1);
return {
ok: false,
error:
left > 0
? `Incorrect code — ${left} attempt${left === 1 ? "" : "s"} left.`
: "Too many attempts — request a new code.",
};
}
await db.update(users).set({ emailVerified: true }).where(eq(users.id, userId));
await db
.delete(emailVerifications)
.where(eq(emailVerifications.userId, userId));
return { ok: true };
}