From 5d1a7f0310df2643213affd2a0d1785b7e0af398 Mon Sep 17 00:00:00 2001 From: Elias Schneider Date: Thu, 9 Feb 2023 18:17:53 +0100 Subject: [PATCH] feat!: reset password with email --- .../migration.sql | 14 +++ backend/prisma/schema.prisma | 17 ++- backend/prisma/seed/config.seed.ts | 59 ++++++---- backend/src/auth/auth.controller.ts | 22 +++- backend/src/auth/auth.module.ts | 3 +- backend/src/auth/auth.service.ts | 48 +++++++- backend/src/auth/authTotp.service.ts | 53 +-------- backend/src/auth/dto/resetPassword.dto.ts | 8 ++ backend/src/email/email.service.ts | 15 +++ backend/src/user/user.service.ts | 1 - .../admin/configuration/AdminConfigTable.tsx | 6 + frontend/src/components/auth/SignInForm.tsx | 18 +-- frontend/src/components/auth/SignUpForm.tsx | 12 +- frontend/src/components/navBar/NavBar.tsx | 53 +++++---- frontend/src/hooks/config.hook.ts | 4 +- frontend/src/middleware.ts | 70 +++++++----- .../resetPassword/[resetPasswordToken].tsx | 81 +++++++++++++ .../src/pages/auth/resetPassword/index.tsx | 107 ++++++++++++++++++ frontend/src/pages/index.tsx | 14 +++ frontend/src/services/auth.service.ts | 10 ++ 20 files changed, 459 insertions(+), 156 deletions(-) create mode 100644 backend/prisma/migrations/20230209101345_reset_password/migration.sql create mode 100644 backend/src/auth/dto/resetPassword.dto.ts create mode 100644 frontend/src/pages/auth/resetPassword/[resetPasswordToken].tsx create mode 100644 frontend/src/pages/auth/resetPassword/index.tsx diff --git a/backend/prisma/migrations/20230209101345_reset_password/migration.sql b/backend/prisma/migrations/20230209101345_reset_password/migration.sql new file mode 100644 index 0000000..1508d82 --- /dev/null +++ b/backend/prisma/migrations/20230209101345_reset_password/migration.sql @@ -0,0 +1,14 @@ +-- CreateTable +CREATE TABLE "ResetPasswordToken" ( + "token" TEXT NOT NULL PRIMARY KEY, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expiresAt" DATETIME NOT NULL, + "userId" TEXT NOT NULL, + CONSTRAINT "ResetPasswordToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- Disable TOTP as secret isn't encrypted anymore +UPDATE User SET totpEnabled=false, totpSecret=null, totpVerified=false WHERE totpSecret IS NOT NULL; + +-- CreateIndex +CREATE UNIQUE INDEX "ResetPasswordToken_userId_key" ON "ResetPasswordToken"("userId"); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index c9444b0..303cd29 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -22,9 +22,10 @@ model User { loginTokens LoginToken[] reverseShares ReverseShare[] - totpEnabled Boolean @default(false) - totpVerified Boolean @default(false) - totpSecret String? + totpEnabled Boolean @default(false) + totpVerified Boolean @default(false) + totpSecret String? + resetPasswordToken ResetPasswordToken? } model RefreshToken { @@ -49,6 +50,16 @@ model LoginToken { used Boolean @default(false) } +model ResetPasswordToken { + token String @id @default(uuid()) + createdAt DateTime @default(now()) + + expiresAt DateTime + + userId String @unique + user User @relation(fields: [userId], references: [id], onDelete: Cascade) +} + model Share { id String @id @default(uuid()) createdAt DateTime @default(now()) diff --git a/backend/prisma/seed/config.seed.ts b/backend/prisma/seed/config.seed.ts index 2b350c8..352ab5e 100644 --- a/backend/prisma/seed/config.seed.ts +++ b/backend/prisma/seed/config.seed.ts @@ -21,15 +21,6 @@ const configVariables: Prisma.ConfigCreateInput[] = [ category: "internal", locked: true, }, - { - order: 0, - key: "TOTP_SECRET", - description: "A 16 byte random string used to generate TOTP secrets", - type: "string", - value: crypto.randomBytes(16).toString("base64"), - category: "internal", - locked: true, - }, { order: 1, key: "APP_URL", @@ -89,6 +80,15 @@ const configVariables: Prisma.ConfigCreateInput[] = [ }, { order: 7, + key: "SHARE_RECEPIENTS_EMAIL_SUBJECT", + description: + "Subject of the email which gets sent to the share recipients.", + type: "string", + value: "Files shared with you", + category: "email", + }, + { + order: 8, key: "SHARE_RECEPIENTS_EMAIL_MESSAGE", description: "Message which gets sent to the share recipients. {creator} and {shareUrl} will be replaced with the creator's name and the share URL.", @@ -98,16 +98,16 @@ const configVariables: Prisma.ConfigCreateInput[] = [ category: "email", }, { - order: 8, - key: "SHARE_RECEPIENTS_EMAIL_SUBJECT", + order: 9, + key: "REVERSE_SHARE_EMAIL_SUBJECT", description: - "Subject of the email which gets sent to the share recipients.", + "Subject of the email which gets sent when someone created a share with your reverse share link.", type: "string", - value: "Files shared with you", + value: "Reverse share link used", category: "email", }, { - order: 9, + order: 10, key: "REVERSE_SHARE_EMAIL_MESSAGE", description: "Message which gets sent when someone created a share with your reverse share link. {shareUrl} will be replaced with the creator's name and the share URL.", @@ -117,16 +117,27 @@ const configVariables: Prisma.ConfigCreateInput[] = [ category: "email", }, { - order: 10, - key: "REVERSE_SHARE_EMAIL_SUBJECT", + order: 11, + key: "RESET_PASSWORD_EMAIL_SUBJECT", description: - "Subject of the email which gets sent when someone created a share with your reverse share link.", + "Subject of the email which gets sent when a user requests a password reset.", type: "string", - value: "Reverse share link used", + value: "Pingvin Share password reset", category: "email", }, { - order: 11, + order: 12, + key: "RESET_PASSWORD_EMAIL_MESSAGE", + description: + "Message which gets sent when a user requests a password reset. {url} will be replaced with the reset password URL.", + type: "text", + value: + "Hey!\nYou requested a password reset. Click this link to reset your password: {url}\nThe link expires in a hour.\nPingvin Share 🐧", + category: "email", + }, + + { + order: 13, key: "SMTP_ENABLED", description: "Whether SMTP is enabled. Only set this to true if you entered the host, port, email, user and password of your SMTP server.", @@ -136,7 +147,7 @@ const configVariables: Prisma.ConfigCreateInput[] = [ secret: false, }, { - order: 12, + order: 14, key: "SMTP_HOST", description: "Host of the SMTP server", type: "string", @@ -144,7 +155,7 @@ const configVariables: Prisma.ConfigCreateInput[] = [ category: "smtp", }, { - order: 13, + order: 15, key: "SMTP_PORT", description: "Port of the SMTP server", type: "number", @@ -152,7 +163,7 @@ const configVariables: Prisma.ConfigCreateInput[] = [ category: "smtp", }, { - order: 14, + order: 16, key: "SMTP_EMAIL", description: "Email address which the emails get sent from", type: "string", @@ -160,7 +171,7 @@ const configVariables: Prisma.ConfigCreateInput[] = [ category: "smtp", }, { - order: 15, + order: 17, key: "SMTP_USERNAME", description: "Username of the SMTP server", type: "string", @@ -168,7 +179,7 @@ const configVariables: Prisma.ConfigCreateInput[] = [ category: "smtp", }, { - order: 16, + order: 18, key: "SMTP_PASSWORD", description: "Password of the SMTP server", type: "string", diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts index 5dd8e3c..2a9d562 100644 --- a/backend/src/auth/auth.controller.ts +++ b/backend/src/auth/auth.controller.ts @@ -3,6 +3,7 @@ import { Controller, ForbiddenException, HttpCode, + Param, Patch, Post, Req, @@ -21,6 +22,7 @@ import { AuthRegisterDTO } from "./dto/authRegister.dto"; import { AuthSignInDTO } from "./dto/authSignIn.dto"; import { AuthSignInTotpDTO } from "./dto/authSignInTotp.dto"; import { EnableTotpDTO } from "./dto/enableTotp.dto"; +import { ResetPasswordDTO } from "./dto/resetPassword.dto"; import { TokenDTO } from "./dto/token.dto"; import { UpdatePasswordDTO } from "./dto/updatePassword.dto"; import { VerifyTotpDTO } from "./dto/verifyTotp.dto"; @@ -34,8 +36,8 @@ export class AuthController { private config: ConfigService ) {} - @Throttle(10, 5 * 60) @Post("signUp") + @Throttle(10, 5 * 60) async signUp( @Body() dto: AuthRegisterDTO, @Res({ passthrough: true }) response: Response @@ -54,8 +56,8 @@ export class AuthController { return result; } - @Throttle(10, 5 * 60) @Post("signIn") + @Throttle(10, 5 * 60) @HttpCode(200) async signIn( @Body() dto: AuthSignInDTO, @@ -74,8 +76,8 @@ export class AuthController { return result; } - @Throttle(10, 5 * 60) @Post("signIn/totp") + @Throttle(10, 5 * 60) @HttpCode(200) async signInTotp( @Body() dto: AuthSignInTotpDTO, @@ -92,6 +94,20 @@ export class AuthController { return new TokenDTO().from(result); } + @Post("resetPassword/:email") + @Throttle(5, 5 * 60) + @HttpCode(204) + async requestResetPassword(@Param("email") email: string) { + return await this.authService.requestResetPassword(email); + } + + @Post("resetPassword") + @Throttle(5, 5 * 60) + @HttpCode(204) + async resetPassword(@Body() dto: ResetPasswordDTO) { + return await this.authService.resetPassword(dto.token, dto.password); + } + @Patch("password") @UseGuards(JwtGuard) async updatePassword( diff --git a/backend/src/auth/auth.module.ts b/backend/src/auth/auth.module.ts index 2b29236..56204d1 100644 --- a/backend/src/auth/auth.module.ts +++ b/backend/src/auth/auth.module.ts @@ -1,12 +1,13 @@ import { Module } from "@nestjs/common"; import { JwtModule } from "@nestjs/jwt"; +import { EmailModule } from "src/email/email.module"; import { AuthController } from "./auth.controller"; import { AuthService } from "./auth.service"; import { AuthTotpService } from "./authTotp.service"; import { JwtStrategy } from "./strategy/jwt.strategy"; @Module({ - imports: [JwtModule.register({})], + imports: [JwtModule.register({}), EmailModule], controllers: [AuthController], providers: [AuthService, AuthTotpService, JwtStrategy], exports: [AuthService], diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index ecd832f..7ddbcf3 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -10,6 +10,7 @@ import { PrismaClientKnownRequestError } from "@prisma/client/runtime"; import * as argon from "argon2"; import * as moment from "moment"; import { ConfigService } from "src/config/config.service"; +import { EmailService } from "src/email/email.service"; import { PrismaService } from "src/prisma/prisma.service"; import { AuthRegisterDTO } from "./dto/authRegister.dto"; import { AuthSignInDTO } from "./dto/authSignIn.dto"; @@ -19,7 +20,8 @@ export class AuthService { constructor( private prisma: PrismaService, private jwtService: JwtService, - private config: ConfigService + private config: ConfigService, + private emailService: EmailService ) {} async signUp(dto: AuthRegisterDTO) { @@ -87,6 +89,50 @@ export class AuthService { return { accessToken, refreshToken }; } + async requestResetPassword(email: string) { + const user = await this.prisma.user.findFirst({ + where: { email }, + include: { resetPasswordToken: true }, + }); + + if (!user) throw new BadRequestException("User not found"); + + // Delete old reset password token + if (user.resetPasswordToken) { + await this.prisma.resetPasswordToken.delete({ + where: { token: user.resetPasswordToken.token }, + }); + } + + const { token } = await this.prisma.resetPasswordToken.create({ + data: { + expiresAt: moment().add(1, "hour").toDate(), + user: { connect: { id: user.id } }, + }, + }); + + await this.emailService.sendResetPasswordEmail(user.email, token); + } + + async resetPassword(token: string, newPassword: string) { + const user = await this.prisma.user.findFirst({ + where: { resetPasswordToken: { token } }, + }); + + if (!user) throw new BadRequestException("Token invalid or expired"); + + const newPasswordHash = await argon.hash(newPassword); + + await this.prisma.resetPasswordToken.delete({ + where: { token }, + }); + + await this.prisma.user.update({ + where: { id: user.id }, + data: { password: newPasswordHash }, + }); + } + async updatePassword(user: User, oldPassword: string, newPassword: string) { if (!(await argon.verify(user.password, oldPassword))) throw new ForbiddenException("Invalid password"); diff --git a/backend/src/auth/authTotp.service.ts b/backend/src/auth/authTotp.service.ts index bc90701..1cbdeeb 100644 --- a/backend/src/auth/authTotp.service.ts +++ b/backend/src/auth/authTotp.service.ts @@ -6,10 +6,8 @@ import { } from "@nestjs/common"; import { User } from "@prisma/client"; import * as argon from "argon2"; -import * as crypto from "crypto"; import { authenticator, totp } from "otplib"; import * as qrcode from "qrcode-svg"; -import { ConfigService } from "src/config/config.service"; import { PrismaService } from "src/prisma/prisma.service"; import { AuthService } from "./auth.service"; import { AuthSignInTotpDTO } from "./dto/authSignInTotp.dto"; @@ -17,7 +15,6 @@ import { AuthSignInTotpDTO } from "./dto/authSignInTotp.dto"; @Injectable() export class AuthTotpService { constructor( - private config: ConfigService, private prisma: PrismaService, private authService: AuthService ) {} @@ -57,9 +54,7 @@ export class AuthTotpService { throw new BadRequestException("TOTP is not enabled"); } - const decryptedSecret = this.decryptTotpSecret(totpSecret, dto.password); - - const expected = authenticator.generate(decryptedSecret); + const expected = authenticator.generate(totpSecret); if (dto.totp !== expected) { throw new BadRequestException("Invalid code"); @@ -81,41 +76,6 @@ export class AuthTotpService { return { accessToken, refreshToken }; } - encryptTotpSecret(totpSecret: string, password: string) { - let iv = this.config.get("TOTP_SECRET"); - iv = Buffer.from(iv, "base64"); - const key = crypto - .createHash("sha256") - .update(String(password)) - .digest("base64") - .substr(0, 32); - - const cipher = crypto.createCipheriv("aes-256-cbc", key, iv); - - let encrypted = cipher.update(totpSecret); - - encrypted = Buffer.concat([encrypted, cipher.final()]); - - return encrypted.toString("base64"); - } - - decryptTotpSecret(encryptedTotpSecret: string, password: string) { - let iv = this.config.get("TOTP_SECRET"); - iv = Buffer.from(iv, "base64"); - const key = crypto - .createHash("sha256") - .update(String(password)) - .digest("base64") - .substr(0, 32); - - const encryptedText = Buffer.from(encryptedTotpSecret, "base64"); - const decipher = crypto.createDecipheriv("aes-256-cbc", key, iv); - let decrypted = decipher.update(encryptedText); - decrypted = Buffer.concat([decrypted, decipher.final()]); - - return decrypted.toString(); - } - async enableTotp(user: User, password: string) { if (!(await argon.verify(user.password, password))) throw new ForbiddenException("Invalid password"); @@ -132,7 +92,6 @@ export class AuthTotpService { // TODO: Maybe make the issuer configurable with env vars? const secret = authenticator.generateSecret(); - const encryptedSecret = this.encryptTotpSecret(secret, password); const otpURL = totp.keyuri( user.username || user.email, @@ -144,7 +103,7 @@ export class AuthTotpService { where: { id: user.id }, data: { totpEnabled: true, - totpSecret: encryptedSecret, + totpSecret: secret, }, }); @@ -177,9 +136,7 @@ export class AuthTotpService { throw new BadRequestException("TOTP is not in progress"); } - const decryptedSecret = this.decryptTotpSecret(totpSecret, password); - - const expected = authenticator.generate(decryptedSecret); + const expected = authenticator.generate(totpSecret); if (code !== expected) { throw new BadRequestException("Invalid code"); @@ -208,9 +165,7 @@ export class AuthTotpService { throw new BadRequestException("TOTP is not enabled"); } - const decryptedSecret = this.decryptTotpSecret(totpSecret, password); - - const expected = authenticator.generate(decryptedSecret); + const expected = authenticator.generate(totpSecret); if (code !== expected) { throw new BadRequestException("Invalid code"); diff --git a/backend/src/auth/dto/resetPassword.dto.ts b/backend/src/auth/dto/resetPassword.dto.ts new file mode 100644 index 0000000..ad8ea54 --- /dev/null +++ b/backend/src/auth/dto/resetPassword.dto.ts @@ -0,0 +1,8 @@ +import { PickType } from "@nestjs/swagger"; +import { IsString } from "class-validator"; +import { UserDTO } from "src/user/dto/user.dto"; + +export class ResetPasswordDTO extends PickType(UserDTO, ["password"]) { + @IsString() + token: string; +} diff --git a/backend/src/email/email.service.ts b/backend/src/email/email.service.ts index aa50aac..8e9bf46 100644 --- a/backend/src/email/email.service.ts +++ b/backend/src/email/email.service.ts @@ -58,6 +58,21 @@ export class EmailService { }); } + async sendResetPasswordEmail(recipientEmail: string, token: string) { + const resetPasswordUrl = `${this.config.get( + "APP_URL" + )}/auth/resetPassword/${token}`; + + await this.getTransporter().sendMail({ + from: `"Pingvin Share" <${this.config.get("SMTP_EMAIL")}>`, + to: recipientEmail, + subject: this.config.get("RESET_PASSWORD_EMAIL_SUBJECT"), + text: this.config + .get("RESET_PASSWORD_EMAIL_MESSAGE") + .replaceAll("{url}", resetPasswordUrl), + }); + } + async sendTestMail(recipientEmail: string) { try { await this.getTransporter().sendMail({ diff --git a/backend/src/user/user.service.ts b/backend/src/user/user.service.ts index 02f382b..52d514d 100644 --- a/backend/src/user/user.service.ts +++ b/backend/src/user/user.service.ts @@ -4,7 +4,6 @@ import * as argon from "argon2"; import { PrismaService } from "src/prisma/prisma.service"; import { CreateUserDTO } from "./dto/createUser.dto"; import { UpdateUserDto } from "./dto/updateUser.dto"; -import { UserDTO } from "./dto/user.dto"; @Injectable() export class UserSevice { diff --git a/frontend/src/components/admin/configuration/AdminConfigTable.tsx b/frontend/src/components/admin/configuration/AdminConfigTable.tsx index 80c6f30..7d59af1 100644 --- a/frontend/src/components/admin/configuration/AdminConfigTable.tsx +++ b/frontend/src/components/admin/configuration/AdminConfigTable.tsx @@ -35,6 +35,12 @@ const AdminConfigTable = () => { UpdateConfig[] >([]); + useEffect(() => { + if (config.get("SETUP_STATUS") != "FINISHED") { + config.refresh(); + } + }, []); + const updateConfigVariable = (configVariable: UpdateConfig) => { const index = updatedConfigVariables.findIndex( (item) => item.key === configVariable.key diff --git a/frontend/src/components/auth/SignInForm.tsx b/frontend/src/components/auth/SignInForm.tsx index 9d1fb49..4e0791c 100644 --- a/frontend/src/components/auth/SignInForm.tsx +++ b/frontend/src/components/auth/SignInForm.tsx @@ -2,6 +2,7 @@ import { Anchor, Button, Container, + Group, Paper, PasswordInput, Text, @@ -91,13 +92,7 @@ const SignInForm = ({ redirectPath }: { redirectPath: string }) => { return ( - ({ - fontFamily: `Greycliff CF, ${theme.fontFamily}`, - fontWeight: 900, - })} - > + <Title order={2} align="center" weight={900}> Welcome back {config.get("ALLOW_REGISTRATION") && ( @@ -118,7 +113,7 @@ const SignInForm = ({ redirectPath }: { redirectPath: string }) => { > { {...form.getInputProps("totp")} /> )} + {config.get("SMTP_ENABLED") && ( + + + Forgot password? + + + )} diff --git a/frontend/src/components/auth/SignUpForm.tsx b/frontend/src/components/auth/SignUpForm.tsx index f1fe330..5b65827 100644 --- a/frontend/src/components/auth/SignUpForm.tsx +++ b/frontend/src/components/auth/SignUpForm.tsx @@ -49,13 +49,7 @@ const SignUpForm = () => { return ( - ({ - fontFamily: `Greycliff CF, ${theme.fontFamily}`, - fontWeight: 900, - })} - > + <Title order={2} align="center" weight={900}> Sign up {config.get("ALLOW_REGISTRATION") && ( @@ -74,12 +68,12 @@ const SignUpForm = () => { > diff --git a/frontend/src/components/navBar/NavBar.tsx b/frontend/src/components/navBar/NavBar.tsx index 020ff7a..188bdb0 100644 --- a/frontend/src/components/navBar/NavBar.tsx +++ b/frontend/src/components/navBar/NavBar.tsx @@ -12,6 +12,7 @@ import { } from "@mantine/core"; import { useDisclosure } from "@mantine/hooks"; import Link from "next/link"; +import { useRouter } from "next/router"; import { ReactNode, useEffect, useState } from "react"; import useConfig from "../../hooks/config.hook"; import useUser from "../../hooks/user.hook"; @@ -109,11 +110,18 @@ const useStyles = createStyles((theme) => ({ const NavBar = () => { const { user } = useUser(); + const router = useRouter(); const config = useConfig(); const [opened, toggleOpened] = useDisclosure(false); - const authenticatedLinks = [ + const [currentRoute, setCurrentRoute] = useState(""); + + useEffect(() => { + setCurrentRoute(router.pathname); + }, [router.pathname]); + + const authenticatedLinks: NavLink[] = [ { link: "/upload", label: "Upload", @@ -126,32 +134,31 @@ const NavBar = () => { }, ]; - const [unauthenticatedLinks, setUnauthenticatedLinks] = useState([ + let unauthenticatedLinks: NavLink[] = [ { link: "/auth/signIn", label: "Sign in", }, - ]); + ]; - useEffect(() => { - if (config.get("SHOW_HOME_PAGE")) - setUnauthenticatedLinks((array) => [ - { - link: "/", - label: "Home", - }, - ...array, - ]); + if (config.get("ALLOW_UNAUTHENTICATED_SHARES")) { + unauthenticatedLinks.unshift({ + link: "/upload", + label: "Upload", + }); + } - if (config.get("ALLOW_REGISTRATION")) - setUnauthenticatedLinks((array) => [ - ...array, - { - link: "/auth/signUp", - label: "Sign up", - }, - ]); - }, []); + if (config.get("SHOW_HOME_PAGE")) + unauthenticatedLinks.unshift({ + link: "/", + label: "Home", + }); + + if (config.get("ALLOW_REGISTRATION")) + unauthenticatedLinks.push({ + link: "/auth/signUp", + label: "Sign up", + }); const { classes, cx } = useStyles(); const items = ( @@ -170,9 +177,7 @@ const NavBar = () => { href={link.link ?? ""} onClick={() => toggleOpened.toggle()} className={cx(classes.link, { - [classes.linkActive]: - typeof window != "undefined" && - window.location.pathname == link.link, + [classes.linkActive]: currentRoute == link.link, })} > {link.label} diff --git a/frontend/src/hooks/config.hook.ts b/frontend/src/hooks/config.hook.ts index 24ab489..730d8ee 100644 --- a/frontend/src/hooks/config.hook.ts +++ b/frontend/src/hooks/config.hook.ts @@ -4,14 +4,14 @@ import { ConfigHook } from "../types/config.type"; export const ConfigContext = createContext({ configVariables: [], - refresh: () => {}, + refresh: async () => {}, }); const useConfig = () => { const configContext = useContext(ConfigContext); return { get: (key: string) => configService.get(key, configContext.configVariables), - refresh: () => configContext.refresh(), + refresh: async () => configContext.refresh(), }; }; diff --git a/frontend/src/middleware.ts b/frontend/src/middleware.ts index 69d3e16..f0c6cba 100644 --- a/frontend/src/middleware.ts +++ b/frontend/src/middleware.ts @@ -12,6 +12,15 @@ export const config = { }; export async function middleware(request: NextRequest) { + const routes = { + unauthenticated: new Routes(["/auth/signIn", "/auth/resetPassword*", "/"]), + public: new Routes(["/share/*", "/upload/*"]), + setupStatusRegistered: new Routes(["/auth/*", "/admin/setup"]), + admin: new Routes(["/admin/*"]), + account: new Routes(["/account/*"]), + disabledRoutes: new Routes([]), + }; + // Get config from backend const config = await ( await fetch("http://localhost:8080/api/configs") @@ -21,14 +30,6 @@ export async function middleware(request: NextRequest) { 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; @@ -44,57 +45,51 @@ export async function middleware(request: NextRequest) { 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_REGISTRATION")) { + routes.disabledRoutes.routes.push("/auth/signUp"); } if (getConfig("ALLOW_UNAUTHENTICATED_SHARES")) { - publicRoutes = ["*"]; + routes.public.routes = ["*"]; } - 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 - ); + if (!getConfig("SMTP_ENABLED")) { + routes.disabledRoutes.routes.push("/auth/resetPassword*"); + } // prettier-ignore const rules = [ + // Disabled routes + { + condition: routes.disabledRoutes.contains(route), + path: "/", + }, // Setup status { condition: getConfig("SETUP_STATUS") == "STARTED" && route != "/auth/signUp", path: "/auth/signUp", }, { - condition: getConfig("SETUP_STATUS") == "REGISTERED" && !isSetupStatusRegisteredRoute, + condition: getConfig("SETUP_STATUS") == "REGISTERED" && !routes.setupStatusRegistered.contains(route), path: user ? "/admin/setup" : "/auth/signIn", }, // Authenticated state { - condition: user && isUnauthenticatedRoute, + condition: user && routes.unauthenticated.contains(route) && !getConfig("ALLOW_UNAUTHENTICATED_SHARES"), path: "/upload", }, // Unauthenticated state { - condition: !user && !isPublicRoute && !isUnauthenticatedRoute, + condition: !user && !routes.public.contains(route) && !routes.unauthenticated.contains(route), path: "/auth/signIn", }, { - condition: !user && isAccountRoute, + condition: !user && routes.account.contains(route), path: "/upload", }, // Admin privileges { - condition: isAdminRoute && !user?.isAdmin, + condition: routes.admin.contains(route) && !user?.isAdmin, path: "/upload", }, // Home page @@ -103,7 +98,6 @@ export async function middleware(request: NextRequest) { path: "/upload", }, ]; - for (const rule of rules) { if (rule.condition) { let { path } = rule; @@ -115,3 +109,17 @@ export async function middleware(request: NextRequest) { } } } + +// Helper class to check if a route matches a list of routes +class Routes { + // eslint-disable-next-line no-unused-vars + constructor(public routes: string[]) {} + + contains(_route: string) { + for (const route of this.routes) { + if (new RegExp("^" + route.replace(/\*/g, ".*") + "$").test(_route)) + return true; + } + return false; + } +} diff --git a/frontend/src/pages/auth/resetPassword/[resetPasswordToken].tsx b/frontend/src/pages/auth/resetPassword/[resetPasswordToken].tsx new file mode 100644 index 0000000..67045c2 --- /dev/null +++ b/frontend/src/pages/auth/resetPassword/[resetPasswordToken].tsx @@ -0,0 +1,81 @@ +import { + Button, + Container, + createStyles, + Group, + Paper, + PasswordInput, + Text, + Title, +} from "@mantine/core"; +import { useForm, yupResolver } from "@mantine/form"; +import { useRouter } from "next/router"; +import * as yup from "yup"; +import authService from "../../../services/auth.service"; +import toast from "../../../utils/toast.util"; + +const useStyles = createStyles((theme) => ({ + control: { + [theme.fn.smallerThan("xs")]: { + width: "100%", + }, + }, +})); + +const ResetPassword = () => { + const { classes } = useStyles(); + const router = useRouter(); + + const form = useForm({ + initialValues: { + password: "", + }, + validate: yupResolver( + yup.object().shape({ + password: yup.string().min(8).required(), + }) + ), + }); + + const resetPasswordToken = router.query.resetPasswordToken as string; + + return ( + + + Reset password + + + Enter your new password + + + +
{ + console.log(resetPasswordToken); + authService + .resetPassword(resetPasswordToken, values.password) + .then(() => { + toast.success("Your password has been reset successfully."); + + router.push("/auth/signIn"); + }) + .catch(toast.axiosError); + })} + > + + + + + +
+
+ ); +}; + +export default ResetPassword; diff --git a/frontend/src/pages/auth/resetPassword/index.tsx b/frontend/src/pages/auth/resetPassword/index.tsx new file mode 100644 index 0000000..9eba593 --- /dev/null +++ b/frontend/src/pages/auth/resetPassword/index.tsx @@ -0,0 +1,107 @@ +import { + Anchor, + Box, + Button, + Center, + Container, + createStyles, + Group, + Paper, + Text, + TextInput, + Title, +} from "@mantine/core"; +import { useForm, yupResolver } from "@mantine/form"; +import Link from "next/link"; +import { useRouter } from "next/router"; +import { TbArrowLeft } from "react-icons/tb"; +import * as yup from "yup"; +import authService from "../../../services/auth.service"; +import toast from "../../../utils/toast.util"; + +const useStyles = createStyles((theme) => ({ + title: { + fontSize: 26, + fontWeight: 900, + fontFamily: `Greycliff CF, ${theme.fontFamily}`, + }, + + controls: { + [theme.fn.smallerThan("xs")]: { + flexDirection: "column-reverse", + }, + }, + + control: { + [theme.fn.smallerThan("xs")]: { + width: "100%", + textAlign: "center", + }, + }, +})); + +const ResetPassword = () => { + const { classes } = useStyles(); + const router = useRouter(); + + const form = useForm({ + initialValues: { + email: "", + }, + validate: yupResolver( + yup.object().shape({ + email: yup.string().email().required(), + }) + ), + }); + + return ( + + + Forgot your password? + + + Enter your email to get a reset link + + + +
+ authService + .requestResetPassword(values.email) + .then(() => { + toast.success("The email has been sent."); + router.push("/auth/signIn"); + }) + .catch(toast.axiosError) + )} + > + + + +
+ + Back to login page +
+
+ +
+ +
+
+ ); +}; + +export default ResetPassword; diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx index 07e75ed..59f4d2f 100644 --- a/frontend/src/pages/index.tsx +++ b/frontend/src/pages/index.tsx @@ -10,8 +10,11 @@ import { } from "@mantine/core"; import Image from "next/image"; import Link from "next/link"; +import { useRouter } from "next/router"; +import { useEffect } from "react"; import { TbCheck } from "react-icons/tb"; import Meta from "../components/Meta"; +import useUser from "../hooks/user.hook"; const useStyles = createStyles((theme) => ({ inner: { @@ -67,6 +70,17 @@ const useStyles = createStyles((theme) => ({ export default function Home() { const { classes } = useStyles(); + const { refreshUser } = useUser(); + const router = useRouter(); + + // If the user is already logged in, redirect to the upload page + useEffect(() => { + refreshUser().then((user) => { + if (user) { + router.replace("/upload"); + } + }); + }, []); return ( <> diff --git a/frontend/src/services/auth.service.ts b/frontend/src/services/auth.service.ts index 5627333..0349a36 100644 --- a/frontend/src/services/auth.service.ts +++ b/frontend/src/services/auth.service.ts @@ -60,6 +60,14 @@ const refreshAccessToken = async () => { } }; +const requestResetPassword = async (email: string) => { + await api.post(`/auth/resetPassword/${email}`); +}; + +const resetPassword = async (token: string, password: string) => { + await api.post("/auth/resetPassword", { token, password }); +}; + const updatePassword = async (oldPassword: string, password: string) => { await api.patch("/auth/password", { oldPassword, password }); }; @@ -95,6 +103,8 @@ export default { signOut, refreshAccessToken, updatePassword, + requestResetPassword, + resetPassword, enableTOTP, verifyTOTP, disableTOTP,