🔓 Add config to secure instance from unauth users (#559)

This commit is contained in:
Luke Vella 2023-03-14 16:48:16 +00:00 committed by GitHub
parent e65c87bf04
commit 1b38a3cf76
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 194 additions and 104 deletions

View file

@ -10,9 +10,9 @@ declare global {
*/ */
NODE_ENV: "development" | "production"; NODE_ENV: "development" | "production";
/** /**
* Can be "false" or a relative path eg. "/new" * Set to "true" to take users straight to app instead of landing page
*/ */
LANDING_PAGE: string; DISABLE_LANDING_PAGE?: string;
/** /**
* Must be 32 characters long * Must be 32 characters long
*/ */
@ -57,6 +57,17 @@ declare global {
* Port number of the SMTP server * Port number of the SMTP server
*/ */
SMTP_PORT: string; SMTP_PORT: string;
/**
* Comma separated list of email addresses that are allowed to register and login.
* If not set, all emails are allowed. Wildcard characters are supported.
*
* Example: "user@example.com, *@example.com, *@*.example.com"
*/
ALLOWED_EMAILS?: string;
/**
* "true" to require authentication for creating new polls and accessing admin pages
*/
AUTH_REQUIRED?: string;
} }
} }
} }

View file

@ -40,7 +40,8 @@ const nextConfig = {
return [ return [
{ {
source: "/", source: "/",
destination: "/home", destination:
process.env.DISABLE_LANDING_PAGE === "true" ? "/new" : "/home",
}, },
]; ];
}, },

View file

@ -39,6 +39,7 @@
"editDetails": "Edit details", "editDetails": "Edit details",
"editOptions": "Edit options", "editOptions": "Edit options",
"email": "Email", "email": "Email",
"emailNotAllowed": "This email is not allowed.",
"emailPlaceholder": "jessie.smith@email.com", "emailPlaceholder": "jessie.smith@email.com",
"endingGuestSessionNotice": "Once a guest session ends it cannot be resumed. You will not be able to edit any votes or comments you've made with this session.", "endingGuestSessionNotice": "Once a guest session ends it cannot be resumed. You will not be able to edit any votes or comments you've made with this session.",
"endSession": "End session", "endSession": "End session",

View file

@ -182,12 +182,16 @@ export const RegisterForm: React.FunctionComponent<{
}); });
if (!res.ok) { if (!res.ok) {
switch (res.code) { switch (res.reason) {
case "userAlreadyExists": case "userAlreadyExists":
setError("email", { setError("email", {
message: t("userAlreadyExists"), message: t("userAlreadyExists"),
}); });
break; break;
case "emailNotAllowed":
setError("email", {
message: t("emailNotAllowed"),
});
} }
} else { } else {
setToken(res.token); setToken(res.token);
@ -308,7 +312,22 @@ export const LoginForm: React.FunctionComponent<{
email: values.email, email: values.email,
}); });
setToken(res.token); if (res.ok) {
setToken(res.token);
} else {
switch (res.reason) {
case "emailNotAllowed":
setError("email", {
message: t("emailNotAllowed"),
});
break;
case "userNotFound":
setError("email", {
message: t("userNotFound"),
});
break;
}
}
}} }}
onChange={() => setToken(undefined)} onChange={() => setToken(undefined)}
email={getValues("email")} email={getValues("email")}
@ -323,13 +342,21 @@ export const LoginForm: React.FunctionComponent<{
email: data.email, email: data.email,
}); });
if (!res.token) { if (res.ok) {
setError("email", {
type: "not_found",
message: t("userNotFound"),
});
} else {
setToken(res.token); setToken(res.token);
} else {
switch (res.reason) {
case "emailNotAllowed":
setError("email", {
message: t("emailNotAllowed"),
});
break;
case "userNotFound":
setError("email", {
message: t("userNotFound"),
});
break;
}
} }
})} })}
> >

View file

@ -79,11 +79,11 @@ export const UserDropdown: React.FunctionComponent<DropdownProps> = ({
onClick={openLoginModal} onClick={openLoginModal}
/> />
) : null} ) : null}
<DropdownItem {user.isGuest ? (
icon={Logout} <DropdownItem
label={user.isGuest ? t("app:forgetMe") : t("app:logout")} icon={Logout}
onClick={() => { label={t("app:forgetMe")}
if (user?.isGuest) { onClick={() => {
modalContext.render({ modalContext.render({
title: t("app:areYouSure"), title: t("app:areYouSure"),
description: t("app:endingGuestSessionNotice"), description: t("app:endingGuestSessionNotice"),
@ -95,11 +95,11 @@ export const UserDropdown: React.FunctionComponent<DropdownProps> = ({
okText: t("app:endSession"), okText: t("app:endSession"),
cancelText: t("app:cancel"), cancelText: t("app:cancel"),
}); });
} else { }}
logout(); />
} ) : (
}} <DropdownItem icon={Logout} href="/logout" label={t("app:logout")} />
/> )}
</Dropdown> </Dropdown>
); );
}; };

View file

@ -7,7 +7,7 @@ import { getStandardLayout } from "@/components/layouts/standard-layout";
import { ParticipantsProvider } from "@/components/participants-provider"; import { ParticipantsProvider } from "@/components/participants-provider";
import { Poll } from "@/components/poll"; import { Poll } from "@/components/poll";
import { PollContextProvider } from "@/components/poll-context"; import { PollContextProvider } from "@/components/poll-context";
import { withSessionSsr } from "@/utils/auth"; import { withAuthIfRequired, withSessionSsr } from "@/utils/auth";
import { trpc } from "@/utils/trpc"; import { trpc } from "@/utils/trpc";
import { withPageTranslations } from "@/utils/with-page-translations"; import { withPageTranslations } from "@/utils/with-page-translations";
@ -51,6 +51,7 @@ Page.getLayout = getStandardLayout;
export const getServerSideProps: GetServerSideProps = withSessionSsr( export const getServerSideProps: GetServerSideProps = withSessionSsr(
[ [
withAuthIfRequired,
withPageTranslations(["common", "app", "errors"]), withPageTranslations(["common", "app", "errors"]),
async (ctx) => { async (ctx) => {
return { return {

View file

@ -7,7 +7,7 @@ import { useMount } from "react-use";
import FullPageLoader from "../components/full-page-loader"; import FullPageLoader from "../components/full-page-loader";
import { withSession } from "../components/user-provider"; import { withSession } from "../components/user-provider";
import { withSessionSsr } from "../utils/auth"; import { withAuthIfRequired, withSessionSsr } from "../utils/auth";
import { trpc } from "../utils/trpc"; import { trpc } from "../utils/trpc";
import { withPageTranslations } from "../utils/with-page-translations"; import { withPageTranslations } from "../utils/with-page-translations";
@ -26,8 +26,9 @@ const Demo: NextPage = () => {
return <FullPageLoader>{t("creatingDemo")}</FullPageLoader>; return <FullPageLoader>{t("creatingDemo")}</FullPageLoader>;
}; };
export const getServerSideProps = withSessionSsr( export const getServerSideProps = withSessionSsr([
withAuthIfRequired,
withPageTranslations(["common", "app"]), withPageTranslations(["common", "app"]),
); ]);
export default withSession(Demo); export default withSession(Demo);

View file

@ -1,37 +1,20 @@
import { GetServerSideProps } from "next"; import { GetServerSideProps } from "next";
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
import Home from "@/components/home"; import Home from "@/components/home";
import { composeGetServerSideProps } from "@/utils/auth";
import { withPageTranslations } from "@/utils/with-page-translations";
export default function Page() { export default function Page() {
return <Home />; return <Home />;
} }
export const getServerSideProps: GetServerSideProps = async ({ export const getServerSideProps: GetServerSideProps = composeGetServerSideProps(
locale = "en", async () => {
}) => { // TODO (Luke Vella) [2023-03-14]: Remove this once we split the app from the landing page
if (process.env.LANDING_PAGE) { if (process.env.DISABLE_LANDING_PAGE === "true") {
if (process.env.LANDING_PAGE === "false") { return { notFound: true };
return {
redirect: {
destination: "/new",
permanent: false,
},
};
} }
// if starts with /, it's a relative path return { props: {} };
if (process.env.LANDING_PAGE.startsWith("/")) { },
return { withPageTranslations(["common", "homepage"]),
redirect: { );
destination: process.env.LANDING_PAGE,
permanent: false,
},
};
}
}
return {
props: {
...(await serverSideTranslations(locale, ["common", "homepage"])),
},
};
};

View file

@ -6,7 +6,7 @@ import CreatePoll from "@/components/create-poll";
import StandardLayout from "../components/layouts/standard-layout"; import StandardLayout from "../components/layouts/standard-layout";
import { NextPageWithLayout } from "../types"; import { NextPageWithLayout } from "../types";
import { withSessionSsr } from "../utils/auth"; import { withAuthIfRequired, withSessionSsr } from "../utils/auth";
import { withPageTranslations } from "../utils/with-page-translations"; import { withPageTranslations } from "../utils/with-page-translations";
const Page: NextPageWithLayout = () => { const Page: NextPageWithLayout = () => {
@ -28,6 +28,7 @@ Page.getLayout = function getLayout(page) {
export default Page; export default Page;
export const getServerSideProps: GetServerSideProps = withSessionSsr( export const getServerSideProps: GetServerSideProps = withSessionSsr([
withAuthIfRequired,
withPageTranslations(["common", "app"]), withPageTranslations(["common", "app"]),
); ]);

View file

@ -1,4 +1,4 @@
import { withSessionSsr } from "@/utils/auth"; import { withAuth, withSessionSsr } from "@/utils/auth";
import { getStandardLayout } from "../components/layouts/standard-layout"; import { getStandardLayout } from "../components/layouts/standard-layout";
import { Profile } from "../components/profile"; import { Profile } from "../components/profile";
@ -11,16 +11,9 @@ const Page: NextPageWithLayout = () => {
Page.getLayout = getStandardLayout; Page.getLayout = getStandardLayout;
export const getServerSideProps = withSessionSsr(async (ctx) => { export const getServerSideProps = withSessionSsr([
if (ctx.req.session.user.isGuest !== false) { withAuth,
return { withPageTranslations(["common", "app"]),
redirect: { ]);
destination: "/login",
},
props: {},
};
}
return withPageTranslations(["common", "app"])(ctx);
});
export default Page; export default Page;

View file

@ -15,6 +15,22 @@ import {
import { generateOtp } from "../../utils/nanoid"; import { generateOtp } from "../../utils/nanoid";
import { publicProcedure, router } from "../trpc"; import { publicProcedure, router } from "../trpc";
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.trim().replace("*", ".*"), "i");
return regex.test(email);
});
if (!isAllowed) {
return true;
}
}
return false;
};
export const auth = router({ export const auth = router({
requestRegistration: publicProcedure requestRegistration: publicProcedure
.input( .input(
@ -27,8 +43,13 @@ export const auth = router({
async ({ async ({
input, input,
}): Promise< }): Promise<
{ ok: true; token: string } | { ok: false; code: "userAlreadyExists" } | { ok: true; token: string }
| { ok: false; reason: "userAlreadyExists" | "emailNotAllowed" }
> => { > => {
if (isEmailBlocked(input.email)) {
return { ok: false, reason: "emailNotAllowed" };
}
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
select: { select: {
id: true, id: true,
@ -39,7 +60,7 @@ export const auth = router({
}); });
if (user) { if (user) {
return { ok: false, code: "userAlreadyExists" }; return { ok: false, reason: "userAlreadyExists" };
} }
const code = await generateOtp(); const code = await generateOtp();
@ -108,36 +129,47 @@ export const auth = router({
email: z.string(), email: z.string(),
}), }),
) )
.mutation(async ({ input }): Promise<{ token?: string }> => { .mutation(
const user = await prisma.user.findUnique({ async ({
where: { input,
email: input.email, }): Promise<
}, | { ok: true; token: string }
}); | { ok: false; reason: "emailNotAllowed" | "userNotFound" }
> => {
if (isEmailBlocked(input.email)) {
return { ok: false, reason: "emailNotAllowed" };
}
if (!user) { const user = await prisma.user.findUnique({
return { token: undefined }; where: {
} email: input.email,
},
});
const code = await generateOtp(); if (!user) {
return { ok: false, reason: "userNotFound" };
}
const token = await createToken<LoginTokenPayload>({ const code = await generateOtp();
userId: user.id,
code,
});
await sendEmail("LoginEmail", { const token = await createToken<LoginTokenPayload>({
to: input.email, userId: user.id,
subject: "Login",
props: {
name: user.name,
code, code,
magicLink: absoluteUrl(`/auth/login?token=${token}`), });
},
});
return { token }; await sendEmail("LoginEmail", {
}), to: input.email,
subject: "Login",
props: {
name: user.name,
code,
magicLink: absoluteUrl(`/auth/login?token=${token}`),
},
});
return { ok: true, token };
},
),
authenticateLogin: publicProcedure authenticateLogin: publicProcedure
.input( .input(
z.object({ z.object({

View file

@ -8,7 +8,7 @@ import { createToken, EnableNotificationsTokenPayload } from "@/utils/auth";
import { absoluteUrl } from "../../utils/absolute-url"; import { absoluteUrl } from "../../utils/absolute-url";
import { nanoid } from "../../utils/nanoid"; import { nanoid } from "../../utils/nanoid";
import { GetPollApiResponse } from "../../utils/trpc/types"; import { GetPollApiResponse } from "../../utils/trpc/types";
import { publicProcedure, router } from "../trpc"; import { possiblyPublicProcedure, publicProcedure, router } from "../trpc";
import { comments } from "./polls/comments"; import { comments } from "./polls/comments";
import { demo } from "./polls/demo"; import { demo } from "./polls/demo";
import { participants } from "./polls/participants"; import { participants } from "./polls/participants";
@ -78,7 +78,7 @@ const getPollIdFromAdminUrlId = async (urlId: string) => {
export const polls = router({ export const polls = router({
// START LEGACY ROUTES // START LEGACY ROUTES
create: publicProcedure create: possiblyPublicProcedure
.input( .input(
z.object({ z.object({
title: z.string(), title: z.string(),
@ -168,7 +168,7 @@ export const polls = router({
return { id: poll.id, urlId: adminUrlId }; return { id: poll.id, urlId: adminUrlId };
}, },
), ),
update: publicProcedure update: possiblyPublicProcedure
.input( .input(
z.object({ z.object({
urlId: z.string(), urlId: z.string(),
@ -222,7 +222,7 @@ export const polls = router({
return { ...poll }; return { ...poll };
}), }),
delete: publicProcedure delete: possiblyPublicProcedure
.input( .input(
z.object({ z.object({
urlId: z.string(), urlId: z.string(),
@ -253,7 +253,7 @@ export const polls = router({
comments, comments,
verification, verification,
// END LEGACY ROUTES // END LEGACY ROUTES
enableNotifications: publicProcedure enableNotifications: possiblyPublicProcedure
.input(z.object({ adminUrlId: z.string() })) .input(z.object({ adminUrlId: z.string() }))
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
const poll = await prisma.poll.findUnique({ const poll = await prisma.poll.findUnique({
@ -293,7 +293,7 @@ export const polls = router({
}, },
}); });
}), }),
getByAdminUrlId: publicProcedure getByAdminUrlId: possiblyPublicProcedure
.input( .input(
z.object({ z.object({
urlId: z.string(), urlId: z.string(),

View file

@ -2,7 +2,7 @@ import { prisma, VoteType } from "@rallly/database";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { nanoid } from "../../../utils/nanoid"; import { nanoid } from "../../../utils/nanoid";
import { publicProcedure, router } from "../../trpc"; import { possiblyPublicProcedure, router } from "../../trpc";
const participantData: Array<{ name: string; votes: VoteType[] }> = [ const participantData: Array<{ name: string; votes: VoteType[] }> = [
{ {
@ -26,7 +26,7 @@ const participantData: Array<{ name: string; votes: VoteType[] }> = [
const optionValues = ["2022-12-14", "2022-12-15", "2022-12-16", "2022-12-17"]; const optionValues = ["2022-12-14", "2022-12-15", "2022-12-16", "2022-12-17"];
export const demo = router({ export const demo = router({
create: publicProcedure.mutation(async () => { create: possiblyPublicProcedure.mutation(async () => {
const adminUrlId = await nanoid(); const adminUrlId = await nanoid();
const demoUser = { name: "John Example", email: "noreply@rallly.co" }; const demoUser = { name: "John Example", email: "noreply@rallly.co" };

View file

@ -1,4 +1,4 @@
import { initTRPC } from "@trpc/server"; import { initTRPC, TRPCError } from "@trpc/server";
import superjson from "superjson"; import superjson from "superjson";
import { Context } from "./context"; import { Context } from "./context";
@ -16,4 +16,13 @@ export const publicProcedure = t.procedure;
export const middleware = t.middleware; export const middleware = t.middleware;
const checkAuthIfRequired = middleware(async ({ ctx, next }) => {
if (process.env.AUTH_REQUIRED === "true" && ctx.session.user.isGuest) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Login is required" });
}
return next();
});
export const possiblyPublicProcedure = t.procedure.use(checkAuthIfRequired);
export const mergeRouters = t.mergeRouters; export const mergeRouters = t.mergeRouters;

View file

@ -101,6 +101,34 @@ export const composeGetServerSideProps = (
}; };
}; };
/**
* Require user to be logged in
* @returns
*/
export const withAuth: GetServerSideProps = async (ctx) => {
if (!ctx.req.session.user || ctx.req.session.user.isGuest) {
return {
redirect: {
destination: "/login",
permanent: false,
},
};
}
return { props: {} };
};
/**
* Require user to be logged in if AUTH_REQUIRED is true
* @returns
*/
export const withAuthIfRequired: GetServerSideProps = async (ctx) => {
if (process.env.AUTH_REQUIRED === "true") {
return await withAuth(ctx);
}
return { props: {} };
};
export function withSessionSsr( export function withSessionSsr(
handler: GetServerSideProps | GetServerSideProps[], handler: GetServerSideProps | GetServerSideProps[],
options?: { options?: {

View file

@ -6,9 +6,11 @@
"dependsOn": ["^build"], "dependsOn": ["^build"],
"outputs": [".next/**"], "outputs": [".next/**"],
"env": [ "env": [
"ALLOWED_EMAILS",
"AUTH_REQUIRED",
"ANALYZE", "ANALYZE",
"API_SECRET", "API_SECRET",
"LANDING_PAGE", "DISABLE_LANDING_PAGE",
"MAINTENANCE_MODE", "MAINTENANCE_MODE",
"NEXT_PUBLIC_BASE_URL", "NEXT_PUBLIC_BASE_URL",
"NEXT_PUBLIC_BETA", "NEXT_PUBLIC_BETA",