remixed v0
This commit is contained in:
178
app/utils/auth.server.ts
Normal file
178
app/utils/auth.server.ts
Normal file
@ -0,0 +1,178 @@
|
||||
import { type Session, redirect } from "@remix-run/node";
|
||||
import type { FormStrategyVerifyParams } from "remix-auth-form";
|
||||
import SecurePassword from "secure-password";
|
||||
import type { MembershipRole, Organization, User } from "@prisma/client";
|
||||
|
||||
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";
|
||||
|
||||
export type SessionOrganization = Pick<Organization, "name" | "id"> & { role: MembershipRole };
|
||||
export type SessionUser = Omit<User, "hashedPassword"> & {
|
||||
organizations: SessionOrganization[];
|
||||
};
|
||||
|
||||
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: {
|
||||
select: { name: true, id: true },
|
||||
},
|
||||
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) {
|
||||
throw redirect("/sign-in", {
|
||||
headers: { "Set-Cookie": await destroySession(await getSession(request)) },
|
||||
});
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function refreshSessionData(request: Request) {
|
||||
const { id } = await requireLoggedIn(request);
|
||||
const user = await db.user.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
memberships: {
|
||||
select: {
|
||||
organization: {
|
||||
select: { name: true, id: true },
|
||||
},
|
||||
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 };
|
||||
}
|
11
app/utils/authenticator.server.ts
Normal file
11
app/utils/authenticator.server.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { Authenticator } from "remix-auth";
|
||||
import { FormStrategy } from "remix-auth-form";
|
||||
|
||||
import { sessionStorage } from "./session.server";
|
||||
import { type SessionUser, login } from "./auth.server";
|
||||
|
||||
const authenticator = new Authenticator<SessionUser>(sessionStorage);
|
||||
|
||||
authenticator.use(new FormStrategy(login), "email-password");
|
||||
|
||||
export default authenticator;
|
23
app/utils/db.server.ts
Normal file
23
app/utils/db.server.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
let db: PrismaClient;
|
||||
|
||||
declare global {
|
||||
var __db: PrismaClient | undefined;
|
||||
}
|
||||
|
||||
// this is needed because in development we don't want to restart
|
||||
// the server with every change, but we want to make sure we don't
|
||||
// create a new connection to the DB with every change either.
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
db = new PrismaClient();
|
||||
db.$connect();
|
||||
} else {
|
||||
if (!global.__db) {
|
||||
global.__db = new PrismaClient();
|
||||
global.__db.$connect();
|
||||
}
|
||||
db = global.__db;
|
||||
}
|
||||
|
||||
export default db;
|
22
app/utils/errors.ts
Normal file
22
app/utils/errors.ts
Normal file
@ -0,0 +1,22 @@
|
||||
export class AuthenticationError extends Error {
|
||||
name = "AuthenticationError";
|
||||
statusCode = 401;
|
||||
|
||||
constructor(message = "You must be logged in to access this") {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export class ResetPasswordError extends Error {
|
||||
name = "ResetPasswordError";
|
||||
message = "Reset password link is invalid or it has expired.";
|
||||
}
|
||||
|
||||
export class NotFoundError extends Error {
|
||||
name = "NotFoundError";
|
||||
statusCode = 404;
|
||||
|
||||
constructor(message = "This could not be found") {
|
||||
super(message);
|
||||
}
|
||||
}
|
6
app/utils/logger.server.ts
Normal file
6
app/utils/logger.server.ts
Normal file
@ -0,0 +1,6 @@
|
||||
// TODO: s/\.server// when https://github.com/fullstack-build/tslog/issues/88 gets implemented
|
||||
import { Logger } from "tslog";
|
||||
|
||||
const logger = new Logger();
|
||||
|
||||
export default logger;
|
39
app/utils/mailer.server.ts
Normal file
39
app/utils/mailer.server.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { type SendMailOptions, createTransport } from "nodemailer";
|
||||
import { Credentials, SES } from "aws-sdk";
|
||||
import previewEmail from "preview-email";
|
||||
|
||||
import serverConfig from "~/config/config.server";
|
||||
|
||||
type SendEmailParams = {
|
||||
text?: string;
|
||||
html?: string;
|
||||
subject: string;
|
||||
recipients: string | string[];
|
||||
};
|
||||
|
||||
export default async function sendEmail({ text, html, subject, recipients }: SendEmailParams) {
|
||||
const email: SendMailOptions = {
|
||||
text,
|
||||
html,
|
||||
subject,
|
||||
encoding: "UTF-8",
|
||||
to: recipients,
|
||||
from: serverConfig.awsSes.fromEmail,
|
||||
};
|
||||
|
||||
if (process.env.NODE_ENV !== "production" || process.env.CI) {
|
||||
return previewEmail(email);
|
||||
}
|
||||
|
||||
const transporter = createTransport({
|
||||
SES: new SES({
|
||||
region: serverConfig.awsSes.awsRegion,
|
||||
credentials: new Credentials({
|
||||
accessKeyId: serverConfig.awsSes.accessKeyId,
|
||||
secretAccessKey: serverConfig.awsSes.secretAccessKey,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
return transporter.sendMail(email);
|
||||
}
|
24
app/utils/organization.server.ts
Normal file
24
app/utils/organization.server.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import type { Organization } from "@prisma/client";
|
||||
|
||||
import db from "~/utils/db.server";
|
||||
|
||||
export async function deleteOrganizationEntities(organization: Organization) {
|
||||
// await cancelSubscription(organization.stripeSubscriptionId);
|
||||
|
||||
// delete user accounts who were only in this one organization
|
||||
await db.user.deleteMany({
|
||||
where: {
|
||||
memberships: {
|
||||
every: { organizationId: organization.id },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await db.membership.deleteMany({
|
||||
where: {
|
||||
organizationId: organization.id,
|
||||
},
|
||||
});
|
||||
|
||||
await db.organization.delete({ where: { id: organization.id } });
|
||||
}
|
64
app/utils/queue.server.ts
Normal file
64
app/utils/queue.server.ts
Normal file
@ -0,0 +1,64 @@
|
||||
import { type Processor, type JobsOptions, Queue as BullQueue, Worker, QueueScheduler } from "bullmq";
|
||||
|
||||
import redis from "./redis.server";
|
||||
import logger from "./logger.server";
|
||||
|
||||
type RegisteredQueue = {
|
||||
queue: BullQueue;
|
||||
worker: Worker;
|
||||
scheduler: QueueScheduler;
|
||||
};
|
||||
|
||||
declare global {
|
||||
var __registeredQueues: Map<string, RegisteredQueue> | undefined;
|
||||
}
|
||||
|
||||
const registeredQueues = global.__registeredQueues || (global.__registeredQueues = new Map<string, RegisteredQueue>());
|
||||
|
||||
export function Queue<Payload>(
|
||||
name: string,
|
||||
handler: Processor<Payload>,
|
||||
defaultJobOptions: JobsOptions = {},
|
||||
): BullQueue<Payload> {
|
||||
if (registeredQueues.has(name)) {
|
||||
return registeredQueues.get(name)!.queue;
|
||||
}
|
||||
|
||||
const jobOptions: JobsOptions = {
|
||||
attempts: 3,
|
||||
backoff: { type: "exponential", delay: 1000 },
|
||||
...defaultJobOptions,
|
||||
};
|
||||
const queue = new BullQueue<Payload>(name, {
|
||||
defaultJobOptions: jobOptions,
|
||||
connection: redis,
|
||||
});
|
||||
const worker = new Worker<Payload>(name, handler, { connection: redis });
|
||||
const scheduler = new QueueScheduler(name, { connection: redis });
|
||||
registeredQueues.set(name, { queue, worker, scheduler });
|
||||
|
||||
return queue;
|
||||
}
|
||||
|
||||
export function CronJob(
|
||||
name: string,
|
||||
handler: Processor<undefined>,
|
||||
cronSchedule: string,
|
||||
defaultJobOptions: Exclude<JobsOptions, "repeat"> = {},
|
||||
) {
|
||||
const jobOptions: JobsOptions = {
|
||||
...defaultJobOptions,
|
||||
repeat: { cron: cronSchedule },
|
||||
};
|
||||
|
||||
return function register() {
|
||||
if (registeredQueues.has(name)) {
|
||||
return registeredQueues.get(name)!.queue;
|
||||
}
|
||||
|
||||
const queue = Queue<undefined>(name, handler, jobOptions);
|
||||
queue.add(name, undefined, jobOptions);
|
||||
logger.info(`registered cron job "${name}"`);
|
||||
return queue;
|
||||
};
|
||||
}
|
31
app/utils/redis.server.ts
Normal file
31
app/utils/redis.server.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import Redis, { type Redis as RedisType, type RedisOptions } from "ioredis";
|
||||
|
||||
import serverConfig from "~/config/config.server";
|
||||
|
||||
let redis: RedisType;
|
||||
|
||||
declare global {
|
||||
var __redis: RedisType | undefined;
|
||||
}
|
||||
|
||||
const redisOptions: RedisOptions = {
|
||||
maxRetriesPerRequest: null,
|
||||
enableReadyCheck: false,
|
||||
password: serverConfig.redis.password,
|
||||
family: 6, // Fly servers use IPv6 only
|
||||
port: 6379,
|
||||
};
|
||||
|
||||
// this is needed because in development we don't want to restart
|
||||
// the server with every change, but we want to make sure we don't
|
||||
// create a new connection to the Redis with every change either.
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
redis = new Redis(serverConfig.redis.url, redisOptions);
|
||||
} else {
|
||||
if (!global.__redis) {
|
||||
global.__redis = new Redis(serverConfig.redis.url, redisOptions);
|
||||
}
|
||||
redis = global.__redis;
|
||||
}
|
||||
|
||||
export default redis;
|
8
app/utils/seo.ts
Normal file
8
app/utils/seo.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { initSeo } from "remix-seo";
|
||||
|
||||
export const { getSeo, getSeoMeta, getSeoLinks } = initSeo({
|
||||
title: "",
|
||||
titleTemplate: "%s | Shellphone",
|
||||
description: "",
|
||||
defaultTitle: "Shellphone",
|
||||
});
|
92
app/utils/session.server.ts
Normal file
92
app/utils/session.server.ts
Normal file
@ -0,0 +1,92 @@
|
||||
import { type Session, type SessionIdStorageStrategy, createSessionStorage } from "@remix-run/node";
|
||||
|
||||
import serverConfig from "~/config/config.server";
|
||||
import db from "./db.server";
|
||||
import logger from "./logger.server";
|
||||
|
||||
const SECOND = 1;
|
||||
const MINUTE = 60 * SECOND;
|
||||
const HOUR = 60 * MINUTE;
|
||||
const DAY = 24 * HOUR;
|
||||
|
||||
export const sessionStorage = createDatabaseSessionStorage({
|
||||
cookie: {
|
||||
name: "__session",
|
||||
httpOnly: true,
|
||||
path: "/",
|
||||
sameSite: "lax",
|
||||
secrets: [serverConfig.app.sessionSecret],
|
||||
secure: process.env.NODE_ENV === "production" && process.env.CI !== "true",
|
||||
maxAge: 30 * DAY,
|
||||
},
|
||||
});
|
||||
|
||||
export function getSession(request: Request): Promise<Session> {
|
||||
return sessionStorage.getSession(request.headers.get("Cookie"));
|
||||
}
|
||||
|
||||
export const { commitSession, destroySession, getSession: __getSession } = sessionStorage;
|
||||
|
||||
function createDatabaseSessionStorage({ cookie }: Pick<SessionIdStorageStrategy, "cookie">) {
|
||||
return createSessionStorage({
|
||||
cookie,
|
||||
async createData(sessionData, expiresAt) {
|
||||
let user;
|
||||
if (sessionData.user) {
|
||||
user = { connect: { id: sessionData.user.id } };
|
||||
}
|
||||
const { id } = await db.session.create({
|
||||
data: {
|
||||
expiresAt,
|
||||
user,
|
||||
data: JSON.stringify(sessionData),
|
||||
},
|
||||
});
|
||||
return id;
|
||||
},
|
||||
async readData(id) {
|
||||
const session = await db.session.findUnique({ where: { id } });
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sessionHasExpired = session.expiresAt && session.expiresAt < new Date();
|
||||
if (sessionHasExpired) {
|
||||
await db.session.delete({ where: { id } });
|
||||
return null;
|
||||
}
|
||||
|
||||
return JSON.parse(session.data);
|
||||
},
|
||||
async updateData(id, sessionData, expiresAt) {
|
||||
try {
|
||||
await db.session.update({
|
||||
where: { id },
|
||||
data: {
|
||||
data: JSON.stringify(sessionData),
|
||||
expiresAt,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
if (error.code === "P2025") {
|
||||
logger.warn("Could not update session because it's not in the DB");
|
||||
return;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
async deleteData(id) {
|
||||
try {
|
||||
await db.session.delete({ where: { id } });
|
||||
} catch (error: any) {
|
||||
if (error.code === "P2025") {
|
||||
logger.warn("Could not delete session because it's not in the DB");
|
||||
return;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
10
app/utils/token.server.ts
Normal file
10
app/utils/token.server.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { nanoid } from "nanoid";
|
||||
import crypto from "crypto";
|
||||
|
||||
export function generateToken() {
|
||||
return nanoid(32);
|
||||
}
|
||||
|
||||
export function hashToken(token: string) {
|
||||
return crypto.createHash("sha256").update(token).digest("hex");
|
||||
}
|
34
app/utils/validation.server.ts
Normal file
34
app/utils/validation.server.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import type { z } from "zod";
|
||||
|
||||
type ErrorMessage = string;
|
||||
type Errors<Schema> = Partial<Record<keyof Schema, ErrorMessage>>;
|
||||
export type FormError<Schema extends z.Schema<unknown>> = Partial<
|
||||
Record<keyof Schema["_type"] | "general", ErrorMessage>
|
||||
>;
|
||||
type ValidationResult<Data, Schema> = { data: Data; errors: undefined } | { data: undefined; errors: Errors<Schema> };
|
||||
|
||||
export function validate<Data, Schema = z.Schema<Data>["_type"]>(
|
||||
schema: z.Schema<Data>,
|
||||
value: unknown,
|
||||
): ValidationResult<Data, Schema> {
|
||||
const result = schema.safeParse(value);
|
||||
if (result.success) {
|
||||
return {
|
||||
data: result.data,
|
||||
errors: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const errors: Errors<Schema> = {};
|
||||
result.error.issues.forEach((error) => {
|
||||
const path = error.path[0] as keyof Schema;
|
||||
if (!errors[path]) {
|
||||
errors[path] = error.message;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
data: undefined,
|
||||
errors,
|
||||
};
|
||||
}
|
Reference in New Issue
Block a user