瀏覽代碼

reset password flow

Milo Schwartz 6 月之前
父節點
當前提交
f224bfa4ee

+ 5 - 9
server/auth/2fa.ts

@@ -4,11 +4,12 @@ import { twoFactorBackupCodes } from "@server/db/schema";
 import { eq } from "drizzle-orm";
 import { decodeHex } from "oslo/encoding";
 import { TOTPController } from "oslo/otp";
+import { verifyPassword } from "./password";
 
 export async function verifyTotpCode(
     code: string,
     secret: string,
-    userId: string,
+    userId: string
 ): Promise<boolean> {
     if (code.length !== 6) {
         const validBackupCode = await verifyBackUpCode(code, userId);
@@ -16,7 +17,7 @@ export async function verifyTotpCode(
     } else {
         const validOTP = await new TOTPController().verify(
             code,
-            decodeHex(secret),
+            decodeHex(secret)
         );
 
         return validOTP;
@@ -25,7 +26,7 @@ export async function verifyTotpCode(
 
 export async function verifyBackUpCode(
     code: string,
-    userId: string,
+    userId: string
 ): Promise<boolean> {
     const allHashed = await db
         .select()
@@ -38,12 +39,7 @@ export async function verifyBackUpCode(
 
     let validId;
     for (const hashedCode of allHashed) {
-        const validCode = await verify(hashedCode.codeHash, code, {
-            memoryCost: 19456,
-            timeCost: 2,
-            outputLen: 32,
-            parallelism: 1,
-        });
+        const validCode = await verifyPassword(code, hashedCode.codeHash);
         if (validCode) {
             validId = hashedCode.codeId;
         }

+ 3 - 12
server/auth/resourceOtp.ts

@@ -8,6 +8,7 @@ import { sendEmail } from "@server/emails";
 import ResourceOTPCode from "@server/emails/templates/ResourceOTPCode";
 import config from "@server/config";
 import { hash, verify } from "@node-rs/argon2";
+import { hashPassword } from "./password";
 
 export async function sendResourceOtpEmail(
     email: string,
@@ -47,12 +48,7 @@ export async function generateResourceOtpCode(
 
     const otp = generateRandomString(8, alphabet("0-9", "A-Z", "a-z"));
 
-    const otpHash = await hash(otp, {
-        memoryCost: 19456,
-        timeCost: 2,
-        outputLen: 32,
-        parallelism: 1,
-    });
+    const otpHash = await hashPassword(otp);
 
     await db.insert(resourceOtp).values({
         resourceId,
@@ -84,12 +80,7 @@ export async function isValidOtp(
         return false;
     }
 
-    const validCode = await verify(record[0].otpHash, otp, {
-        memoryCost: 19456,
-        timeCost: 2,
-        outputLen: 32,
-        parallelism: 1
-    });
+    const validCode = await verifyPassword(otp, record[0].otpHash);
     if (!validCode) {
         return false;
     }

+ 1 - 0
server/db/schema.ts

@@ -150,6 +150,7 @@ export const emailVerificationCodes = sqliteTable("emailVerificationCodes", {
 
 export const passwordResetTokens = sqliteTable("passwordResetTokens", {
     tokenId: integer("id").primaryKey({ autoIncrement: true }),
+    email: text("email").notNull(),
     userId: text("userId")
         .notNull()
         .references(() => users.userId, { onDelete: "cascade" }),

+ 70 - 0
server/emails/templates/ResetPasswordCode.tsx

@@ -0,0 +1,70 @@
+import {
+    Body,
+    Container,
+    Head,
+    Heading,
+    Html,
+    Preview,
+    Section,
+    Text,
+    Tailwind
+} from "@react-email/components";
+import * as React from "react";
+
+interface Props {
+    email: string;
+    code: string;
+    link: string;
+}
+
+export const ResetPasswordCode = ({ email, code, link }: Props) => {
+    const previewText = `Reset your password, ${email}`;
+
+    return (
+        <Html>
+            <Head />
+            <Preview>{previewText}</Preview>
+            <Tailwind
+                config={{
+                    theme: {
+                        extend: {
+                            colors: {
+                                primary: "#F97317"
+                            }
+                        }
+                    }
+                }}
+            >
+                <Body className="font-sans">
+                    <Container className="bg-white border border-solid border-gray-200 p-6 max-w-lg mx-auto my-8 rounded-lg">
+                        <Heading className="text-2xl font-semibold text-gray-800 text-center">
+                            You've requested to reset your password
+                        </Heading>
+                        <Text className="text-base text-gray-700 mt-4">
+                            Hi {email || "there"},
+                        </Text>
+                        <Text className="text-base text-gray-700 mt-2">
+                            You’ve requested to reset your password. Please{" "}
+                            <a href={link} className="text-primary">
+                                click here
+                            </a>{" "}
+                            and follow the instructions to reset your
+                            password, or manually enter the following code:
+                        </Text>
+                        <Section className="text-center my-6">
+                            <Text className="inline-block bg-primary text-xl font-bold text-white py-2 px-4 border border-gray-300 rounded-xl">
+                                {code}
+                            </Text>
+                        </Section>
+                        <Text className="text-base text-gray-700 mt-2">
+                            If you didn’t request this, you can safely ignore
+                            this email.
+                        </Text>
+                    </Container>
+                </Body>
+            </Tailwind>
+        </Html>
+    );
+};
+
+export default ResetPasswordCode;

+ 2 - 6
server/routers/accessToken/generateAccessToken.ts

@@ -19,6 +19,7 @@ import { z } from "zod";
 import { fromError } from "zod-validation-error";
 import logger from "@server/logger";
 import { createDate, TimeSpan } from "oslo";
+import { hashPassword } from "@server/auth/password";
 
 export const generateAccessTokenBodySchema = z
     .object({
@@ -91,12 +92,7 @@ export async function generateAccessToken(
 
         const token = generateIdFromEntropySize(25);
 
-        const tokenHash = await hash(token, {
-            memoryCost: 19456,
-            timeCost: 2,
-            outputLen: 32,
-            parallelism: 1
-        });
+        const tokenHash = await hashPassword(token);
 
         const id = generateId(15);
         const [result] = await db

+ 15 - 18
server/routers/auth/login.ts

@@ -3,7 +3,7 @@ import {
     createSession,
     generateSessionToken,
     serializeSessionCookie,
-    verifySession,
+    verifySession
 } from "@server/auth";
 import db from "@server/db";
 import { users } from "@server/db/schema";
@@ -17,12 +17,15 @@ import { fromError } from "zod-validation-error";
 import { verifyTotpCode } from "@server/auth/2fa";
 import config from "@server/config";
 import logger from "@server/logger";
+import { verifyPassword } from "@server/auth/password";
 
-export const loginBodySchema = z.object({
-    email: z.string().email(),
-    password: z.string(),
-    code: z.string().optional(),
-}).strict();
+export const loginBodySchema = z
+    .object({
+        email: z.string().email(),
+        password: z.string(),
+        code: z.string().optional()
+    })
+    .strict();
 
 export type LoginBody = z.infer<typeof loginBodySchema>;
 
@@ -57,7 +60,7 @@ export async function login(
                 success: true,
                 error: false,
                 message: "Already logged in",
-                status: HttpCode.OK,
+                status: HttpCode.OK
             });
         }
 
@@ -76,15 +79,9 @@ export async function login(
 
         const existingUser = existingUserRes[0];
 
-        const validPassword = await verify(
-            existingUser.passwordHash,
+        const validPassword = await verifyPassword(
             password,
-            {
-                memoryCost: 19456,
-                timeCost: 2,
-                outputLen: 32,
-                parallelism: 1,
-            }
+            existingUser.passwordHash
         );
         if (!validPassword) {
             return next(
@@ -102,7 +99,7 @@ export async function login(
                     success: true,
                     error: false,
                     message: "Two-factor authentication required",
-                    status: HttpCode.ACCEPTED,
+                    status: HttpCode.ACCEPTED
                 });
             }
 
@@ -137,7 +134,7 @@ export async function login(
                 success: true,
                 error: false,
                 message: "Email verification code sent",
-                status: HttpCode.OK,
+                status: HttpCode.OK
             });
         }
 
@@ -146,7 +143,7 @@ export async function login(
             success: true,
             error: false,
             message: "Logged in successfully",
-            status: HttpCode.OK,
+            status: HttpCode.OK
         });
     } catch (e) {
         logger.error(e);

+ 39 - 22
server/routers/auth/requestPasswordReset.ts

@@ -7,16 +7,22 @@ import { response } from "@server/utils";
 import { db } from "@server/db";
 import { passwordResetTokens, users } from "@server/db/schema";
 import { eq } from "drizzle-orm";
-import { sha256 } from "oslo/crypto";
+import { alphabet, generateRandomString, sha256 } from "oslo/crypto";
 import { encodeHex } from "oslo/encoding";
 import { createDate } from "oslo";
 import logger from "@server/logger";
 import { generateIdFromEntropySize } from "@server/auth";
 import { TimeSpan } from "oslo";
+import config from "@server/config";
+import { sendEmail } from "@server/emails";
+import ResetPasswordCode from "@server/emails/templates/ResetPasswordCode";
+import { hashPassword } from "@server/auth/password";
 
-export const requestPasswordResetBody = z.object({
-    email: z.string().email(),
-}).strict();
+export const requestPasswordResetBody = z
+    .object({
+        email: z.string().email()
+    })
+    .strict();
 
 export type RequestPasswordResetBody = z.infer<typeof requestPasswordResetBody>;
 
@@ -27,7 +33,7 @@ export type RequestPasswordResetResponse = {
 export async function requestPasswordReset(
     req: Request,
     res: Response,
-    next: NextFunction,
+    next: NextFunction
 ): Promise<any> {
     const parsedBody = requestPasswordResetBody.safeParse(req.body);
 
@@ -35,8 +41,8 @@ export async function requestPasswordReset(
         return next(
             createHttpError(
                 HttpCode.BAD_REQUEST,
-                fromError(parsedBody.error).toString(),
-            ),
+                fromError(parsedBody.error).toString()
+            )
         );
     }
 
@@ -52,8 +58,8 @@ export async function requestPasswordReset(
             return next(
                 createHttpError(
                     HttpCode.BAD_REQUEST,
-                    "No user with that email exists",
-                ),
+                    "A user with that email does not exist"
+                )
             );
         }
 
@@ -61,36 +67,47 @@ export async function requestPasswordReset(
             .delete(passwordResetTokens)
             .where(eq(passwordResetTokens.userId, existingUser[0].userId));
 
-        const token = generateIdFromEntropySize(25);
-        const tokenHash = encodeHex(
-            await sha256(new TextEncoder().encode(token)),
-        );
+        const token = generateRandomString(8, alphabet("0-9", "A-Z", "a-z"));
+        const tokenHash = await hashPassword(token);
 
         await db.insert(passwordResetTokens).values({
             userId: existingUser[0].userId,
+            email: existingUser[0].email,
             tokenHash,
-            expiresAt: createDate(new TimeSpan(2, "h")).getTime(),
+            expiresAt: createDate(new TimeSpan(2, "h")).getTime()
         });
 
-        // TODO: send email with link to reset password on dashboard
-        // something like: https://example.com/auth/reset-password?email=${email}&?token=${token}
-        // for now, just log the token
+        const url = `${config.app.base_url}/auth/reset-password?email=${email}&token=${token}`;
+
+        await sendEmail(
+            ResetPasswordCode({
+                email,
+                code: token,
+                link: url
+            }),
+            {
+                from: config.email?.no_reply,
+                to: email,
+                subject: "Reset your password"
+            }
+        );
+
         return response<RequestPasswordResetResponse>(res, {
             data: {
-                sentEmail: true,
+                sentEmail: true
             },
             success: true,
             error: false,
-            message: "Password reset email sent",
-            status: HttpCode.OK,
+            message: "Password reset requested",
+            status: HttpCode.OK
         });
     } catch (e) {
         logger.error(e);
         return next(
             createHttpError(
                 HttpCode.INTERNAL_SERVER_ERROR,
-                "Failed to process password reset request",
-            ),
+                "Failed to process password reset request"
+            )
         );
     }
 }

+ 2 - 6
server/routers/auth/requestTotpSecret.ts

@@ -13,6 +13,7 @@ import { verify } from "@node-rs/argon2";
 import { createTOTPKeyURI } from "oslo/otp";
 import config from "@server/config";
 import logger from "@server/logger";
+import { verifyPassword } from "@server/auth/password";
 
 export const requestTotpSecretBody = z
     .object({
@@ -47,12 +48,7 @@ export async function requestTotpSecret(
     const user = req.user as User;
 
     try {
-        const validPassword = await verify(user.passwordHash, password, {
-            memoryCost: 19456,
-            timeCost: 2,
-            outputLen: 32,
-            parallelism: 1
-        });
+        const validPassword = await verifyPassword(password, user.passwordHash);
         if (!validPassword) {
             return next(unauthorized());
         }

+ 32 - 16
server/routers/auth/resetPassword.ts

@@ -8,7 +8,7 @@ import { db } from "@server/db";
 import { passwordResetTokens, users } from "@server/db/schema";
 import { eq } from "drizzle-orm";
 import { sha256 } from "oslo/crypto";
-import { hashPassword } from "@server/auth/password";
+import { hashPassword, verifyPassword } from "@server/auth/password";
 import { verifyTotpCode } from "@server/auth/2fa";
 import { passwordSchema } from "@server/auth/passwordSchema";
 import { encodeHex } from "oslo/encoding";
@@ -18,9 +18,10 @@ import logger from "@server/logger";
 
 export const resetPasswordBody = z
     .object({
-        token: z.string(),
+        email: z.string().email(),
+        token: z.string(), // reset secret code
         newPassword: passwordSchema,
-        code: z.string().optional()
+        code: z.string().optional() // 2fa code
     })
     .strict();
 
@@ -46,27 +47,28 @@ export async function resetPassword(
         );
     }
 
-    const { token, newPassword, code } = parsedBody.data;
+    const { token, newPassword, code, email } = parsedBody.data;
 
     try {
-        const tokenHash = encodeHex(
-            await sha256(new TextEncoder().encode(token))
-        );
-
         const resetRequest = await db
             .select()
             .from(passwordResetTokens)
-            .where(eq(passwordResetTokens.tokenHash, tokenHash));
+            .where(eq(passwordResetTokens.email, email));
+
+        if (!resetRequest || !resetRequest.length) {
+            return next(
+                createHttpError(
+                    HttpCode.BAD_REQUEST,
+                    "Invalid password reset token"
+                )
+            );
+        }
 
-        if (
-            !resetRequest ||
-            !resetRequest.length ||
-            !isWithinExpirationDate(new Date(resetRequest[0].expiresAt))
-        ) {
+        if (!isWithinExpirationDate(new Date(resetRequest[0].expiresAt))) {
             return next(
                 createHttpError(
                     HttpCode.BAD_REQUEST,
-                    "Invalid or expired password reset token"
+                    "Password reset token has expired"
                 )
             );
         }
@@ -112,6 +114,20 @@ export async function resetPassword(
             }
         }
 
+        const isTokenValid = await verifyPassword(
+            token,
+            resetRequest[0].tokenHash
+        );
+
+        if (!isTokenValid) {
+            return next(
+                createHttpError(
+                    HttpCode.BAD_REQUEST,
+                    "Invalid password reset token"
+                )
+            );
+        }
+
         const passwordHash = await hashPassword(newPassword);
 
         await invalidateAllSessions(resetRequest[0].userId);
@@ -123,7 +139,7 @@ export async function resetPassword(
 
         await db
             .delete(passwordResetTokens)
-            .where(eq(passwordResetTokens.tokenHash, tokenHash));
+            .where(eq(passwordResetTokens.email, email));
 
         // TODO: send email to user confirming password reset
 

+ 2 - 6
server/routers/auth/signup.ts

@@ -21,6 +21,7 @@ import {
 import { ActionsEnum } from "@server/auth/actions";
 import config from "@server/config";
 import logger from "@server/logger";
+import { hashPassword } from "@server/auth/password";
 
 export const signupBodySchema = z.object({
     email: z.string().email(),
@@ -51,12 +52,7 @@ export async function signup(
 
     const { email, password } = parsedBody.data;
 
-    const passwordHash = await hash(password, {
-        memoryCost: 19456,
-        timeCost: 2,
-        outputLen: 32,
-        parallelism: 1,
-    });
+    const passwordHash = await hashPassword(password);
     const userId = generateId(15);
 
     try {

+ 3 - 8
server/routers/newt/createNewt.ts

@@ -11,6 +11,7 @@ import moment from "moment";
 import { generateSessionToken } from "@server/auth";
 import { createNewtSession } from "@server/auth/newt";
 import { fromError } from "zod-validation-error";
+import { hashPassword } from "@server/auth/password";
 
 export const createNewtBodySchema = z.object({});
 
@@ -54,13 +55,7 @@ export async function createNewt(
             );
         }
 
-        // generate a newtId and secret
-        const secretHash = await hash(secret, {
-            memoryCost: 19456,
-            timeCost: 2,
-            outputLen: 32,
-            parallelism: 1,
-        });
+        const secretHash = await hashPassword(secret);
 
         await db.insert(newts).values({
             newtId: newtId,
@@ -99,7 +94,7 @@ export async function createNewt(
             );
         } else {
             console.error(e);
-            
+
             return next(
                 createHttpError(
                     HttpCode.INTERNAL_SERVER_ERROR,

+ 9 - 19
server/routers/newt/getToken.ts

@@ -2,7 +2,7 @@ import { verify } from "@node-rs/argon2";
 import {
     createSession,
     generateSessionToken,
-    verifySession,
+    verifySession
 } from "@server/auth";
 import db from "@server/db";
 import { newts } from "@server/db/schema";
@@ -14,11 +14,12 @@ import createHttpError from "http-errors";
 import { z } from "zod";
 import { fromError } from "zod-validation-error";
 import { createNewtSession, validateNewtSessionToken } from "@server/auth/newt";
+import { verifyPassword } from "@server/auth/password";
 
 export const newtGetTokenBodySchema = z.object({
     newtId: z.string(),
     secret: z.string(),
-    token: z.string().optional(),
+    token: z.string().optional()
 });
 
 export type NewtGetTokenBody = z.infer<typeof newtGetTokenBodySchema>;
@@ -43,16 +44,14 @@ export async function getToken(
 
     try {
         if (token) {
-            const { session, newt } = await validateNewtSessionToken(
-                token
-            );
+            const { session, newt } = await validateNewtSessionToken(token);
             if (session) {
                 return response<null>(res, {
                     data: null,
                     success: true,
                     error: false,
                     message: "Token session already valid",
-                    status: HttpCode.OK,
+                    status: HttpCode.OK
                 });
             }
         }
@@ -72,22 +71,13 @@ export async function getToken(
 
         const existingNewt = existingNewtRes[0];
 
-        const validSecret = await verify(
-            existingNewt.secretHash,
+        const validSecret = await verifyPassword(
             secret,
-            {
-                memoryCost: 19456,
-                timeCost: 2,
-                outputLen: 32,
-                parallelism: 1,
-            }
+            existingNewt.secretHash
         );
         if (!validSecret) {
             return next(
-                createHttpError(
-                    HttpCode.BAD_REQUEST,
-                    "Secret is incorrect"
-                )
+                createHttpError(HttpCode.BAD_REQUEST, "Secret is incorrect")
             );
         }
 
@@ -101,7 +91,7 @@ export async function getToken(
             success: true,
             error: false,
             message: "Token created successfully",
-            status: HttpCode.OK,
+            status: HttpCode.OK
         });
     } catch (e) {
         console.error(e);

+ 3 - 6
server/routers/resource/authWithAccessToken.ts

@@ -16,6 +16,7 @@ import config from "@server/config";
 import logger from "@server/logger";
 import { verify } from "@node-rs/argon2";
 import { isWithinExpirationDate } from "oslo";
+import { verifyPassword } from "@server/auth/password";
 
 const authWithAccessTokenBodySchema = z
     .object({
@@ -104,12 +105,8 @@ export async function authWithAccessToken(
             );
         }
 
-        const validCode = await verify(tokenItem.tokenHash, accessToken, {
-            memoryCost: 19456,
-            timeCost: 2,
-            outputLen: 32,
-            parallelism: 1
-        });
+        const validCode = await verifyPassword(tokenItem.tokenHash, accessToken);
+
         if (!validCode) {
             return next(
                 createHttpError(HttpCode.UNAUTHORIZED, "Invalid access token")

+ 3 - 8
server/routers/resource/authWithPassword.ts

@@ -15,6 +15,7 @@ import {
 } from "@server/auth/resource";
 import config from "@server/config";
 import logger from "@server/logger";
+import { verifyPassword } from "@server/auth/password";
 
 export const authWithPasswordBodySchema = z
     .object({
@@ -105,15 +106,9 @@ export async function authWithPassword(
             );
         }
 
-        const validPassword = await verify(
-            definedPassword.passwordHash,
+        const validPassword = await verifyPassword(
             password,
-            {
-                memoryCost: 19456,
-                timeCost: 2,
-                outputLen: 32,
-                parallelism: 1
-            }
+            definedPassword.passwordHash
         );
         if (!validPassword) {
             return next(

+ 5 - 6
server/routers/resource/authWithPincode.ts

@@ -23,6 +23,7 @@ import logger from "@server/logger";
 import config from "@server/config";
 import { AuthWithPasswordResponse } from "./authWithPassword";
 import { isValidOtp, sendResourceOtpEmail } from "@server/auth/resourceOtp";
+import { verifyPassword } from "@server/auth/password";
 
 export const authWithPincodeBodySchema = z
     .object({
@@ -116,12 +117,10 @@ export async function authWithPincode(
             );
         }
 
-        const validPincode = await verify(definedPincode.pincodeHash, pincode, {
-            memoryCost: 19456,
-            timeCost: 2,
-            outputLen: 32,
-            parallelism: 1
-        });
+        const validPincode = verifyPassword(
+            pincode,
+            definedPincode.pincodeHash
+        );
         if (!validPincode) {
             return next(
                 createHttpError(HttpCode.UNAUTHORIZED, "Incorrect PIN")

+ 2 - 6
server/routers/resource/setResourcePassword.ts

@@ -9,6 +9,7 @@ import { fromError } from "zod-validation-error";
 import { hash } from "@node-rs/argon2";
 import { response } from "@server/utils";
 import logger from "@server/logger";
+import { hashPassword } from "@server/auth/password";
 
 const setResourceAuthMethodsParamsSchema = z.object({
     resourceId: z.string().transform(Number).pipe(z.number().int().positive())
@@ -57,12 +58,7 @@ export async function setResourcePassword(
                 .where(eq(resourcePassword.resourceId, resourceId));
 
             if (password) {
-                const passwordHash = await hash(password, {
-                    memoryCost: 19456,
-                    timeCost: 2,
-                    outputLen: 32,
-                    parallelism: 1
-                });
+                const passwordHash = await hashPassword(password);
 
                 await trx
                     .insert(resourcePassword)

+ 2 - 6
server/routers/resource/setResourcePincode.ts

@@ -10,6 +10,7 @@ import { hash } from "@node-rs/argon2";
 import { response } from "@server/utils";
 import stoi from "@server/utils/stoi";
 import logger from "@server/logger";
+import { hashPassword } from "@server/auth/password";
 
 const setResourceAuthMethodsParamsSchema = z.object({
     resourceId: z.string().transform(Number).pipe(z.number().int().positive()),
@@ -61,12 +62,7 @@ export async function setResourcePincode(
                 .where(eq(resourcePincode.resourceId, resourceId));
 
             if (pincode) {
-                const pincodeHash = await hash(pincode, {
-                    memoryCost: 19456,
-                    timeCost: 2,
-                    outputLen: 32,
-                    parallelism: 1,
-                });
+                const pincodeHash = await hashPassword(pincode);
 
                 await trx
                     .insert(resourcePincode)

+ 2 - 6
server/routers/site/createSite.ts

@@ -13,6 +13,7 @@ import { fromError } from "zod-validation-error";
 import { hash } from "@node-rs/argon2";
 import { newts } from "@server/db/schema";
 import moment from "moment";
+import { hashPassword } from "@server/auth/password";
 
 const createSiteParamsSchema = z
     .object({
@@ -122,12 +123,7 @@ export async function createSite(
 
         // add the peer to the exit node
         if (type == "newt") {
-            const secretHash = await hash(secret!, {
-                memoryCost: 19456,
-                timeCost: 2,
-                outputLen: 32,
-                parallelism: 1
-            });
+            const secretHash = await hashPassword(secret!);
 
             await db.insert(newts).values({
                 newtId: newtId!,

+ 5 - 6
server/routers/user/acceptInvite.ts

@@ -10,6 +10,7 @@ import createHttpError from "http-errors";
 import logger from "@server/logger";
 import { fromError } from "zod-validation-error";
 import { isWithinExpirationDate } from "oslo";
+import { verifyPassword } from "@server/auth/password";
 
 const acceptInviteBodySchema = z
     .object({
@@ -62,12 +63,10 @@ export async function acceptInvite(
             );
         }
 
-        const validToken = await verify(existingInvite[0].tokenHash, token, {
-            memoryCost: 19456,
-            timeCost: 2,
-            outputLen: 32,
-            parallelism: 1
-        });
+        const validToken = await verifyPassword(
+            token,
+            existingInvite[0].tokenHash
+        );
         if (!validToken) {
             return next(
                 createHttpError(

+ 470 - 0
src/app/auth/reset-password/ResetPasswordForm.tsx

@@ -0,0 +1,470 @@
+"use client";
+
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useForm } from "react-hook-form";
+import { z } from "zod";
+import { useState } from "react";
+import { Button } from "@/components/ui/button";
+import {
+    Card,
+    CardContent,
+    CardDescription,
+    CardHeader,
+    CardTitle
+} from "@/components/ui/card";
+import {
+    Form,
+    FormControl,
+    FormDescription,
+    FormField,
+    FormItem,
+    FormLabel,
+    FormMessage
+} from "@/components/ui/form";
+import { Input } from "@/components/ui/input";
+import {
+    InputOTP,
+    InputOTPGroup,
+    InputOTPSeparator,
+    InputOTPSlot
+} from "@/components/ui/input-otp";
+import { AxiosResponse } from "axios";
+import {
+    RequestPasswordResetBody,
+    RequestPasswordResetResponse,
+    resetPasswordBody,
+    ResetPasswordBody,
+    ResetPasswordResponse
+} from "@server/routers/auth";
+import { Loader2 } from "lucide-react";
+import { Alert, AlertDescription } from "../../../components/ui/alert";
+import { useToast } from "@app/hooks/useToast";
+import { useRouter } from "next/navigation";
+import { formatAxiosError } from "@app/lib/utils";
+import { createApiClient } from "@app/api";
+import { useEnvContext } from "@app/hooks/useEnvContext";
+import { passwordSchema } from "@server/auth/passwordSchema";
+import { get } from "http";
+
+const requestSchema = z.object({
+    email: z.string().email()
+});
+
+const formSchema = z
+    .object({
+        email: z.string().email({ message: "Invalid email address" }),
+        token: z.string().min(8, { message: "Invalid token" }),
+        password: passwordSchema,
+        confirmPassword: passwordSchema
+    })
+    .refine((data) => data.password === data.confirmPassword, {
+        path: ["confirmPassword"],
+        message: "Passwords do not match"
+    });
+
+const mfaSchema = z.object({
+    code: z.string().length(6, { message: "Invalid code" })
+});
+
+export type ResetPasswordFormProps = {
+    emailParam?: string;
+    tokenParam?: string;
+    redirect?: string;
+};
+
+export default function ResetPasswordForm({
+    emailParam,
+    tokenParam,
+    redirect
+}: ResetPasswordFormProps) {
+    const router = useRouter();
+
+    const [error, setError] = useState<string | null>(null);
+    const [successMessage, setSuccessMessage] = useState<string | null>(null);
+    const [isSubmitting, setIsSubmitting] = useState(false);
+
+    function getState() {
+        if (emailParam && !tokenParam) {
+            return "request";
+        }
+
+        if (emailParam && tokenParam) {
+            return "reset";
+        }
+
+        return "request";
+    }
+
+    const [state, setState] = useState<"request" | "reset" | "mfa">(getState());
+
+    const { toast } = useToast();
+
+    const api = createApiClient(useEnvContext());
+
+    const form = useForm<z.infer<typeof formSchema>>({
+        resolver: zodResolver(formSchema),
+        defaultValues: {
+            email: emailParam || "",
+            token: tokenParam || "",
+            password: "",
+            confirmPassword: ""
+        }
+    });
+
+    const mfaForm = useForm<z.infer<typeof mfaSchema>>({
+        resolver: zodResolver(mfaSchema),
+        defaultValues: {
+            code: ""
+        }
+    });
+
+    const requestForm = useForm<z.infer<typeof requestSchema>>({
+        resolver: zodResolver(requestSchema),
+        defaultValues: {
+            email: emailParam || ""
+        }
+    });
+
+    async function onRequest(data: z.infer<typeof requestSchema>) {
+        const { email } = data;
+
+        setIsSubmitting(true);
+
+        const res = await api
+            .post<AxiosResponse<RequestPasswordResetResponse>>(
+                "/auth/reset-password/request",
+                {
+                    email
+                } as RequestPasswordResetBody
+            )
+            .catch((e) => {
+                setError(formatAxiosError(e, "An error occurred"));
+                console.error("Failed to request reset:", e);
+                setIsSubmitting(false);
+            });
+
+        if (res && res.data?.data) {
+            setError(null);
+            setState("reset");
+            setIsSubmitting(false);
+            form.setValue("email", email);
+        }
+    }
+
+    async function onReset(data: any) {
+        setIsSubmitting(true);
+
+        const { password, email, token } = form.getValues();
+        const { code } = mfaForm.getValues();
+
+        const res = await api
+            .post<AxiosResponse<ResetPasswordResponse>>(
+                "/auth/reset-password",
+                {
+                    email,
+                    token,
+                    newPassword: password,
+                    code
+                } as ResetPasswordBody
+            )
+            .catch((e) => {
+                setError(formatAxiosError(e, "An error occurred"));
+                console.error("Failed to reset password:", e);
+                setIsSubmitting(false);
+            });
+
+        console.log(res);
+
+        if (res) {
+            setError(null);
+
+            if (res.data.data?.codeRequested) {
+                setState("mfa");
+                setIsSubmitting(false);
+                mfaForm.reset();
+                return;
+            }
+
+            setSuccessMessage("Password reset successfully! Back to login...");
+
+            setTimeout(() => {
+                if (redirect && redirect.includes("http")) {
+                    window.location.href = redirect;
+                }
+                if (redirect) {
+                    router.push(redirect);
+                } else {
+                    router.push("/login");
+                }
+                setIsSubmitting(false);
+            }, 1500);
+        }
+    }
+
+    return (
+        <div>
+            <Card className="w-full max-w-md">
+                <CardHeader>
+                    <CardTitle>Reset Password</CardTitle>
+                    <CardDescription>
+                        Follow the steps to reset your password
+                    </CardDescription>
+                </CardHeader>
+                <CardContent>
+                    <div className="space-y-8">
+                        {state === "request" && (
+                            <Form {...requestForm}>
+                                <form
+                                    onSubmit={requestForm.handleSubmit(
+                                        onRequest
+                                    )}
+                                    className="space-y-4"
+                                    id="form"
+                                >
+                                    <FormField
+                                        control={requestForm.control}
+                                        name="email"
+                                        render={({ field }) => (
+                                            <FormItem>
+                                                <FormLabel>Email</FormLabel>
+                                                <FormControl>
+                                                    <Input
+                                                        placeholder="Enter your email"
+                                                        {...field}
+                                                    />
+                                                </FormControl>
+                                                <FormDescription>
+                                                    We'll send a password reset
+                                                    code to this email address.
+                                                </FormDescription>
+                                                <FormMessage />
+                                            </FormItem>
+                                        )}
+                                    />
+                                </form>
+                            </Form>
+                        )}
+
+                        {state === "reset" && (
+                            <Form {...form}>
+                                <form
+                                    onSubmit={form.handleSubmit(onReset)}
+                                    className="space-y-4"
+                                    id="form"
+                                >
+                                    <FormField
+                                        control={form.control}
+                                        name="email"
+                                        render={({ field }) => (
+                                            <FormItem>
+                                                <FormLabel>Email</FormLabel>
+                                                <FormControl>
+                                                    <Input
+                                                        placeholder="Email"
+                                                        {...field}
+                                                        disabled
+                                                    />
+                                                </FormControl>
+                                                <FormMessage />
+                                            </FormItem>
+                                        )}
+                                    />
+
+                                    {!tokenParam && (
+                                        <FormField
+                                            control={form.control}
+                                            name="token"
+                                            render={({ field }) => (
+                                                <FormItem>
+                                                    <FormLabel>
+                                                        Reset Code
+                                                    </FormLabel>
+                                                    <FormControl>
+                                                        <Input
+                                                            placeholder="Enter reset code sent to your email"
+                                                            type="password"
+                                                            {...field}
+                                                        />
+                                                    </FormControl>
+                                                    <FormMessage />
+                                                </FormItem>
+                                            )}
+                                        />
+                                    )}
+
+                                    <FormField
+                                        control={form.control}
+                                        name="password"
+                                        render={({ field }) => (
+                                            <FormItem>
+                                                <FormLabel>
+                                                    New Password
+                                                </FormLabel>
+                                                <FormControl>
+                                                    <Input
+                                                        type="password"
+                                                        placeholder="Password"
+                                                        {...field}
+                                                    />
+                                                </FormControl>
+                                                <FormMessage />
+                                            </FormItem>
+                                        )}
+                                    />
+                                    <FormField
+                                        control={form.control}
+                                        name="confirmPassword"
+                                        render={({ field }) => (
+                                            <FormItem>
+                                                <FormLabel>
+                                                    Confirm New Password
+                                                </FormLabel>
+                                                <FormControl>
+                                                    <Input
+                                                        type="password"
+                                                        placeholder="Confirm Password"
+                                                        {...field}
+                                                    />
+                                                </FormControl>
+                                                <FormMessage />
+                                            </FormItem>
+                                        )}
+                                    />
+                                </form>
+                            </Form>
+                        )}
+
+                        {state === "mfa" && (
+                            <Form {...mfaForm}>
+                                <form
+                                    onSubmit={mfaForm.handleSubmit(onReset)}
+                                    className="space-y-4"
+                                    id="form"
+                                >
+                                    <FormField
+                                        control={mfaForm.control}
+                                        name="code"
+                                        render={({ field }) => (
+                                            <FormItem>
+                                                <FormLabel>
+                                                    Authenticator Code
+                                                </FormLabel>
+                                                <FormControl>
+                                                    <div className="flex justify-center">
+                                                        <InputOTP
+                                                            maxLength={6}
+                                                            {...field}
+                                                        >
+                                                            <InputOTPGroup>
+                                                                <InputOTPSlot
+                                                                    index={0}
+                                                                />
+                                                                <InputOTPSlot
+                                                                    index={1}
+                                                                />
+                                                                <InputOTPSlot
+                                                                    index={2}
+                                                                />
+                                                            </InputOTPGroup>
+                                                            <InputOTPSeparator />
+                                                            <InputOTPGroup>
+                                                                <InputOTPSlot
+                                                                    index={3}
+                                                                />
+                                                                <InputOTPSlot
+                                                                    index={4}
+                                                                />
+                                                                <InputOTPSlot
+                                                                    index={5}
+                                                                />
+                                                            </InputOTPGroup>
+                                                        </InputOTP>
+                                                    </div>
+                                                </FormControl>
+                                                <FormMessage />
+                                            </FormItem>
+                                        )}
+                                    />
+                                </form>
+                            </Form>
+                        )}
+
+                        {error && (
+                            <Alert variant="destructive">
+                                <AlertDescription>{error}</AlertDescription>
+                            </Alert>
+                        )}
+
+                        {successMessage && (
+                            <Alert variant="success">
+                                <AlertDescription>
+                                    {successMessage}
+                                </AlertDescription>
+                            </Alert>
+                        )}
+
+                        <div className="space-y-4">
+                            {(state === "reset" || state === "mfa") && (
+                                <Button
+                                    type="submit"
+                                    form="form"
+                                    className="w-full"
+                                    disabled={isSubmitting}
+                                >
+                                    {isSubmitting && (
+                                        <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+                                    )}
+                                    {state === "reset"
+                                        ? "Reset Password"
+                                        : "Submit Code"}
+                                </Button>
+                            )}
+
+                            {state === "request" && (
+                                <Button
+                                    type="submit"
+                                    form="form"
+                                    className="w-full"
+                                    disabled={isSubmitting}
+                                >
+                                    {isSubmitting && (
+                                        <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+                                    )}
+                                    Request Reset
+                                </Button>
+                            )}
+
+                            {state === "mfa" && (
+                                <Button
+                                    type="button"
+                                    className="w-full"
+                                    variant="outline"
+                                    onClick={() => {
+                                        setState("reset");
+                                        mfaForm.reset();
+                                    }}
+                                >
+                                    Back to Password
+                                </Button>
+                            )}
+
+                            {(state === "mfa" || state === "reset") && (
+                                <Button
+                                    type="button"
+                                    className="w-full"
+                                    variant="outline"
+                                    onClick={() => {
+                                        setState("request");
+                                        form.reset();
+                                    }}
+                                >
+                                    Back to Email
+                                </Button>
+                            )}
+                        </div>
+                    </div>
+                </CardContent>
+            </Card>
+        </div>
+    );
+}

+ 32 - 0
src/app/auth/reset-password/page.tsx

@@ -0,0 +1,32 @@
+import { verifySession } from "@app/lib/auth/verifySession";
+import { redirect } from "next/navigation";
+import { cache } from "react";
+import ResetPasswordForm from "./ResetPasswordForm";
+
+export const dynamic = "force-dynamic";
+
+export default async function Page(props: {
+    searchParams: Promise<{
+        redirect: string | undefined;
+        email: string | undefined;
+        token: string | undefined;
+    }>;
+}) {
+    const searchParams = await props.searchParams;
+    const getUser = cache(verifySession);
+    const user = await getUser();
+
+    if (user) {
+        redirect("/");
+    }
+
+    return (
+        <>
+            <ResetPasswordForm
+                redirect={searchParams.redirect}
+                tokenParam={searchParams.token}
+                emailParam={searchParams.email}
+            />
+        </>
+    );
+}

+ 32 - 18
src/components/LoginForm.tsx

@@ -35,6 +35,7 @@ import {
     InputOTPSeparator,
     InputOTPSlot
 } from "./ui/input-otp";
+import Link from "next/link";
 
 type LoginFormProps = {
     redirect?: string;
@@ -79,7 +80,7 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
 
     async function onSubmit(values: any) {
         const { email, password } = form.getValues();
-        const { code } = mfaForm.getValues()
+        const { code } = mfaForm.getValues();
 
         setLoading(true);
 
@@ -151,23 +152,36 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
                                 </FormItem>
                             )}
                         />
-                        <FormField
-                            control={form.control}
-                            name="password"
-                            render={({ field }) => (
-                                <FormItem>
-                                    <FormLabel>Password</FormLabel>
-                                    <FormControl>
-                                        <Input
-                                            type="password"
-                                            placeholder="Enter your password"
-                                            {...field}
-                                        />
-                                    </FormControl>
-                                    <FormMessage />
-                                </FormItem>
-                            )}
-                        />
+
+                        <div className="space-y-4">
+                            <FormField
+                                control={form.control}
+                                name="password"
+                                render={({ field }) => (
+                                    <FormItem>
+                                        <FormLabel>Password</FormLabel>
+                                        <FormControl>
+                                            <Input
+                                                type="password"
+                                                placeholder="Enter your password"
+                                                {...field}
+                                            />
+                                        </FormControl>
+                                        <FormMessage />
+                                    </FormItem>
+                                )}
+                            />
+
+                            <div className="text-center">
+                                <Link
+                                    href={`/auth/reset-password${form.getValues().email ? `?email=${form.getValues().email}` : ""}`}
+                                    className="text-sm text-muted-foreground"
+                                >
+                                    Forgot password? Click here
+                                </Link>
+                            </div>
+                        </div>
+
                         {error && (
                             <Alert variant="destructive">
                                 <AlertDescription>{error}</AlertDescription>