0
Fork 0
mirror of https://github.com/stonith404/pingvin-share.git synced 2025-02-19 01:55:48 -05:00

refactor: handle authentication state in middleware

This commit is contained in:
Elias Schneider 2023-02-04 18:12:49 +01:00
parent 064ef38d78
commit 4e840ecd29
No known key found for this signature in database
GPG key ID: 07E623B294202B6C
17 changed files with 511 additions and 474 deletions

View file

@ -120,7 +120,7 @@ export class AuthController {
const accessToken = await this.authService.refreshAccessToken(
request.cookies.refresh_token
);
response.cookie("access_token", accessToken);
response = this.addTokensToResponse(response, undefined, accessToken);
return new TokenDTO().from({ accessToken });
}
@ -162,11 +162,13 @@ export class AuthController {
refreshToken?: string,
accessToken?: string
) {
if (accessToken) response.cookie("access_token", accessToken);
if (accessToken)
response.cookie("access_token", accessToken, { sameSite: "lax" });
if (refreshToken)
response.cookie("refresh_token", refreshToken, {
path: "/api/auth/token",
httpOnly: true,
sameSite: "strict",
maxAge: 1000 * 60 * 60 * 24 * 30 * 3,
});

View file

@ -110,20 +110,23 @@ export class AuthService {
{
sub: user.id,
email: user.email,
isAdmin: user.isAdmin,
refreshTokenId,
},
{
expiresIn: "15min",
expiresIn: "10s",
secret: this.config.get("JWT_SECRET"),
}
);
}
async signOut(accessToken: string) {
const { refreshTokenId } = this.jwtService.decode(accessToken) as {
const { refreshTokenId } =
(this.jwtService.decode(accessToken) as {
refreshTokenId: string;
};
}) || {};
if (refreshTokenId) {
await this.prisma.refreshToken
.delete({ where: { id: refreshTokenId } })
.catch((e) => {
@ -131,6 +134,7 @@ export class AuthService {
if (e.code != "P2025") throw e;
});
}
}
async refreshAccessToken(refreshToken: string) {
const refreshTokenMetaData = await this.prisma.refreshToken.findUnique({

View file

@ -1,4 +1,5 @@
import { Body, Controller, Get, Patch, Post, UseGuards } from "@nestjs/common";
import { SkipThrottle } from "@nestjs/throttler";
import { AdministratorGuard } from "src/auth/guard/isAdmin.guard";
import { JwtGuard } from "src/auth/guard/jwt.guard";
import { EmailService } from "src/email/email.service";
@ -16,6 +17,7 @@ export class ConfigController {
) {}
@Get()
@SkipThrottle()
async list() {
return new ConfigDTO().fromList(await this.configService.list());
}

View file

@ -21,6 +21,7 @@
"cookies-next": "^2.1.1",
"file-saver": "^2.0.5",
"jose": "^4.11.2",
"jwt-decode": "^3.1.2",
"mime-types": "^2.1.35",
"moment": "^2.29.4",
"next": "^13.1.2",
@ -5610,6 +5611,11 @@
"node": ">=4.0"
}
},
"node_modules/jwt-decode": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz",
"integrity": "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A=="
},
"node_modules/klona": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/klona/-/klona-2.0.5.tgz",
@ -12122,6 +12128,11 @@
"object.assign": "^4.1.2"
}
},
"jwt-decode": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz",
"integrity": "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A=="
},
"klona": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/klona/-/klona-2.0.5.tgz",

View file

@ -22,6 +22,7 @@
"cookies-next": "^2.1.1",
"file-saver": "^2.0.5",
"jose": "^4.11.2",
"jwt-decode": "^3.1.2",
"mime-types": "^2.1.35",
"moment": "^2.29.4",
"next": "^13.1.2",

View file

@ -14,7 +14,6 @@ import { useForm, yupResolver } from "@mantine/form";
import { useModals } from "@mantine/modals";
import { ModalsContextProps } from "@mantine/modals/lib/context";
import * as yup from "yup";
import useUser from "../../hooks/user.hook";
import authService from "../../services/auth.service";
import toast from "../../utils/toast.util";

View file

@ -9,6 +9,7 @@ import {
Title,
} from "@mantine/core";
import { useMediaQuery } from "@mantine/hooks";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import useConfig from "../../../hooks/config.hook";
import configService from "../../../services/config.service";
@ -27,6 +28,7 @@ import TestEmailButton from "./TestEmailButton";
const AdminConfigTable = () => {
const config = useConfig();
const router = useRouter();
const isMobile = useMediaQuery("(max-width: 560px)");
const [updatedConfigVariables, setUpdatedConfigVariables] = useState<
@ -68,7 +70,7 @@ const AdminConfigTable = () => {
.updateMany(updatedConfigVariables)
.then(async () => {
await configService.finishSetup();
window.location.reload();
router.replace("/upload");
})
.catch(toast.axiosError);
} else {

111
frontend/src/middleware.ts Normal file
View file

@ -0,0 +1,111 @@
import jwtDecode from "jwt-decode";
import { NextRequest, NextResponse } from "next/server";
import configService from "./services/config.service";
// This middleware redirects based on different conditions:
// - Authentication state
// - Setup status
// - Admin privileges
export const config = {
matcher: "/((?!api|static|.*\\..*|_next).*)",
};
export async function middleware(request: NextRequest) {
// Get config from backend
const config = await (
await fetch("http://localhost:8080/api/configs")
).json();
const getConfig = (key: string) => {
return configService.get(key, config);
};
const containsRoute = (routes: string[], url: string) => {
for (const route of routes) {
if (new RegExp("^" + route.replace(/\*/g, ".*") + "$").test(url))
return true;
}
return false;
};
const route = request.nextUrl.pathname;
let user: { isAdmin: boolean } | null = null;
const accessToken = request.cookies.get("access_token")?.value;
try {
const claims = jwtDecode<{ exp: number; isAdmin: boolean }>(
accessToken as string
);
if (claims.exp * 1000 > Date.now()) {
user = claims;
}
} catch {
user = null;
}
const unauthenticatedRoutes = ["/auth/signIn", "/"];
let publicRoutes = ["/share/*", "/upload/*"];
const setupStatusRegisteredRoutes = ["/auth/*", "/admin/setup"];
const adminRoutes = ["/admin/*"];
const accountRoutes = ["/account/*"];
if (getConfig("ALLOW_REGISTRATION")) {
unauthenticatedRoutes.push("/auth/signUp");
}
if (getConfig("ALLOW_UNAUTHENTICATED_SHARES")) {
publicRoutes = ["*"];
}
const isPublicRoute = containsRoute(publicRoutes, route);
const isUnauthenticatedRoute = containsRoute(unauthenticatedRoutes, route);
const isAdminRoute = containsRoute(adminRoutes, route);
const isAccountRoute = containsRoute(accountRoutes, route);
const isSetupStatusRegisteredRoute = containsRoute(
setupStatusRegisteredRoutes,
route
);
// prettier-ignore
const rules = [
// Setup status
{
condition: getConfig("SETUP_STATUS") == "STARTED" && route != "/auth/signUp",
path: "/auth/signUp",
},
{
condition: getConfig("SETUP_STATUS") == "REGISTERED" && !isSetupStatusRegisteredRoute,
path: user ? "/admin/setup" : "/auth/signIn",
},
// Authenticated state
{
condition: user && isUnauthenticatedRoute,
path: "/upload",
},
// Unauthenticated state
{
condition: !user && !isPublicRoute && !isUnauthenticatedRoute,
path: "/auth/signIn",
},
{
condition: !user && isAccountRoute,
path: "/upload",
},
// Admin privileges
{
condition: isAdminRoute && !user?.isAdmin,
path: "/upload",
},
// Home page
{
condition: (!getConfig("SHOW_HOME_PAGE") || user) && route == "/",
path: "/upload",
},
];
for (const rule of rules) {
if (rule.condition)
return NextResponse.redirect(new URL(rule.path, request.url));
}
}

View file

@ -9,7 +9,6 @@ import { useColorScheme } from "@mantine/hooks";
import { ModalsProvider } from "@mantine/modals";
import { NotificationsProvider } from "@mantine/notifications";
import type { AppProps } from "next/app";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import Header from "../components/navBar/NavBar";
import { ConfigContext } from "../hooks/config.hook";
@ -26,7 +25,7 @@ import { GlobalLoadingContext } from "../utils/loading.util";
function App({ Component, pageProps }: AppProps) {
const systemTheme = useColorScheme();
const router = useRouter();
const preferences = usePreferences();
const [colorScheme, setColorScheme] = useState<ColorScheme>("light");
const [isLoading, setIsLoading] = useState(true);
@ -46,25 +45,6 @@ function App({ Component, pageProps }: AppProps) {
getInitalData();
}, []);
// Redirect to setup page if setup is not completed
useEffect(() => {
if (
configVariables &&
!["/auth/signUp", "/admin/setup"].includes(router.asPath)
) {
const setupStatus = configVariables.filter(
(variable) => variable.key == "SETUP_STATUS"
)[0].value;
if (setupStatus == "STARTED") {
router.replace("/auth/signUp");
} else if (user && setupStatus == "REGISTERED") {
router.replace("/admin/setup");
} else if (setupStatus == "REGISTERED") {
router.replace("/auth/signIn");
}
}
}, [configVariables, router.asPath]);
useEffect(() => {
setColorScheme(
preferences.get("colorScheme") == "system"

View file

@ -13,7 +13,6 @@ import {
} from "@mantine/core";
import { useForm, yupResolver } from "@mantine/form";
import { useModals } from "@mantine/modals";
import { useRouter } from "next/router";
import { Tb2Fa } from "react-icons/tb";
import * as yup from "yup";
import showEnableTotpModal from "../../components/account/showEnableTotpModal";
@ -27,7 +26,6 @@ import toast from "../../utils/toast.util";
const Account = () => {
const { user, setUser } = useUser();
const modals = useModals();
const router = useRouter();
const accountForm = useForm({
initialValues: {
@ -85,11 +83,6 @@ const Account = () => {
const refreshUser = async () => setUser(await userService.getCurrentUser());
if (!user) {
router.push("/");
return;
}
return (
<>
<Meta title="My account" />
@ -171,7 +164,7 @@ const Account = () => {
</Tabs.List>
<Tabs.Panel value="totp" pt="xs">
{user.totpVerified ? (
{user!.totpVerified ? (
<>
<form
onSubmit={disableTotpForm.onSubmit((values) => {

View file

@ -13,7 +13,6 @@ import {
import { useClipboard } from "@mantine/hooks";
import { useModals } from "@mantine/modals";
import moment from "moment";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import { TbInfoCircle, TbLink, TbPlus, TbTrash } from "react-icons/tb";
import showShareLinkModal from "../../components/account/showShareLinkModal";
@ -21,7 +20,6 @@ import CenterLoader from "../../components/core/CenterLoader";
import Meta from "../../components/Meta";
import showCreateReverseShareModal from "../../components/share/modals/showCreateReverseShareModal";
import useConfig from "../../hooks/config.hook";
import useUser from "../../hooks/user.hook";
import shareService from "../../services/share.service";
import { MyReverseShare } from "../../types/share.type";
import { byteToHumanSizeString } from "../../utils/fileSize.util";
@ -30,10 +28,8 @@ import toast from "../../utils/toast.util";
const MyShares = () => {
const modals = useModals();
const clipboard = useClipboard();
const router = useRouter();
const config = useConfig();
const { user } = useUser();
const config = useConfig();
const [reverseShares, setReverseShares] = useState<MyReverseShare[]>();
@ -47,9 +43,6 @@ const MyShares = () => {
getReverseShares();
}, []);
if (!user) {
router.replace("/");
} else {
if (!reverseShares) return <CenterLoader />;
return (
<>
@ -113,9 +106,7 @@ const MyShares = () => {
</td>
<td>{reverseShare.share?.views ?? "0"}</td>
<td>
{byteToHumanSizeString(
parseInt(reverseShare.maxShareSize)
)}
{byteToHumanSizeString(parseInt(reverseShare.maxShareSize))}
</td>
<td>
{moment(reverseShare.shareExpiration).unix() === 0
@ -160,9 +151,8 @@ const MyShares = () => {
title: `Delete reverse share`,
children: (
<Text size="sm">
Do you really want to delete this reverse
share? If you do, the share will be deleted as
well.
Do you really want to delete this reverse share?
If you do, the share will be deleted as well.
</Text>
),
confirmProps: {
@ -170,9 +160,7 @@ const MyShares = () => {
},
labels: { confirm: "Confirm", cancel: "Cancel" },
onConfirm: () => {
shareService.removeReverseShare(
reverseShare.id
);
shareService.removeReverseShare(reverseShare.id);
setReverseShares(
reverseShares.filter(
(item) => item.id !== reverseShare.id
@ -194,7 +182,6 @@ const MyShares = () => {
)}
</>
);
}
};
export default MyShares;

View file

@ -15,13 +15,11 @@ import { useClipboard } from "@mantine/hooks";
import { useModals } from "@mantine/modals";
import moment from "moment";
import Link from "next/link";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import { TbLink, TbTrash } from "react-icons/tb";
import showShareLinkModal from "../../components/account/showShareLinkModal";
import Meta from "../../components/Meta";
import useConfig from "../../hooks/config.hook";
import useUser from "../../hooks/user.hook";
import shareService from "../../services/share.service";
import { MyShare } from "../../types/share.type";
import toast from "../../utils/toast.util";
@ -29,20 +27,14 @@ import toast from "../../utils/toast.util";
const MyShares = () => {
const modals = useModals();
const clipboard = useClipboard();
const router = useRouter();
const config = useConfig();
const { user } = useUser();
const [shares, setShares] = useState<MyShare[]>();
useEffect(() => {
shareService.getMyShares().then((shares) => setShares(shares));
}, []);
if (!user) {
router.replace("/");
} else {
if (!shares) return <LoadingOverlay visible />;
return (
<>
@ -144,7 +136,6 @@ const MyShares = () => {
)}
</>
);
}
};
export default MyShares;

View file

@ -1,25 +1,10 @@
import { Box, Stack, Text, Title } from "@mantine/core";
import { useRouter } from "next/router";
import AdminConfigTable from "../../components/admin/configuration/AdminConfigTable";
import Logo from "../../components/Logo";
import Meta from "../../components/Meta";
import useConfig from "../../hooks/config.hook";
import useUser from "../../hooks/user.hook";
const Setup = () => {
const router = useRouter();
const config = useConfig();
const { user } = useUser();
if (!user) {
router.push("/auth/signUp");
return;
} else if (config.get("SETUP_STATUS") == "FINISHED") {
router.push("/");
return;
}
return (
<>
<Meta title="Setup" />

View file

@ -1,20 +1,25 @@
import { LoadingOverlay } from "@mantine/core";
import { useRouter } from "next/router";
import SignInForm from "../../components/auth/SignInForm";
import Meta from "../../components/Meta";
import useUser from "../../hooks/user.hook";
const SignIn = () => {
const { user } = useUser();
const router = useRouter();
const { user } = useUser();
// If the access token is expired, the middleware redirects to this page.
// If the refresh token is still valid, the user will be redirected to the home page.
if (user) {
router.replace("/");
} else {
return <LoadingOverlay overlayOpacity={1} visible />;
}
return (
<>
<Meta title="Sign In" />
<SignInForm />
</>
);
}
};
export default SignIn;

View file

@ -1,24 +1,12 @@
import { useRouter } from "next/router";
import SignUpForm from "../../components/auth/SignUpForm";
import Meta from "../../components/Meta";
import useConfig from "../../hooks/config.hook";
import useUser from "../../hooks/user.hook";
const SignUp = () => {
const config = useConfig();
const { user } = useUser();
const router = useRouter();
if (user) {
router.replace("/");
} else if (!config.get("ALLOW_REGISTRATION")) {
router.replace("/auth/signIn");
} else {
return (
<>
<Meta title="Sign Up" />
<SignUpForm />
</>
);
}
};
export default SignUp;

View file

@ -10,11 +10,8 @@ import {
} from "@mantine/core";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/router";
import { TbCheck } from "react-icons/tb";
import Meta from "../components/Meta";
import useConfig from "../hooks/config.hook";
import useUser from "../hooks/user.hook";
const useStyles = createStyles((theme) => ({
inner: {
@ -69,16 +66,8 @@ const useStyles = createStyles((theme) => ({
}));
export default function Home() {
const config = useConfig();
const { user } = useUser();
const { classes } = useStyles();
const router = useRouter();
if (user || config.get("ALLOW_UNAUTHENTICATED_SHARES")) {
router.replace("/upload");
} else if (!config.get("SHOW_HOME_PAGE")) {
router.replace("/auth/signIn");
} else {
return (
<>
<Meta title="Home" />
@ -158,5 +147,4 @@ export default function Home() {
</Container>
</>
);
}
}

View file

@ -2,8 +2,6 @@ import { Button, Group } from "@mantine/core";
import { useModals } from "@mantine/modals";
import { cleanNotifications } from "@mantine/notifications";
import { AxiosError } from "axios";
import { getCookie } from "cookies-next";
import { useRouter } from "next/router";
import pLimit from "p-limit";
import { useEffect, useState } from "react";
import Meta from "../../components/Meta";
@ -30,7 +28,6 @@ const Upload = ({
maxShareSize?: number;
isReverseShare: boolean;
}) => {
const router = useRouter();
const modals = useModals();
const { user } = useUser();
@ -158,14 +155,6 @@ const Upload = ({
}
}, [files]);
if (
!user &&
!config.get("ALLOW_UNAUTHENTICATED_SHARES") &&
!getCookie("reverse_share_token")
) {
router.replace("/");
return null;
} else {
return (
<>
<Meta title="Upload" />
@ -203,6 +192,5 @@ const Upload = ({
{files.length > 0 && <FileList files={files} setFiles={setFiles} />}
</>
);
}
};
export default Upload;