2022-05-15 16:33:19 +00:00
|
|
|
import { redirect, type Session } from "@remix-run/node";
|
2022-05-14 10:22:06 +00:00
|
|
|
import type { FormStrategyVerifyParams } from "remix-auth-form";
|
|
|
|
import SecurePassword from "secure-password";
|
2022-05-19 22:55:02 +00:00
|
|
|
import type { MembershipRole, Organization, PhoneNumber, User } from "@prisma/client";
|
2022-05-14 10:22:06 +00:00
|
|
|
|
|
|
|
import db from "./db.server";
|
|
|
|
import logger from "./logger.server";
|
|
|
|
import authenticator from "./authenticator.server";
|
|
|
|
import { AuthenticationError } from "./errors";
|
|
|
|
import { commitSession, destroySession, getSession } from "./session.server";
|
|
|
|
|
2022-05-15 16:33:19 +00:00
|
|
|
export type SessionOrganization = Pick<Organization, "id" | "twilioSubAccountSid" | "twilioAccountSid"> & {
|
|
|
|
role: MembershipRole;
|
|
|
|
};
|
2022-05-19 22:55:02 +00:00
|
|
|
export type SessionPhoneNumber = Pick<PhoneNumber, "id" | "number">;
|
2022-05-14 10:22:06 +00:00
|
|
|
export type SessionUser = Omit<User, "hashedPassword"> & {
|
|
|
|
organizations: SessionOrganization[];
|
|
|
|
};
|
2022-05-19 22:55:02 +00:00
|
|
|
export type SessionData = SessionUser & { currentOrganization: SessionOrganization; currentPhoneNumber: SessionPhoneNumber };
|
2022-05-14 10:22:06 +00:00
|
|
|
|
|
|
|
const SP = new SecurePassword();
|
|
|
|
|
|
|
|
export async function login({ form }: FormStrategyVerifyParams): Promise<SessionUser> {
|
|
|
|
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() },
|
|
|
|
include: {
|
|
|
|
memberships: {
|
|
|
|
select: {
|
|
|
|
organization: {
|
2022-05-14 23:29:51 +00:00
|
|
|
select: { id: true, twilioSubAccountSid: true, twilioAccountSid: true },
|
2022-05-14 10:22:06 +00:00
|
|
|
},
|
|
|
|
role: true,
|
|
|
|
},
|
|
|
|
},
|
|
|
|
},
|
|
|
|
});
|
|
|
|
if (!user || !user.hashedPassword) {
|
|
|
|
logger.warn(`User with email=${email.toLowerCase()} not found`);
|
|
|
|
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");
|
|
|
|
}
|
|
|
|
|
|
|
|
const { hashedPassword, memberships, ...rest } = user;
|
|
|
|
const organizations = memberships.map((membership) => ({
|
|
|
|
...membership.organization,
|
|
|
|
role: membership.role,
|
|
|
|
}));
|
|
|
|
|
|
|
|
return {
|
|
|
|
...rest,
|
|
|
|
organizations,
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
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 user = await authenticator.authenticate("email-password", signInRequest, { failureRedirect });
|
|
|
|
const session = await getSession(request);
|
|
|
|
session.set(authenticator.sessionKey, user);
|
|
|
|
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) {
|
2022-05-19 22:55:02 +00:00
|
|
|
const signInUrl = "/sign-in";
|
|
|
|
const redirectTo = buildRedirectTo(new URL(request.url));
|
|
|
|
const searchParams = new URLSearchParams({ redirectTo });
|
|
|
|
|
2022-05-17 23:49:31 +00:00
|
|
|
throw redirect(`${signInUrl}?${searchParams.toString()}`, {
|
2022-05-14 10:22:06 +00:00
|
|
|
headers: { "Set-Cookie": await destroySession(await getSession(request)) },
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
return user;
|
|
|
|
}
|
|
|
|
|
2022-05-17 23:49:31 +00:00
|
|
|
function buildRedirectTo(url: URL) {
|
2022-05-15 16:33:19 +00:00
|
|
|
let redirectTo = url.pathname;
|
|
|
|
const searchParams = url.searchParams.toString();
|
|
|
|
if (searchParams.length > 0) {
|
|
|
|
redirectTo += `?${searchParams}`;
|
|
|
|
}
|
|
|
|
|
|
|
|
return encodeURIComponent(redirectTo);
|
|
|
|
}
|
|
|
|
|
2022-05-14 10:22:06 +00:00
|
|
|
export async function refreshSessionData(request: Request) {
|
|
|
|
const { id } = await requireLoggedIn(request);
|
|
|
|
const user = await db.user.findUnique({
|
|
|
|
where: { id },
|
|
|
|
include: {
|
|
|
|
memberships: {
|
|
|
|
select: {
|
|
|
|
organization: {
|
2022-05-14 23:29:51 +00:00
|
|
|
select: { id: true, twilioSubAccountSid: true, twilioAccountSid: true },
|
2022-05-14 10:22:06 +00:00
|
|
|
},
|
|
|
|
role: true,
|
|
|
|
},
|
|
|
|
},
|
|
|
|
},
|
|
|
|
});
|
|
|
|
if (!user || !user.hashedPassword) {
|
|
|
|
logger.warn(`User with id=${id} not found`);
|
|
|
|
throw new AuthenticationError("Could not refresh session, user does not exist");
|
|
|
|
}
|
|
|
|
|
|
|
|
const { hashedPassword, memberships, ...rest } = user;
|
|
|
|
const organizations = memberships.map((membership) => ({
|
|
|
|
...membership.organization,
|
|
|
|
role: membership.role,
|
|
|
|
}));
|
|
|
|
const sessionUser: SessionUser = {
|
|
|
|
...rest,
|
|
|
|
organizations,
|
|
|
|
};
|
|
|
|
const session = await getSession(request);
|
|
|
|
session.set(authenticator.sessionKey, sessionUser);
|
|
|
|
|
|
|
|
return { session, user: sessionUser };
|
|
|
|
}
|