72 lines
2.3 KiB
TypeScript
72 lines
2.3 KiB
TypeScript
"use server";
|
|
|
|
import { eq } from "drizzle-orm";
|
|
import { redirect } from "next/navigation";
|
|
import { db } from "@/db";
|
|
import { users } from "@/db/schema";
|
|
import {
|
|
clearSessionCookie,
|
|
readSessionCookie,
|
|
setSessionCookie,
|
|
} from "@/lib/auth/cookies";
|
|
import { hashPassword, verifyPassword } from "@/lib/auth/password";
|
|
import { createSession, deleteExpiredSessions, deleteSession } from "@/lib/auth/session";
|
|
import type { FormState } from "@/lib/forms";
|
|
import { zodErrorToFormState } from "@/lib/forms";
|
|
import { loginFormSchema } from "@/lib/validation";
|
|
|
|
const GENERIC_LOGIN_ERROR = "Invalid username or password.";
|
|
|
|
// Verified against when the username doesn't exist, so both failure paths
|
|
// cost one scrypt derivation (no username-probing timing signal).
|
|
let dummyHashPromise: Promise<string> | null = null;
|
|
function dummyHash(): Promise<string> {
|
|
dummyHashPromise ??= hashPassword("dummy-password-for-timing");
|
|
return dummyHashPromise;
|
|
}
|
|
|
|
export async function loginAction(_prev: FormState, formData: FormData): Promise<FormState> {
|
|
const parsed = loginFormSchema.safeParse({
|
|
username: formData.get("username"),
|
|
password: formData.get("password"),
|
|
});
|
|
if (!parsed.success) return zodErrorToFormState(parsed.error);
|
|
|
|
let ok = false;
|
|
try {
|
|
const [user] = await db
|
|
.select()
|
|
.from(users)
|
|
.where(eq(users.username, parsed.data.username))
|
|
.limit(1);
|
|
|
|
const storedHash = user?.passwordHash ?? (await dummyHash());
|
|
const passwordOk = await verifyPassword(storedHash, parsed.data.password);
|
|
ok = passwordOk && user !== undefined;
|
|
|
|
if (ok && user) {
|
|
await deleteExpiredSessions();
|
|
const { token, expiresAt } = await createSession(user.id);
|
|
await setSessionCookie(token, expiresAt);
|
|
}
|
|
} catch (error) {
|
|
console.error("loginAction failed", error);
|
|
return { formError: "Could not sign in right now. Please try again." };
|
|
}
|
|
|
|
if (!ok) return { formError: GENERIC_LOGIN_ERROR };
|
|
redirect("/admin");
|
|
}
|
|
|
|
export async function logoutAction(): Promise<void> {
|
|
try {
|
|
const token = await readSessionCookie();
|
|
if (token) await deleteSession(token);
|
|
} catch (error) {
|
|
// Losing the DB row is not fatal — the cookie is cleared regardless.
|
|
console.error("logoutAction failed", error);
|
|
}
|
|
await clearSessionCookie();
|
|
redirect("/admin/login");
|
|
}
|