mirror of
https://gitea.gofwd.group/dstrawsb/ballistic-builder.git
synced 2025-12-06 10:46:44 -05:00
lucia authentication
This commit is contained in:
282
src/lib/auth/actions.ts
Normal file
282
src/lib/auth/actions.ts
Normal 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
55
src/lib/auth/index.ts
Normal 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"> {}
|
||||
40
src/lib/auth/validate-request.ts
Normal file
40
src/lib/auth/validate-request.ts
Normal 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);
|
||||
Reference in New Issue
Block a user