From 40d6c7f248c3fe506690ee3ccb3894f952e164ed Mon Sep 17 00:00:00 2001 From: Joe Carstairs Date: Thu, 18 Dec 2025 11:03:32 +0000 Subject: [PATCH] otp actions --- website/src/actions/index.ts | 1 + website/src/actions/otp/otp.ts | 7 ++++ website/src/actions/otp/send-otp.ts | 51 +++++++++++++++++++++++++++ website/src/actions/otp/verify-otp.ts | 48 +++++++++++++++++++++++++ 4 files changed, 107 insertions(+) create mode 100644 website/src/actions/otp/otp.ts create mode 100644 website/src/actions/otp/send-otp.ts create mode 100644 website/src/actions/otp/verify-otp.ts diff --git a/website/src/actions/index.ts b/website/src/actions/index.ts index 8f55443..a2dcded 100644 --- a/website/src/actions/index.ts +++ b/website/src/actions/index.ts @@ -2,5 +2,6 @@ import otp from "./otp/otp"; import sendmail from "./sendmail"; export const server = { + otp, sendmail, }; diff --git a/website/src/actions/otp/otp.ts b/website/src/actions/otp/otp.ts new file mode 100644 index 0000000..73c322a --- /dev/null +++ b/website/src/actions/otp/otp.ts @@ -0,0 +1,7 @@ +import send from "./send-otp"; +import verify from "./verify-otp"; + +export default { + send, + verify, +}; diff --git a/website/src/actions/otp/send-otp.ts b/website/src/actions/otp/send-otp.ts new file mode 100644 index 0000000..b349feb --- /dev/null +++ b/website/src/actions/otp/send-otp.ts @@ -0,0 +1,51 @@ +import crypto from "node:crypto"; +import { SENDMAIL_BIN } from "astro:env/server"; +import nodemailer from "nodemailer"; +import { z } from "astro/zod"; +import { defineAction } from "astro:actions"; +import { db, Otp } from "astro:db"; + +export default defineAction({ + input: z.object({ + email: z.string().email(), + name: z.string().optional(), + type: z.enum(["email"]), + }), + handler: sendOtp, +}); + +type OtpParams = { + email: string; + name?: string; + type: "email"; +}; + +async function sendOtp({ email, name }: OtpParams) { + const otp = crypto.randomBytes(3).toString("hex").toLocaleUpperCase(); + const otpPretty = `${otp.slice(0, 3)}-${otp.slice(3)}`; + + const info = await transporter.sendMail({ + from: `"Joe Carstairs" `, + to: `${name ? `"${name}" ` : ""}<${email}>`, + subject: `joeac.net: your OTP is ${otpPretty}`, + text: ` +Someone tried to use this email address on joeac.net. If this was you, +your one-time passcode is ${otpPretty}. If this wasn't you, you don't need +to do anything.`, + }); + console.log( + `Sent OTP (${otpPretty}) to ${email}. Message ID: ${info.messageId}`, + ); + + await db.insert(Otp).values({ + userId: email, + value: otp, + createdAt: Date.now(), + validUntil: Date.now() + 1000 * 60 * 5, + }); +} + +const transporter = nodemailer.createTransport({ + sendmail: true, + path: SENDMAIL_BIN, +}); diff --git a/website/src/actions/otp/verify-otp.ts b/website/src/actions/otp/verify-otp.ts new file mode 100644 index 0000000..726786e --- /dev/null +++ b/website/src/actions/otp/verify-otp.ts @@ -0,0 +1,48 @@ +import { randomBytes } from "node:crypto"; +import { z } from "astro/zod"; +import { defineAction } from "astro:actions"; +import { and, db, eq, gte, Otp, SendmailToken } from "astro:db"; + +export default defineAction({ + input: z.object({ + guess: z.string().length(6), + lenient: z.boolean().default(false), + userId: z.string().nonempty(), + }), + handler: verifyOtp, +}); + +async function verifyOtp({ guess, lenient, userId }: VerifyOtpParams) { + const leniency = lenient ? 1000 * 60 : 0; + const isOtpCorrect = + (await db.$count( + Otp, + and( + eq(Otp.userId, userId), + eq(Otp.value, guess), + gte(Otp.validUntil, Date.now() - leniency), + ), + )) > 0; + + if (!isOtpCorrect) { + return false; + } + + await db.delete(Otp).where(and(eq(Otp.userId, userId), eq(Otp.value, guess))); + + const token = randomBytes(256).toString("hex"); + await db.insert(SendmailToken).values({ + userId, + value: token, + createdAt: Date.now(), + validUntil: Date.now() + 60_000, + }); + + return token; +} + +type VerifyOtpParams = { + guess: string; + lenient: boolean; + userId: string; +};