mirror of
https://github.com/lukevella/rallly.git
synced 2025-04-29 02:06:34 +02:00
🔓 Add config to secure instance from unauth users (#559)
This commit is contained in:
parent
e65c87bf04
commit
1b38a3cf76
16 changed files with 194 additions and 104 deletions
15
apps/web/declarations/environment.d.ts
vendored
15
apps/web/declarations/environment.d.ts
vendored
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -40,7 +40,8 @@ const nextConfig = {
|
|||
return [
|
||||
{
|
||||
source: "/",
|
||||
destination: "/home",
|
||||
destination:
|
||||
process.env.DISABLE_LANDING_PAGE === "true" ? "/new" : "/home",
|
||||
},
|
||||
];
|
||||
},
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
})}
|
||||
>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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"]),
|
||||
);
|
||||
|
|
|
@ -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"]),
|
||||
);
|
||||
]);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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" };
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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?: {
|
||||
|
|
|
@ -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",
|
||||
|
|
Loading…
Add table
Reference in a new issue