feat!: reset password with email

This commit is contained in:
Elias Schneider
2023-02-09 18:17:53 +01:00
parent 8ab359b71d
commit 5d1a7f0310
20 changed files with 459 additions and 156 deletions

View File

@@ -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");

View File

@@ -22,9 +22,10 @@ model User {
loginTokens LoginToken[] loginTokens LoginToken[]
reverseShares ReverseShare[] reverseShares ReverseShare[]
totpEnabled Boolean @default(false) totpEnabled Boolean @default(false)
totpVerified Boolean @default(false) totpVerified Boolean @default(false)
totpSecret String? totpSecret String?
resetPasswordToken ResetPasswordToken?
} }
model RefreshToken { model RefreshToken {
@@ -49,6 +50,16 @@ model LoginToken {
used Boolean @default(false) 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 { model Share {
id String @id @default(uuid()) id String @id @default(uuid())
createdAt DateTime @default(now()) createdAt DateTime @default(now())

View File

@@ -21,15 +21,6 @@ const configVariables: Prisma.ConfigCreateInput[] = [
category: "internal", category: "internal",
locked: true, 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, order: 1,
key: "APP_URL", key: "APP_URL",
@@ -89,6 +80,15 @@ const configVariables: Prisma.ConfigCreateInput[] = [
}, },
{ {
order: 7, 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", key: "SHARE_RECEPIENTS_EMAIL_MESSAGE",
description: description:
"Message which gets sent to the share recipients. {creator} and {shareUrl} will be replaced with the creator's name and the share URL.", "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", category: "email",
}, },
{ {
order: 8, order: 9,
key: "SHARE_RECEPIENTS_EMAIL_SUBJECT", key: "REVERSE_SHARE_EMAIL_SUBJECT",
description: 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", type: "string",
value: "Files shared with you", value: "Reverse share link used",
category: "email", category: "email",
}, },
{ {
order: 9, order: 10,
key: "REVERSE_SHARE_EMAIL_MESSAGE", key: "REVERSE_SHARE_EMAIL_MESSAGE",
description: 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.", "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", category: "email",
}, },
{ {
order: 10, order: 11,
key: "REVERSE_SHARE_EMAIL_SUBJECT", key: "RESET_PASSWORD_EMAIL_SUBJECT",
description: 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", type: "string",
value: "Reverse share link used", value: "Pingvin Share password reset",
category: "email", 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", key: "SMTP_ENABLED",
description: description:
"Whether SMTP is enabled. Only set this to true if you entered the host, port, email, user and password of your SMTP server.", "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, secret: false,
}, },
{ {
order: 12, order: 14,
key: "SMTP_HOST", key: "SMTP_HOST",
description: "Host of the SMTP server", description: "Host of the SMTP server",
type: "string", type: "string",
@@ -144,7 +155,7 @@ const configVariables: Prisma.ConfigCreateInput[] = [
category: "smtp", category: "smtp",
}, },
{ {
order: 13, order: 15,
key: "SMTP_PORT", key: "SMTP_PORT",
description: "Port of the SMTP server", description: "Port of the SMTP server",
type: "number", type: "number",
@@ -152,7 +163,7 @@ const configVariables: Prisma.ConfigCreateInput[] = [
category: "smtp", category: "smtp",
}, },
{ {
order: 14, order: 16,
key: "SMTP_EMAIL", key: "SMTP_EMAIL",
description: "Email address which the emails get sent from", description: "Email address which the emails get sent from",
type: "string", type: "string",
@@ -160,7 +171,7 @@ const configVariables: Prisma.ConfigCreateInput[] = [
category: "smtp", category: "smtp",
}, },
{ {
order: 15, order: 17,
key: "SMTP_USERNAME", key: "SMTP_USERNAME",
description: "Username of the SMTP server", description: "Username of the SMTP server",
type: "string", type: "string",
@@ -168,7 +179,7 @@ const configVariables: Prisma.ConfigCreateInput[] = [
category: "smtp", category: "smtp",
}, },
{ {
order: 16, order: 18,
key: "SMTP_PASSWORD", key: "SMTP_PASSWORD",
description: "Password of the SMTP server", description: "Password of the SMTP server",
type: "string", type: "string",

View File

@@ -3,6 +3,7 @@ import {
Controller, Controller,
ForbiddenException, ForbiddenException,
HttpCode, HttpCode,
Param,
Patch, Patch,
Post, Post,
Req, Req,
@@ -21,6 +22,7 @@ import { AuthRegisterDTO } from "./dto/authRegister.dto";
import { AuthSignInDTO } from "./dto/authSignIn.dto"; import { AuthSignInDTO } from "./dto/authSignIn.dto";
import { AuthSignInTotpDTO } from "./dto/authSignInTotp.dto"; import { AuthSignInTotpDTO } from "./dto/authSignInTotp.dto";
import { EnableTotpDTO } from "./dto/enableTotp.dto"; import { EnableTotpDTO } from "./dto/enableTotp.dto";
import { ResetPasswordDTO } from "./dto/resetPassword.dto";
import { TokenDTO } from "./dto/token.dto"; import { TokenDTO } from "./dto/token.dto";
import { UpdatePasswordDTO } from "./dto/updatePassword.dto"; import { UpdatePasswordDTO } from "./dto/updatePassword.dto";
import { VerifyTotpDTO } from "./dto/verifyTotp.dto"; import { VerifyTotpDTO } from "./dto/verifyTotp.dto";
@@ -34,8 +36,8 @@ export class AuthController {
private config: ConfigService private config: ConfigService
) {} ) {}
@Throttle(10, 5 * 60)
@Post("signUp") @Post("signUp")
@Throttle(10, 5 * 60)
async signUp( async signUp(
@Body() dto: AuthRegisterDTO, @Body() dto: AuthRegisterDTO,
@Res({ passthrough: true }) response: Response @Res({ passthrough: true }) response: Response
@@ -54,8 +56,8 @@ export class AuthController {
return result; return result;
} }
@Throttle(10, 5 * 60)
@Post("signIn") @Post("signIn")
@Throttle(10, 5 * 60)
@HttpCode(200) @HttpCode(200)
async signIn( async signIn(
@Body() dto: AuthSignInDTO, @Body() dto: AuthSignInDTO,
@@ -74,8 +76,8 @@ export class AuthController {
return result; return result;
} }
@Throttle(10, 5 * 60)
@Post("signIn/totp") @Post("signIn/totp")
@Throttle(10, 5 * 60)
@HttpCode(200) @HttpCode(200)
async signInTotp( async signInTotp(
@Body() dto: AuthSignInTotpDTO, @Body() dto: AuthSignInTotpDTO,
@@ -92,6 +94,20 @@ export class AuthController {
return new TokenDTO().from(result); 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") @Patch("password")
@UseGuards(JwtGuard) @UseGuards(JwtGuard)
async updatePassword( async updatePassword(

View File

@@ -1,12 +1,13 @@
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { JwtModule } from "@nestjs/jwt"; import { JwtModule } from "@nestjs/jwt";
import { EmailModule } from "src/email/email.module";
import { AuthController } from "./auth.controller"; import { AuthController } from "./auth.controller";
import { AuthService } from "./auth.service"; import { AuthService } from "./auth.service";
import { AuthTotpService } from "./authTotp.service"; import { AuthTotpService } from "./authTotp.service";
import { JwtStrategy } from "./strategy/jwt.strategy"; import { JwtStrategy } from "./strategy/jwt.strategy";
@Module({ @Module({
imports: [JwtModule.register({})], imports: [JwtModule.register({}), EmailModule],
controllers: [AuthController], controllers: [AuthController],
providers: [AuthService, AuthTotpService, JwtStrategy], providers: [AuthService, AuthTotpService, JwtStrategy],
exports: [AuthService], exports: [AuthService],

View File

@@ -10,6 +10,7 @@ import { PrismaClientKnownRequestError } from "@prisma/client/runtime";
import * as argon from "argon2"; import * as argon from "argon2";
import * as moment from "moment"; import * as moment from "moment";
import { ConfigService } from "src/config/config.service"; import { ConfigService } from "src/config/config.service";
import { EmailService } from "src/email/email.service";
import { PrismaService } from "src/prisma/prisma.service"; import { PrismaService } from "src/prisma/prisma.service";
import { AuthRegisterDTO } from "./dto/authRegister.dto"; import { AuthRegisterDTO } from "./dto/authRegister.dto";
import { AuthSignInDTO } from "./dto/authSignIn.dto"; import { AuthSignInDTO } from "./dto/authSignIn.dto";
@@ -19,7 +20,8 @@ export class AuthService {
constructor( constructor(
private prisma: PrismaService, private prisma: PrismaService,
private jwtService: JwtService, private jwtService: JwtService,
private config: ConfigService private config: ConfigService,
private emailService: EmailService
) {} ) {}
async signUp(dto: AuthRegisterDTO) { async signUp(dto: AuthRegisterDTO) {
@@ -87,6 +89,50 @@ export class AuthService {
return { accessToken, refreshToken }; 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) { async updatePassword(user: User, oldPassword: string, newPassword: string) {
if (!(await argon.verify(user.password, oldPassword))) if (!(await argon.verify(user.password, oldPassword)))
throw new ForbiddenException("Invalid password"); throw new ForbiddenException("Invalid password");

View File

@@ -6,10 +6,8 @@ import {
} from "@nestjs/common"; } from "@nestjs/common";
import { User } from "@prisma/client"; import { User } from "@prisma/client";
import * as argon from "argon2"; import * as argon from "argon2";
import * as crypto from "crypto";
import { authenticator, totp } from "otplib"; import { authenticator, totp } from "otplib";
import * as qrcode from "qrcode-svg"; import * as qrcode from "qrcode-svg";
import { ConfigService } from "src/config/config.service";
import { PrismaService } from "src/prisma/prisma.service"; import { PrismaService } from "src/prisma/prisma.service";
import { AuthService } from "./auth.service"; import { AuthService } from "./auth.service";
import { AuthSignInTotpDTO } from "./dto/authSignInTotp.dto"; import { AuthSignInTotpDTO } from "./dto/authSignInTotp.dto";
@@ -17,7 +15,6 @@ import { AuthSignInTotpDTO } from "./dto/authSignInTotp.dto";
@Injectable() @Injectable()
export class AuthTotpService { export class AuthTotpService {
constructor( constructor(
private config: ConfigService,
private prisma: PrismaService, private prisma: PrismaService,
private authService: AuthService private authService: AuthService
) {} ) {}
@@ -57,9 +54,7 @@ export class AuthTotpService {
throw new BadRequestException("TOTP is not enabled"); throw new BadRequestException("TOTP is not enabled");
} }
const decryptedSecret = this.decryptTotpSecret(totpSecret, dto.password); const expected = authenticator.generate(totpSecret);
const expected = authenticator.generate(decryptedSecret);
if (dto.totp !== expected) { if (dto.totp !== expected) {
throw new BadRequestException("Invalid code"); throw new BadRequestException("Invalid code");
@@ -81,41 +76,6 @@ export class AuthTotpService {
return { accessToken, refreshToken }; 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) { async enableTotp(user: User, password: string) {
if (!(await argon.verify(user.password, password))) if (!(await argon.verify(user.password, password)))
throw new ForbiddenException("Invalid password"); throw new ForbiddenException("Invalid password");
@@ -132,7 +92,6 @@ export class AuthTotpService {
// TODO: Maybe make the issuer configurable with env vars? // TODO: Maybe make the issuer configurable with env vars?
const secret = authenticator.generateSecret(); const secret = authenticator.generateSecret();
const encryptedSecret = this.encryptTotpSecret(secret, password);
const otpURL = totp.keyuri( const otpURL = totp.keyuri(
user.username || user.email, user.username || user.email,
@@ -144,7 +103,7 @@ export class AuthTotpService {
where: { id: user.id }, where: { id: user.id },
data: { data: {
totpEnabled: true, totpEnabled: true,
totpSecret: encryptedSecret, totpSecret: secret,
}, },
}); });
@@ -177,9 +136,7 @@ export class AuthTotpService {
throw new BadRequestException("TOTP is not in progress"); throw new BadRequestException("TOTP is not in progress");
} }
const decryptedSecret = this.decryptTotpSecret(totpSecret, password); const expected = authenticator.generate(totpSecret);
const expected = authenticator.generate(decryptedSecret);
if (code !== expected) { if (code !== expected) {
throw new BadRequestException("Invalid code"); throw new BadRequestException("Invalid code");
@@ -208,9 +165,7 @@ export class AuthTotpService {
throw new BadRequestException("TOTP is not enabled"); throw new BadRequestException("TOTP is not enabled");
} }
const decryptedSecret = this.decryptTotpSecret(totpSecret, password); const expected = authenticator.generate(totpSecret);
const expected = authenticator.generate(decryptedSecret);
if (code !== expected) { if (code !== expected) {
throw new BadRequestException("Invalid code"); throw new BadRequestException("Invalid code");

View File

@@ -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;
}

View File

@@ -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) { async sendTestMail(recipientEmail: string) {
try { try {
await this.getTransporter().sendMail({ await this.getTransporter().sendMail({

View File

@@ -4,7 +4,6 @@ import * as argon from "argon2";
import { PrismaService } from "src/prisma/prisma.service"; import { PrismaService } from "src/prisma/prisma.service";
import { CreateUserDTO } from "./dto/createUser.dto"; import { CreateUserDTO } from "./dto/createUser.dto";
import { UpdateUserDto } from "./dto/updateUser.dto"; import { UpdateUserDto } from "./dto/updateUser.dto";
import { UserDTO } from "./dto/user.dto";
@Injectable() @Injectable()
export class UserSevice { export class UserSevice {

View File

@@ -35,6 +35,12 @@ const AdminConfigTable = () => {
UpdateConfig[] UpdateConfig[]
>([]); >([]);
useEffect(() => {
if (config.get("SETUP_STATUS") != "FINISHED") {
config.refresh();
}
}, []);
const updateConfigVariable = (configVariable: UpdateConfig) => { const updateConfigVariable = (configVariable: UpdateConfig) => {
const index = updatedConfigVariables.findIndex( const index = updatedConfigVariables.findIndex(
(item) => item.key === configVariable.key (item) => item.key === configVariable.key

View File

@@ -2,6 +2,7 @@ import {
Anchor, Anchor,
Button, Button,
Container, Container,
Group,
Paper, Paper,
PasswordInput, PasswordInput,
Text, Text,
@@ -91,13 +92,7 @@ const SignInForm = ({ redirectPath }: { redirectPath: string }) => {
return ( return (
<Container size={420} my={40}> <Container size={420} my={40}>
<Title <Title order={2} align="center" weight={900}>
align="center"
sx={(theme) => ({
fontFamily: `Greycliff CF, ${theme.fontFamily}`,
fontWeight: 900,
})}
>
Welcome back Welcome back
</Title> </Title>
{config.get("ALLOW_REGISTRATION") && ( {config.get("ALLOW_REGISTRATION") && (
@@ -118,7 +113,7 @@ const SignInForm = ({ redirectPath }: { redirectPath: string }) => {
> >
<TextInput <TextInput
label="Email or username" label="Email or username"
placeholder="you@email.com" placeholder="Your email or username"
{...form.getInputProps("emailOrUsername")} {...form.getInputProps("emailOrUsername")}
/> />
<PasswordInput <PasswordInput
@@ -136,6 +131,13 @@ const SignInForm = ({ redirectPath }: { redirectPath: string }) => {
{...form.getInputProps("totp")} {...form.getInputProps("totp")}
/> />
)} )}
{config.get("SMTP_ENABLED") && (
<Group position="right" mt="xs">
<Anchor component={Link} href="/auth/resetPassword" size="xs">
Forgot password?
</Anchor>
</Group>
)}
<Button fullWidth mt="xl" type="submit"> <Button fullWidth mt="xl" type="submit">
Sign in Sign in
</Button> </Button>

View File

@@ -49,13 +49,7 @@ const SignUpForm = () => {
return ( return (
<Container size={420} my={40}> <Container size={420} my={40}>
<Title <Title order={2} align="center" weight={900}>
align="center"
sx={(theme) => ({
fontFamily: `Greycliff CF, ${theme.fontFamily}`,
fontWeight: 900,
})}
>
Sign up Sign up
</Title> </Title>
{config.get("ALLOW_REGISTRATION") && ( {config.get("ALLOW_REGISTRATION") && (
@@ -74,12 +68,12 @@ const SignUpForm = () => {
> >
<TextInput <TextInput
label="Username" label="Username"
placeholder="john.doe" placeholder="Your username"
{...form.getInputProps("username")} {...form.getInputProps("username")}
/> />
<TextInput <TextInput
label="Email" label="Email"
placeholder="you@email.com" placeholder="Your email"
mt="md" mt="md"
{...form.getInputProps("email")} {...form.getInputProps("email")}
/> />

View File

@@ -12,6 +12,7 @@ import {
} from "@mantine/core"; } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks"; import { useDisclosure } from "@mantine/hooks";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router";
import { ReactNode, useEffect, useState } from "react"; import { ReactNode, useEffect, useState } from "react";
import useConfig from "../../hooks/config.hook"; import useConfig from "../../hooks/config.hook";
import useUser from "../../hooks/user.hook"; import useUser from "../../hooks/user.hook";
@@ -109,11 +110,18 @@ const useStyles = createStyles((theme) => ({
const NavBar = () => { const NavBar = () => {
const { user } = useUser(); const { user } = useUser();
const router = useRouter();
const config = useConfig(); const config = useConfig();
const [opened, toggleOpened] = useDisclosure(false); const [opened, toggleOpened] = useDisclosure(false);
const authenticatedLinks = [ const [currentRoute, setCurrentRoute] = useState("");
useEffect(() => {
setCurrentRoute(router.pathname);
}, [router.pathname]);
const authenticatedLinks: NavLink[] = [
{ {
link: "/upload", link: "/upload",
label: "Upload", label: "Upload",
@@ -126,32 +134,31 @@ const NavBar = () => {
}, },
]; ];
const [unauthenticatedLinks, setUnauthenticatedLinks] = useState<NavLink[]>([ let unauthenticatedLinks: NavLink[] = [
{ {
link: "/auth/signIn", link: "/auth/signIn",
label: "Sign in", label: "Sign in",
}, },
]); ];
useEffect(() => { if (config.get("ALLOW_UNAUTHENTICATED_SHARES")) {
if (config.get("SHOW_HOME_PAGE")) unauthenticatedLinks.unshift({
setUnauthenticatedLinks((array) => [ link: "/upload",
{ label: "Upload",
link: "/", });
label: "Home", }
},
...array,
]);
if (config.get("ALLOW_REGISTRATION")) if (config.get("SHOW_HOME_PAGE"))
setUnauthenticatedLinks((array) => [ unauthenticatedLinks.unshift({
...array, link: "/",
{ label: "Home",
link: "/auth/signUp", });
label: "Sign up",
}, if (config.get("ALLOW_REGISTRATION"))
]); unauthenticatedLinks.push({
}, []); link: "/auth/signUp",
label: "Sign up",
});
const { classes, cx } = useStyles(); const { classes, cx } = useStyles();
const items = ( const items = (
@@ -170,9 +177,7 @@ const NavBar = () => {
href={link.link ?? ""} href={link.link ?? ""}
onClick={() => toggleOpened.toggle()} onClick={() => toggleOpened.toggle()}
className={cx(classes.link, { className={cx(classes.link, {
[classes.linkActive]: [classes.linkActive]: currentRoute == link.link,
typeof window != "undefined" &&
window.location.pathname == link.link,
})} })}
> >
{link.label} {link.label}

View File

@@ -4,14 +4,14 @@ import { ConfigHook } from "../types/config.type";
export const ConfigContext = createContext<ConfigHook>({ export const ConfigContext = createContext<ConfigHook>({
configVariables: [], configVariables: [],
refresh: () => {}, refresh: async () => {},
}); });
const useConfig = () => { const useConfig = () => {
const configContext = useContext(ConfigContext); const configContext = useContext(ConfigContext);
return { return {
get: (key: string) => configService.get(key, configContext.configVariables), get: (key: string) => configService.get(key, configContext.configVariables),
refresh: () => configContext.refresh(), refresh: async () => configContext.refresh(),
}; };
}; };

View File

@@ -12,6 +12,15 @@ export const config = {
}; };
export async function middleware(request: NextRequest) { 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 // Get config from backend
const config = await ( const config = await (
await fetch("http://localhost:8080/api/configs") await fetch("http://localhost:8080/api/configs")
@@ -21,14 +30,6 @@ export async function middleware(request: NextRequest) {
return configService.get(key, config); 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; const route = request.nextUrl.pathname;
let user: { isAdmin: boolean } | null = null; let user: { isAdmin: boolean } | null = null;
const accessToken = request.cookies.get("access_token")?.value; const accessToken = request.cookies.get("access_token")?.value;
@@ -44,57 +45,51 @@ export async function middleware(request: NextRequest) {
user = null; user = null;
} }
const unauthenticatedRoutes = ["/auth/signIn", "/"]; if (!getConfig("ALLOW_REGISTRATION")) {
let publicRoutes = ["/share/*", "/upload/*"]; routes.disabledRoutes.routes.push("/auth/signUp");
const setupStatusRegisteredRoutes = ["/auth/*", "/admin/setup"];
const adminRoutes = ["/admin/*"];
const accountRoutes = ["/account/*"];
if (getConfig("ALLOW_REGISTRATION")) {
unauthenticatedRoutes.push("/auth/signUp");
} }
if (getConfig("ALLOW_UNAUTHENTICATED_SHARES")) { if (getConfig("ALLOW_UNAUTHENTICATED_SHARES")) {
publicRoutes = ["*"]; routes.public.routes = ["*"];
} }
const isPublicRoute = containsRoute(publicRoutes, route); if (!getConfig("SMTP_ENABLED")) {
const isUnauthenticatedRoute = containsRoute(unauthenticatedRoutes, route); routes.disabledRoutes.routes.push("/auth/resetPassword*");
const isAdminRoute = containsRoute(adminRoutes, route); }
const isAccountRoute = containsRoute(accountRoutes, route);
const isSetupStatusRegisteredRoute = containsRoute(
setupStatusRegisteredRoutes,
route
);
// prettier-ignore // prettier-ignore
const rules = [ const rules = [
// Disabled routes
{
condition: routes.disabledRoutes.contains(route),
path: "/",
},
// Setup status // Setup status
{ {
condition: getConfig("SETUP_STATUS") == "STARTED" && route != "/auth/signUp", condition: getConfig("SETUP_STATUS") == "STARTED" && route != "/auth/signUp",
path: "/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", path: user ? "/admin/setup" : "/auth/signIn",
}, },
// Authenticated state // Authenticated state
{ {
condition: user && isUnauthenticatedRoute, condition: user && routes.unauthenticated.contains(route) && !getConfig("ALLOW_UNAUTHENTICATED_SHARES"),
path: "/upload", path: "/upload",
}, },
// Unauthenticated state // Unauthenticated state
{ {
condition: !user && !isPublicRoute && !isUnauthenticatedRoute, condition: !user && !routes.public.contains(route) && !routes.unauthenticated.contains(route),
path: "/auth/signIn", path: "/auth/signIn",
}, },
{ {
condition: !user && isAccountRoute, condition: !user && routes.account.contains(route),
path: "/upload", path: "/upload",
}, },
// Admin privileges // Admin privileges
{ {
condition: isAdminRoute && !user?.isAdmin, condition: routes.admin.contains(route) && !user?.isAdmin,
path: "/upload", path: "/upload",
}, },
// Home page // Home page
@@ -103,7 +98,6 @@ export async function middleware(request: NextRequest) {
path: "/upload", path: "/upload",
}, },
]; ];
for (const rule of rules) { for (const rule of rules) {
if (rule.condition) { if (rule.condition) {
let { path } = rule; 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;
}
}

View File

@@ -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 (
<Container size={460} my={30}>
<Title order={2} weight={900} align="center">
Reset password
</Title>
<Text color="dimmed" size="sm" align="center">
Enter your new password
</Text>
<Paper withBorder shadow="md" p={30} radius="md" mt="xl">
<form
onSubmit={form.onSubmit((values) => {
console.log(resetPasswordToken);
authService
.resetPassword(resetPasswordToken, values.password)
.then(() => {
toast.success("Your password has been reset successfully.");
router.push("/auth/signIn");
})
.catch(toast.axiosError);
})}
>
<PasswordInput
label="New password"
placeholder="••••••••••"
{...form.getInputProps("password")}
/>
<Group position="right" mt="lg">
<Button type="submit" className={classes.control}>
Reset password
</Button>
</Group>
</form>
</Paper>
</Container>
);
};
export default ResetPassword;

View File

@@ -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 (
<Container size={460} my={30}>
<Title order={2} weight={900} align="center">
Forgot your password?
</Title>
<Text color="dimmed" size="sm" align="center">
Enter your email to get a reset link
</Text>
<Paper withBorder shadow="md" p={30} radius="md" mt="xl">
<form
onSubmit={form.onSubmit((values) =>
authService
.requestResetPassword(values.email)
.then(() => {
toast.success("The email has been sent.");
router.push("/auth/signIn");
})
.catch(toast.axiosError)
)}
>
<TextInput
label="Your email"
placeholder="Your email"
{...form.getInputProps("email")}
/>
<Group position="apart" mt="lg" className={classes.controls}>
<Anchor
component={Link}
color="dimmed"
size="sm"
className={classes.control}
href={"/auth/signIn"}
>
<Center inline>
<TbArrowLeft size={12} />
<Box ml={5}>Back to login page</Box>
</Center>
</Anchor>
<Button type="submit" className={classes.control}>
Reset password
</Button>
</Group>
</form>
</Paper>
</Container>
);
};
export default ResetPassword;

View File

@@ -10,8 +10,11 @@ import {
} from "@mantine/core"; } from "@mantine/core";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router";
import { useEffect } from "react";
import { TbCheck } from "react-icons/tb"; import { TbCheck } from "react-icons/tb";
import Meta from "../components/Meta"; import Meta from "../components/Meta";
import useUser from "../hooks/user.hook";
const useStyles = createStyles((theme) => ({ const useStyles = createStyles((theme) => ({
inner: { inner: {
@@ -67,6 +70,17 @@ const useStyles = createStyles((theme) => ({
export default function Home() { export default function Home() {
const { classes } = useStyles(); 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 ( return (
<> <>

View File

@@ -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) => { const updatePassword = async (oldPassword: string, password: string) => {
await api.patch("/auth/password", { oldPassword, password }); await api.patch("/auth/password", { oldPassword, password });
}; };
@@ -95,6 +103,8 @@ export default {
signOut, signOut,
refreshAccessToken, refreshAccessToken,
updatePassword, updatePassword,
requestResetPassword,
resetPassword,
enableTOTP, enableTOTP,
verifyTOTP, verifyTOTP,
disableTOTP, disableTOTP,