♻️ Switch to next-auth for handling authentication (#899)

This commit is contained in:
Luke Vella 2023-10-19 09:14:53 +01:00 committed by GitHub
parent 5f9e428432
commit 6fa66da681
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
65 changed files with 1514 additions and 1586 deletions

View file

@ -1,62 +0,0 @@
import { EmailClient, SupportedEmailProviders } from "@rallly/emails";
import { createProxySSGHelpers } from "@trpc/react-query/ssg";
import * as trpc from "@trpc/server";
import * as trpcNext from "@trpc/server/adapters/next";
import { GetServerSidePropsContext } from "next";
import superjson from "superjson";
import { randomid } from "../utils/nanoid";
import { appRouter } from "./routers";
// Avoid use NODE_ENV directly because it will be replaced when using the dev server for e2e tests
const env = process.env["NODE" + "_ENV"];
export async function createContext(
opts: trpcNext.CreateNextContextOptions | GetServerSidePropsContext,
) {
let user = opts.req.session.user;
if (!user) {
user = {
id: `user-${randomid()}`,
isGuest: true,
};
opts.req.session.user = user;
await opts.req.session.save();
}
const emailClient = new EmailClient({
openPreviews: env === "development",
useTestServer: env === "test",
provider: {
name: (process.env.EMAIL_PROVIDER as SupportedEmailProviders) ?? "smtp",
},
mail: {
from: {
name: "Rallly",
address:
(process.env.NOREPLY_EMAIL as string) ||
(process.env.SUPPORT_EMAIL as string),
},
},
});
return {
user,
session: opts.req.session,
req: opts.req,
res: opts.res,
isSelfHosted: process.env.NEXT_PUBLIC_SELF_HOSTED === "true",
emailClient,
};
}
export type Context = trpc.inferAsyncReturnType<typeof createContext>;
export const createSSGHelperFromContext = async (
ctx: GetServerSidePropsContext,
) =>
createProxySSGHelpers({
router: appRouter,
ctx: await createContext(ctx),
transformer: superjson,
});

View file

@ -1,12 +1,10 @@
import { prisma } from "@rallly/database";
import { absoluteUrl } from "@rallly/utils";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { createToken, decryptToken } from "../../session";
import { generateOtp } from "../../utils/nanoid";
import { publicProcedure, router } from "../trpc";
import { LoginTokenPayload, RegistrationTokenPayload } from "../types";
import { RegistrationTokenPayload } from "../types";
// assigns participants and comments created by guests to a user
// we could have multiple guests because a login might be triggered from one device
@ -46,27 +44,8 @@ const mergeGuestsIntoUser = async (userId: string, guestIds: string[]) => {
});
};
const isEmailBlocked = (email: string) => {
if (process.env.ALLOWED_EMAILS) {
const allowedEmails = process.env.ALLOWED_EMAILS.split(",");
// Check whether the email matches enough of the patterns specified in ALLOWED_EMAILS
const isAllowed = allowedEmails.some((allowedEmail) => {
const regex = new RegExp(
`^${allowedEmail
.replace(/[.+?^${}()|[\]\\]/g, "\\$&")
.replaceAll(/[*]/g, ".*")}$`,
);
return regex.test(email);
});
if (!isAllowed) {
return true;
}
}
return false;
};
export const auth = router({
// @deprecated
requestRegistration: publicProcedure
.input(
z.object({
@ -82,7 +61,7 @@ export const auth = router({
| { ok: true; token: string }
| { ok: false; reason: "userAlreadyExists" | "emailNotAllowed" }
> => {
if (isEmailBlocked(input.email)) {
if (ctx.isEmailBlocked?.(input.email)) {
return { ok: false, reason: "emailNotAllowed" };
}
@ -124,6 +103,8 @@ export const auth = router({
z.object({
token: z.string(),
code: z.string(),
timeZone: z.string().optional(),
locale: z.string().optional(),
}),
)
.mutation(async ({ input, ctx }) => {
@ -143,115 +124,17 @@ export const auth = router({
data: {
name,
email,
timeZone: input.timeZone,
locale: input.locale,
},
});
if (ctx.session.user?.isGuest) {
await mergeGuestsIntoUser(user.id, [ctx.session.user.id]);
if (ctx.user.isGuest) {
await mergeGuestsIntoUser(user.id, [ctx.user.id]);
}
ctx.session.user = {
isGuest: false,
id: user.id,
};
await ctx.session.save();
return { ok: true, user };
}),
requestLogin: publicProcedure
.input(
z.object({
email: z.string(),
}),
)
.mutation(
async ({
input,
ctx,
}): Promise<
| { ok: true; token: string }
| { ok: false; reason: "emailNotAllowed" | "userNotFound" }
> => {
if (isEmailBlocked(input.email)) {
return { ok: false, reason: "emailNotAllowed" };
}
const user = await prisma.user.findUnique({
where: {
email: input.email,
},
});
if (!user) {
return { ok: false, reason: "userNotFound" };
}
const code = generateOtp();
const token = await createToken<LoginTokenPayload>({
userId: user.id,
code,
});
await ctx.emailClient.sendTemplate("LoginEmail", {
to: input.email,
subject: `${code} is your 6-digit code`,
props: {
name: user.name,
code,
magicLink: absoluteUrl(`/auth/login?token=${token}`),
},
});
return { ok: true, token };
},
),
authenticateLogin: publicProcedure
.input(
z.object({
token: z.string(),
code: z.string(),
}),
)
.mutation(async ({ input, ctx }) => {
const payload = await decryptToken<LoginTokenPayload>(input.token);
if (!payload) {
return { user: null };
}
const { userId, code } = payload;
if (input.code !== code) {
return { user: null };
}
const user = await prisma.user.findUnique({
select: { id: true, name: true, email: true },
where: { id: userId },
});
if (!user) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "The user doesn't exist anymore",
});
}
if (ctx.session.user?.isGuest) {
await mergeGuestsIntoUser(user.id, [ctx.session.user.id]);
}
ctx.session.user = {
isGuest: false,
id: user.id,
};
await ctx.session.save();
return { user };
}),
getUserPermission: publicProcedure
.input(z.object({ token: z.string() }))
.query(async ({ input }) => {

View file

@ -3,11 +3,9 @@ import { auth } from "./auth";
import { polls } from "./polls";
import { user } from "./user";
import { userPreferences } from "./user-preferences";
import { whoami } from "./whoami";
export const appRouter = mergeRouters(
router({
whoami,
auth,
polls,
user,

View file

@ -121,9 +121,7 @@ export const polls = router({
},
});
const pollLink = ctx.user.isGuest
? absoluteUrl(`/admin/${adminToken}`)
: absoluteUrl(`/poll/${pollId}`);
const pollLink = absoluteUrl(`/poll/${pollId}`);
const participantLink = shortUrl(`/invite/${pollId}`);
@ -288,7 +286,7 @@ export const polls = router({
});
}
const res = await prisma.watcher.findFirst({
const watcher = await prisma.watcher.findFirst({
where: {
pollId: input.pollId,
userId: ctx.user.id,
@ -298,18 +296,13 @@ export const polls = router({
},
});
if (!res) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Not watching this poll",
if (watcher) {
await prisma.watcher.delete({
where: {
id: watcher.id,
},
});
}
await prisma.watcher.delete({
where: {
id: res.id,
},
});
}),
getByAdminUrlId: possiblyPublicProcedure
.input(
@ -321,28 +314,6 @@ export const polls = router({
const res = await prisma.poll.findUnique({
select: {
id: true,
timeZone: true,
title: true,
location: true,
description: true,
createdAt: true,
adminUrlId: true,
participantUrlId: true,
closed: true,
legacy: true,
demo: true,
options: {
orderBy: {
start: "asc",
},
},
user: true,
deleted: true,
watchers: {
select: {
userId: true,
},
},
},
where: {
adminUrlId: input.urlId,

View file

@ -6,13 +6,7 @@ import { publicProcedure, router } from "../trpc";
export const userPreferences = router({
get: publicProcedure.query(async ({ ctx }) => {
if (ctx.user.isGuest) {
return ctx.user.preferences
? {
timeZone: ctx.user.preferences.timeZone ?? null,
timeFormat: ctx.user.preferences.timeFormat ?? null,
weekStart: ctx.user.preferences.weekStart ?? null,
}
: null;
return null;
} else {
return await prisma.userPreferences.findUnique({
where: {
@ -48,21 +42,11 @@ export const userPreferences = router({
...input,
},
});
} else {
ctx.session.user = {
...ctx.user,
preferences: { ...ctx.user.preferences, ...input },
};
await ctx.session.save();
}
}),
delete: publicProcedure.mutation(async ({ ctx }) => {
if (ctx.user.isGuest) {
ctx.session.user = {
...ctx.user,
preferences: undefined,
};
await ctx.session.save();
// delete guest preferences
} else {
await prisma.userPreferences.delete({
where: {

View file

@ -48,4 +48,23 @@ export const user = router({
},
});
}),
updatePreferences: privateProcedure
.input(
z.object({
locale: z.string().optional(),
timeZone: z.string().optional(),
weekStart: z.number().min(0).max(6).optional(),
timeFormat: z.enum(["hours12", "hours24"]).optional(),
}),
)
.mutation(async ({ input, ctx }) => {
if (ctx.user.isGuest === false) {
await prisma.user.update({
where: {
id: ctx.user.id,
},
data: input,
});
}
}),
});

View file

@ -1,64 +0,0 @@
import { prisma } from "@rallly/database";
import { TRPCError } from "@trpc/server";
import z from "zod";
import { decryptToken } from "../../session";
import { publicProcedure, router } from "../trpc";
import { LoginTokenPayload } from "../types";
export const whoami = router({
get: publicProcedure.query(async ({ ctx }) => {
if (ctx.user.isGuest) {
return { isGuest: true as const, id: ctx.user.id };
}
const user = await prisma.user.findUnique({
select: {
id: true,
name: true,
email: true,
},
where: { id: ctx.user.id },
});
if (user === null) {
ctx.session.destroy();
throw new Error("User not found");
}
return { isGuest: false as const, ...user };
}),
destroy: publicProcedure.mutation(async ({ ctx }) => {
ctx.session.destroy();
}),
authenticate: publicProcedure
.input(z.object({ token: z.string() }))
.mutation(async ({ ctx, input }) => {
const payload = await decryptToken<LoginTokenPayload>(input.token);
if (!payload) {
// token is invalid or expired
throw new TRPCError({ code: "PARSE_ERROR", message: "Invalid token" });
}
const user = await prisma.user.findFirst({
select: {
id: true,
name: true,
email: true,
},
where: { id: payload.userId },
});
if (!user) {
// user does not exist
throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });
}
ctx.session.user = { id: user.id, isGuest: false };
await ctx.session.save();
return user;
}),
});

View file

@ -1,10 +1,10 @@
import { initTRPC, TRPCError } from "@trpc/server";
import superjson from "superjson";
import { TRPCContext } from "../next/trpc/server";
import { getSubscriptionStatus } from "../utils/auth";
import { Context } from "./context";
const t = initTRPC.context<Context>().create({
const t = initTRPC.context<TRPCContext>().create({
transformer: superjson,
errorFormatter({ shape }) {
return shape;

View file

@ -1,11 +1,8 @@
export type RegistrationTokenPayload = {
name: string;
email: string;
code: string;
};
export type LoginTokenPayload = {
userId: string;
locale?: string;
timeZone?: string;
code: string;
};