🔓 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";
/**
* 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
*/
@ -57,6 +57,17 @@ declare global {
* Port number of the SMTP server
*/
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 [
{
source: "/",
destination: "/home",
destination:
process.env.DISABLE_LANDING_PAGE === "true" ? "/new" : "/home",
},
];
},

View file

@ -39,6 +39,7 @@
"editDetails": "Edit details",
"editOptions": "Edit options",
"email": "Email",
"emailNotAllowed": "This email is not allowed.",
"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.",
"endSession": "End session",

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -6,7 +6,7 @@ import CreatePoll from "@/components/create-poll";
import StandardLayout from "../components/layouts/standard-layout";
import { NextPageWithLayout } from "../types";
import { withSessionSsr } from "../utils/auth";
import { withAuthIfRequired, withSessionSsr } from "../utils/auth";
import { withPageTranslations } from "../utils/with-page-translations";
const Page: NextPageWithLayout = () => {
@ -28,6 +28,7 @@ Page.getLayout = function getLayout(page) {
export default Page;
export const getServerSideProps: GetServerSideProps = withSessionSsr(
export const getServerSideProps: GetServerSideProps = withSessionSsr([
withAuthIfRequired,
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 { Profile } from "../components/profile";
@ -11,16 +11,9 @@ const Page: NextPageWithLayout = () => {
Page.getLayout = getStandardLayout;
export const getServerSideProps = withSessionSsr(async (ctx) => {
if (ctx.req.session.user.isGuest !== false) {
return {
redirect: {
destination: "/login",
},
props: {},
};
}
return withPageTranslations(["common", "app"])(ctx);
});
export const getServerSideProps = withSessionSsr([
withAuth,
withPageTranslations(["common", "app"]),
]);
export default Page;

View file

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

View file

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

View file

@ -2,7 +2,7 @@ import { prisma, VoteType } from "@rallly/database";
import dayjs from "dayjs";
import { nanoid } from "../../../utils/nanoid";
import { publicProcedure, router } from "../../trpc";
import { possiblyPublicProcedure, router } from "../../trpc";
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"];
export const demo = router({
create: publicProcedure.mutation(async () => {
create: possiblyPublicProcedure.mutation(async () => {
const adminUrlId = await nanoid();
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 { Context } from "./context";
@ -16,4 +16,13 @@ export const publicProcedure = t.procedure;
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;

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(
handler: GetServerSideProps | GetServerSideProps[],
options?: {

View file

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