otp actions

This commit is contained in:
2025-12-18 11:03:32 +00:00
parent a81d1de1e5
commit 40d6c7f248
4 changed files with 107 additions and 0 deletions

View File

@@ -2,5 +2,6 @@ import otp from "./otp/otp";
import sendmail from "./sendmail";
export const server = {
otp,
sendmail,
};

View File

@@ -0,0 +1,7 @@
import send from "./send-otp";
import verify from "./verify-otp";
export default {
send,
verify,
};

View File

@@ -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,
});

View File

@@ -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;
};