summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJoe Carstairs <me@joeac.net>2025-12-18 11:03:32 +0000
committerJoe Carstairs <me@joeac.net>2025-12-18 11:03:32 +0000
commit40d6c7f248c3fe506690ee3ccb3894f952e164ed (patch)
treefe135c14cb58385d109a3bfcadde46dc5ffcafba
parenta81d1de1e58ee2c314dbbea941dcb4e4a776ed84 (diff)
otp actions
-rw-r--r--website/src/actions/index.ts1
-rw-r--r--website/src/actions/otp/otp.ts7
-rw-r--r--website/src/actions/otp/send-otp.ts51
-rw-r--r--website/src/actions/otp/verify-otp.ts48
4 files changed, 107 insertions, 0 deletions
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" <me@joeac.net>`,
+ 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;
+};