import { redirect, type Session } from "@remix-run/node"; import type { FormStrategyVerifyParams } from "remix-auth-form"; import SecurePassword from "secure-password"; import type { MembershipRole, Organization, PhoneNumber, TwilioAccount, User } from "@prisma/client"; import db from "./db.server"; import logger from "./logger.server"; import authenticator from "./authenticator.server"; import { AuthenticationError, NotFoundError } from "./errors"; import { commitSession, destroySession, getSession } from "./session.server"; type SessionTwilioAccount = Pick< TwilioAccount, "accountSid" | "subAccountSid" | "subAccountAuthToken" | "apiKeySid" | "apiKeySecret" | "twimlAppSid" >; type SessionOrganization = Pick & { role: MembershipRole }; type SessionPhoneNumber = Pick; export type SessionUser = Pick; export type SessionData = { user: SessionUser; organization: SessionOrganization; phoneNumber: SessionPhoneNumber | null; twilioAccount: SessionTwilioAccount | null; }; const SP = new SecurePassword(); export async function login({ form }: FormStrategyVerifyParams): Promise { const email = form.get("email"); const password = form.get("password"); const isEmailValid = typeof email === "string" && email.length > 0; const isPasswordValid = typeof password === "string" && password.length > 0; if (!isEmailValid && !isPasswordValid) { throw new AuthenticationError("Email and password are required"); } if (!isEmailValid) { throw new AuthenticationError("Email is required"); } if (!isPasswordValid) { throw new AuthenticationError("Password is required"); } const user = await db.user.findUnique({ where: { email: email.toLowerCase() } }); if (!user || !user.hashedPassword) { throw new AuthenticationError("Incorrect password"); } switch (await verifyPassword(user.hashedPassword, password)) { case SecurePassword.VALID: break; case SecurePassword.VALID_NEEDS_REHASH: // Upgrade hashed password with a more secure hash const improvedHash = await hashPassword(password); await db.user.update({ where: { id: user.id }, data: { hashedPassword: improvedHash } }); break; default: logger.warn(`Tried to log into account with email=${email.toLowerCase()} with an incorrect password`); throw new AuthenticationError("Incorrect password"); } try { return await buildSessionData(user.id); } catch (error: any) { if (error instanceof AuthenticationError) { throw error; } throw new AuthenticationError("Incorrect password"); } } export async function verifyPassword(hashedPassword: string, password: string) { try { return await SP.verify(Buffer.from(password), Buffer.from(hashedPassword, "base64")); } catch (error) { logger.error(error); return false; } } export async function hashPassword(password: string) { const hashedBuffer = await SP.hash(Buffer.from(password)); return hashedBuffer.toString("base64"); } type AuthenticateParams = { email: string; password: string; request: Request; successRedirect?: string | null; failureRedirect?: string; }; export async function authenticate({ email, password, request, successRedirect, failureRedirect = "/sign-in", }: AuthenticateParams) { const body = new URLSearchParams({ email, password }); const signInRequest = new Request(request.url, { body, method: "post", headers: request.headers, }); const sessionData = await authenticator.authenticate("email-password", signInRequest, { failureRedirect }); const session = await getSession(request); session.set(authenticator.sessionKey, sessionData); const redirectTo = successRedirect ?? "/messages"; return redirect(redirectTo, { headers: { "Set-Cookie": await commitSession(session) }, }); } export function getErrorMessage(session: Session) { const authError = session.get(authenticator.sessionErrorKey || "auth:error"); return authError?.message; } export async function requireLoggedOut(request: Request) { const user = await authenticator.isAuthenticated(request); if (user) { throw redirect("/messages"); } } export async function requireLoggedIn(request: Request) { const user = await authenticator.isAuthenticated(request); if (!user) { const signInUrl = "/sign-in"; const redirectTo = buildRedirectTo(new URL(request.url)); const searchParams = new URLSearchParams({ redirectTo }); throw redirect(`${signInUrl}?${searchParams.toString()}`, { headers: { "Set-Cookie": await destroySession(await getSession(request)) }, }); } return user; } function buildRedirectTo(url: URL) { let redirectTo = url.pathname; const searchParams = url.searchParams.toString(); if (searchParams.length > 0) { redirectTo += `?${searchParams}`; } return encodeURIComponent(redirectTo); } export async function refreshSessionData(request: Request) { const { user: { id }, } = await requireLoggedIn(request); const user = await db.user.findUnique({ where: { id } }); if (!user || !user.hashedPassword) { logger.warn(`User with id=${id} not found`); throw new AuthenticationError("Could not refresh session, user does not exist"); } const sessionData = await buildSessionData(id); const session = await getSession(request); session.set(authenticator.sessionKey, sessionData); return { session, sessionData: sessionData }; } async function buildSessionData(id: string): Promise { const user = await db.user.findUnique({ where: { id }, include: { memberships: { select: { organization: { select: { id: true, twilioAccount: { select: { accountSid: true, subAccountSid: true, subAccountAuthToken: true, apiKeySid: true, apiKeySecret: true, twimlAppSid: true, }, }, }, }, role: true, }, }, }, }); if (!user) { logger.warn(`User with id=${id} not found`); throw new NotFoundError(`User with id=${id} not found`); } const { hashedPassword, memberships, ...rest } = user; const organizations = memberships.map((membership) => ({ ...membership.organization, role: membership.role, })); const { twilioAccount, ...organization } = organizations[0]; const phoneNumber = await db.phoneNumber.findUnique({ where: { organizationId_isCurrent: { organizationId: organization.id, isCurrent: true } }, }); return { user: rest, organization, phoneNumber, twilioAccount, }; }