lucia authentication

This commit is contained in:
2025-01-28 15:09:06 -05:00
parent 84e5c9e399
commit 0ab4c88264
121 changed files with 5613 additions and 66 deletions

282
src/lib/auth/actions.ts Normal file
View File

@@ -0,0 +1,282 @@
"use server";
/* eslint @typescript-eslint/no-explicit-any:0, @typescript-eslint/prefer-optional-chain:0 */
import { z } from "zod";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { generateId, Scrypt } from "lucia";
import { isWithinExpirationDate, TimeSpan, createDate } from "oslo";
import { generateRandomString, alphabet } from "oslo/crypto";
import { eq } from "drizzle-orm";
import { lucia } from "@/lib/auth";
import { db } from "@/server/db";
import {
loginSchema,
signupSchema,
type LoginInput,
type SignupInput,
resetPasswordSchema,
} from "@/lib/validators/auth";
import { emailVerificationCodes, passwordResetTokens, users } from "@schemas/schema";
import { sendMail, EmailTemplate } from "@/lib/email";
import { validateRequest } from "@/lib/auth/validate-request";
import { Paths } from "../constants";
import { env } from "@/env";
export interface ActionResponse<T> {
fieldError?: Partial<Record<keyof T, string | undefined>>;
formError?: string;
}
export async function login(_: any, formData: FormData): Promise<ActionResponse<LoginInput>> {
const obj = Object.fromEntries(formData.entries());
const parsed = loginSchema.safeParse(obj);
if (!parsed.success) {
const err = parsed.error.flatten();
return {
fieldError: {
email: err.fieldErrors.email?.[0],
password: err.fieldErrors.password?.[0],
},
};
}
const { email, password } = parsed.data;
const existingUser = await db.query.users.findFirst({
where: (table, { eq }) => eq(table.email, email),
});
if (!existingUser || !existingUser?.hashedPassword) {
return {
formError: "Incorrect email or password",
};
}
const validPassword = await new Scrypt().verify(existingUser.hashedPassword, password);
if (!validPassword) {
return {
formError: "Incorrect email or password",
};
}
const session = await lucia.createSession(existingUser.id, {});
const sessionCookie = lucia.createSessionCookie(session.id);
(await cookies()).set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
return redirect(Paths.Home);
}
export async function signup(_: any, formData: FormData): Promise<ActionResponse<SignupInput>> {
const obj = Object.fromEntries(formData.entries());
const parsed = signupSchema.safeParse(obj);
if (!parsed.success) {
const err = parsed.error.flatten();
return {
fieldError: {
email: err.fieldErrors.email?.[0],
password: err.fieldErrors.password?.[0],
},
};
}
const { email, password } = parsed.data;
const existingUser = await db.query.users.findFirst({
where: (table, { eq }) => eq(table.email, email),
columns: { email: true },
});
if (existingUser) {
return {
formError: "Cannot create account with that email",
};
}
const userId = generateId(21);
const hashedPassword = await new Scrypt().hash(password);
await db.insert(users).values({
id: userId,
email,
hashedPassword,
});
const verificationCode = await generateEmailVerificationCode(userId, email);
await sendMail(email, EmailTemplate.EmailVerification, { code: verificationCode });
const session = await lucia.createSession(userId, {});
const sessionCookie = lucia.createSessionCookie(session.id);
(await cookies()).set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
return redirect(Paths.VerifyEmail);
}
export async function logout(): Promise<{ error: string } | void> {
const { session } = await validateRequest();
if (!session) {
return {
error: "No session found",
};
}
await lucia.invalidateSession(session.id);
const sessionCookie = lucia.createBlankSessionCookie();
(await cookies()).set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
return redirect("/");
}
export async function resendVerificationEmail(): Promise<{
error?: string;
success?: boolean;
}> {
const { user } = await validateRequest();
if (!user) {
return redirect(Paths.Login);
}
const lastSent = await db.query.emailVerificationCodes.findFirst({
where: (table, { eq }) => eq(table.userId, user.id),
columns: { expiresAt: true },
});
if (lastSent && isWithinExpirationDate(lastSent.expiresAt)) {
return {
error: `Please wait ${timeFromNow(lastSent.expiresAt)} before resending`,
};
}
const verificationCode = await generateEmailVerificationCode(user.id, user.email);
await sendMail(user.email, EmailTemplate.EmailVerification, { code: verificationCode });
return { success: true };
}
export async function verifyEmail(_: any, formData: FormData): Promise<{ error: string } | void> {
const code = formData.get("code");
if (typeof code !== "string" || code.length !== 8) {
return { error: "Invalid code" };
}
const { user } = await validateRequest();
if (!user) {
return redirect(Paths.Login);
}
const dbCode = await db.transaction(async (tx) => {
const item = await tx.query.emailVerificationCodes.findFirst({
where: (table, { eq }) => eq(table.userId, user.id),
});
if (item) {
await tx.delete(emailVerificationCodes).where(eq(emailVerificationCodes.id, item.id));
}
return item;
});
if (!dbCode || dbCode.code !== code) return { error: "Invalid verification code" };
if (!isWithinExpirationDate(dbCode.expiresAt)) return { error: "Verification code expired" };
if (dbCode.email !== user.email) return { error: "Email does not match" };
await lucia.invalidateUserSessions(user.id);
await db.update(users).set({ emailVerified: true }).where(eq(users.id, user.id));
const session = await lucia.createSession(user.id, {});
const sessionCookie = lucia.createSessionCookie(session.id);
(await cookies()).set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
redirect(Paths.Home);
}
export async function sendPasswordResetLink(
_: any,
formData: FormData,
): Promise<{ error?: string; success?: boolean }> {
const email = formData.get("email");
const parsed = z.string().trim().email().safeParse(email);
if (!parsed.success) {
return { error: "Provided email is invalid." };
}
try {
const user = await db.query.users.findFirst({
where: (table, { eq }) => eq(table.email, parsed.data),
});
if (!user || !user.emailVerified) return { error: "Provided email is invalid." };
const verificationToken = await generatePasswordResetToken(user.id);
const verificationLink = `${env.NEXT_PUBLIC_APP_URL}/reset-password/${verificationToken}`;
await sendMail(user.email, EmailTemplate.PasswordReset, { link: verificationLink });
return { success: true };
} catch (error) {
return { error: "Failed to send verification email." };
}
}
export async function resetPassword(
_: any,
formData: FormData,
): Promise<{ error?: string; success?: boolean }> {
const obj = Object.fromEntries(formData.entries());
const parsed = resetPasswordSchema.safeParse(obj);
if (!parsed.success) {
const err = parsed.error.flatten();
return {
error: err.fieldErrors.password?.[0] ?? err.fieldErrors.token?.[0],
};
}
const { token, password } = parsed.data;
const dbToken = await db.transaction(async (tx) => {
const item = await tx.query.passwordResetTokens.findFirst({
where: (table, { eq }) => eq(table.id, token),
});
if (item) {
await tx.delete(passwordResetTokens).where(eq(passwordResetTokens.id, item.id));
}
return item;
});
if (!dbToken) return { error: "Invalid password reset link" };
if (!isWithinExpirationDate(dbToken.expiresAt)) return { error: "Password reset link expired." };
await lucia.invalidateUserSessions(dbToken.userId);
const hashedPassword = await new Scrypt().hash(password);
await db.update(users).set({ hashedPassword }).where(eq(users.id, dbToken.userId));
const session = await lucia.createSession(dbToken.userId, {});
const sessionCookie = lucia.createSessionCookie(session.id);
(await cookies()).set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
redirect(Paths.Home);
}
const timeFromNow = (time: Date) => {
const now = new Date();
const diff = time.getTime() - now.getTime();
const minutes = Math.floor(diff / 1000 / 60);
const seconds = Math.floor(diff / 1000) % 60;
return `${minutes}m ${seconds}s`;
};
async function generateEmailVerificationCode(userId: string, email: string): Promise<string> {
await db.delete(emailVerificationCodes).where(eq(emailVerificationCodes.userId, userId));
const code = generateRandomString(8, alphabet("0-9")); // 8 digit code
await db.insert(emailVerificationCodes).values({
userId,
email,
code,
expiresAt: createDate(new TimeSpan(10, "m")), // 10 minutes
});
return code;
}
async function generatePasswordResetToken(userId: string): Promise<string> {
await db.delete(passwordResetTokens).where(eq(passwordResetTokens.userId, userId));
const tokenId = generateId(40);
await db.insert(passwordResetTokens).values({
id: tokenId,
userId,
expiresAt: createDate(new TimeSpan(2, "h")),
});
return tokenId;
}

55
src/lib/auth/index.ts Normal file
View File

@@ -0,0 +1,55 @@
import { Lucia, TimeSpan } from "lucia";
import { Discord } from "arctic";
import { DrizzlePostgreSQLAdapter } from "@lucia-auth/adapter-drizzle";
import { env } from "@/env.js";
import { db } from "@/server/db";
import { sessions, users, type User as DbUser } from "@schemas/schema";
import { absoluteUrl } from "@/lib/utils"
// Uncomment the following lines if you are using nodejs 18 or lower. Not required in Node.js 20, CloudFlare Workers, Deno, Bun, and Vercel Edge Functions.
// import { webcrypto } from "node:crypto";
// globalThis.crypto = webcrypto as Crypto;
const adapter = new DrizzlePostgreSQLAdapter(db, sessions, users);
export const lucia = new Lucia(adapter, {
getSessionAttributes: (/* attributes */) => {
return {};
},
getUserAttributes: (attributes) => {
return {
id: attributes.id,
email: attributes.email,
emailVerified: attributes.emailVerified,
avatar: attributes.avatar,
createdAt: attributes.createdAt,
updatedAt: attributes.updatedAt,
};
},
sessionExpiresIn: new TimeSpan(30, "d"),
sessionCookie: {
name: "session",
expires: false, // session cookies have very long lifespan (2 years)
attributes: {
secure: env.NODE_ENV === "production",
},
},
});
export const discord = new Discord(
env.DISCORD_CLIENT_ID,
env.DISCORD_CLIENT_SECRET,
absoluteUrl("/login/discord/callback")
);
declare module "lucia" {
interface Register {
Lucia: typeof lucia;
DatabaseSessionAttributes: DatabaseSessionAttributes;
DatabaseUserAttributes: DatabaseUserAttributes;
}
}
interface DatabaseSessionAttributes {}
interface DatabaseUserAttributes extends Omit<DbUser, "hashedPassword"> {}

View File

@@ -0,0 +1,40 @@
import { cache } from "react";
import { cookies } from "next/headers";
import type { Session, User } from "lucia";
import { lucia } from "@/lib/auth";
export const uncachedValidateRequest = async (): Promise<
{ user: User; session: Session } | { user: null; session: null }
> => {
const sessionId = (await cookies()).get(lucia.sessionCookieName)?.value ?? null;
//const sessionId = cookies().get(lucia.sessionCookieName)?.value ?? null;
if (!sessionId) {
return { user: null, session: null };
}
const result = await lucia.validateSession(sessionId);
// next.js throws when you attempt to set cookie when rendering page
try {
if (result.session && result.session.fresh) {
const sessionCookie = lucia.createSessionCookie(result.session.id);
(await cookies()).set(
sessionCookie.name,
sessionCookie.value,
sessionCookie.attributes,
);
}
if (!result.session) {
const sessionCookie = lucia.createBlankSessionCookie();
(await cookies()).set(
sessionCookie.name,
sessionCookie.value,
sessionCookie.attributes,
);
}
} catch {
console.error("Failed to set session cookie");
}
return result;
};
export const validateRequest = cache(uncachedValidateRequest);