added reset password workflow

This commit is contained in:
Milo Schwartz 2024-10-05 17:01:49 -04:00
parent 838047bb4c
commit 7d66a6ff66
No known key found for this signature in database
12 changed files with 278 additions and 29 deletions

View file

@ -120,6 +120,15 @@ export const emailVerificationCodes = sqliteTable("emailVerificationCodes", {
expiresAt: integer("expiresAt").notNull(),
});
export const passwordResetTokens = sqliteTable("passwordResetTokens", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("userId")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
tokenHash: text("tokenHash").notNull(),
expiresAt: integer("expiresAt").notNull(),
});
// Define the model types for type inference
export type Org = InferSelectModel<typeof orgs>;
export type User = InferSelectModel<typeof users>;
@ -133,3 +142,4 @@ export type EmailVerificationCode = InferSelectModel<
typeof emailVerificationCodes
>;
export type TwoFactorBackupCode = InferSelectModel<typeof twoFactorBackupCodes>;
export type PasswordResetToken = InferSelectModel<typeof passwordResetTokens>;

View file

@ -19,10 +19,6 @@ export async function verifyTotpCode(
decodeHex(secret),
);
if (!validOTP) {
await new Promise((resolve) => setTimeout(resolve, 250)); // delay to prevent brute force attack
}
return validOTP;
}
}

View file

@ -98,6 +98,8 @@ export async function changePassword(
await lucia.invalidateUserSessions(user.id);
// TODO: send email to user confirming password change
return response(res, {
data: null,
success: true,

View file

@ -90,6 +90,8 @@ export async function disable2fa(
.delete(twoFactorBackupCodes)
.where(eq(twoFactorBackupCodes.userId, user.id));
// TODO: send email to user confirming two-factor authentication is disabled
return response<null>(res, {
data: null,
success: true,

View file

@ -12,3 +12,5 @@ export * from "./verifyTargetAccess";
export * from "./verifyEmail";
export * from "./requestEmailVerificationCode";
export * from "./changePassword";
export * from "./requestPasswordReset";
export * from "./resetPassword";

View file

@ -80,7 +80,6 @@ export async function login(
},
);
if (!validPassword) {
await new Promise((resolve) => setTimeout(resolve, 250)); // delay to prevent brute force attacks
return next(
createHttpError(
HttpCode.BAD_REQUEST,

View file

@ -10,10 +10,6 @@ export async function verifyPassword(
outputLen: 32,
parallelism: 1,
});
if (!validPassword) {
await new Promise((resolve) => setTimeout(resolve, 250)); // delay to prevent brute force attacks
}
return validPassword;
}

View file

@ -0,0 +1,96 @@
import { Request, Response, NextFunction } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
import { fromError } from "zod-validation-error";
import HttpCode from "@server/types/HttpCode";
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 { generateIdFromEntropySize, TimeSpan } from "lucia";
import { encodeHex } from "oslo/encoding";
import { createDate } from "oslo";
import logger from "@server/logger";
export const requestPasswordResetBody = z.object({
email: z.string().email(),
});
export type RequestPasswordResetBody = z.infer<typeof requestPasswordResetBody>;
export type RequestPasswordResetResponse = {
sentEmail: boolean;
};
export async function requestPasswordReset(
req: Request,
res: Response,
next: NextFunction,
): Promise<any> {
const parsedBody = requestPasswordResetBody.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString(),
),
);
}
const { email } = parsedBody.data;
try {
const existingUser = await db
.select()
.from(users)
.where(eq(users.email, email));
if (!existingUser || !existingUser.length) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"No user with that email exists",
),
);
}
await db
.delete(passwordResetTokens)
.where(eq(passwordResetTokens.userId, existingUser[0].id));
const token = generateIdFromEntropySize(25);
const tokenHash = encodeHex(
await sha256(new TextEncoder().encode(token)),
);
await db.insert(passwordResetTokens).values({
userId: existingUser[0].id,
tokenHash,
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
logger.debug(`Password reset token: ${token}`);
return response<RequestPasswordResetResponse>(res, {
data: {
sentEmail: true,
},
success: true,
error: false,
message: "Password reset email sent",
status: HttpCode.OK,
});
} catch (e) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to process password reset request"
),
);
}
}

View file

@ -51,7 +51,6 @@ export async function requestTotpSecret(
parallelism: 1,
});
if (!validPassword) {
await new Promise((resolve) => setTimeout(resolve, 250)); // delay to prevent brute force attacks
return next(unauthorized());
}

View file

@ -0,0 +1,142 @@
import { Request, Response, NextFunction } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
import { fromError } from "zod-validation-error";
import HttpCode from "@server/types/HttpCode";
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 { hashPassword } from "./password";
import { verifyTotpCode } from "./2fa";
import { passwordSchema } from "./passwordSchema";
import { encodeHex } from "oslo/encoding";
import { isWithinExpirationDate } from "oslo";
import lucia from "@server/auth";
export const resetPasswordBody = z.object({
token: z.string(),
newPassword: passwordSchema,
code: z.string().optional(),
});
export type ResetPasswordBody = z.infer<typeof resetPasswordBody>;
export type ResetPasswordResponse = {
codeRequested?: boolean;
};
export async function resetPassword(
req: Request,
res: Response,
next: NextFunction,
): Promise<any> {
const parsedBody = resetPasswordBody.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString(),
),
);
}
const { token, newPassword, code } = parsedBody.data;
try {
const tokenHash = encodeHex(
await sha256(new TextEncoder().encode(token)),
);
const resetRequest = await db
.select()
.from(passwordResetTokens)
.where(eq(passwordResetTokens.tokenHash, tokenHash));
if (
!resetRequest ||
!resetRequest.length ||
!isWithinExpirationDate(new Date(resetRequest[0].expiresAt))
) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invalid or expired password reset token",
),
);
}
const user = await db
.select()
.from(users)
.where(eq(users.id, resetRequest[0].userId));
if (!user || !user.length) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"User not found",
),
);
}
if (user[0].twoFactorEnabled) {
if (!code) {
return response<ResetPasswordResponse>(res, {
data: { codeRequested: true },
success: true,
error: false,
message: "Two-factor authentication required",
status: HttpCode.ACCEPTED,
});
}
const validOTP = await verifyTotpCode(
code!,
user[0].twoFactorSecret!,
user[0].id,
);
if (!validOTP) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invalid two-factor authentication code",
),
);
}
}
const passwordHash = await hashPassword(newPassword);
await lucia.invalidateUserSessions(resetRequest[0].userId);
await db
.update(users)
.set({ passwordHash })
.where(eq(users.id, resetRequest[0].userId));
await db
.delete(passwordResetTokens)
.where(eq(passwordResetTokens.tokenHash, tokenHash));
// TODO: send email to user confirming password reset
return response<ResetPasswordResponse>(res, {
data: null,
success: true,
error: false,
message: "Password reset successfully",
status: HttpCode.OK,
});
} catch (e) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to reset password",
),
);
}
}

View file

@ -19,7 +19,7 @@ export type VerifyTotpBody = z.infer<typeof verifyTotpBody>;
export type VerifyTotpResponse = {
valid: boolean;
backupCodes: string[];
backupCodes?: string[];
};
export async function verifyTotp(
@ -63,26 +63,33 @@ export async function verifyTotp(
try {
const valid = await verifyTotpCode(code, user.twoFactorSecret, user.id);
const backupCodes = await generateBackupCodes();
for (const code of backupCodes) {
const hash = await hashPassword(code);
await db.insert(twoFactorBackupCodes).values({
userId: user.id,
codeHash: hash,
});
}
let codes;
if (valid) {
// if valid, enable two-factor authentication; the totp secret is no longer temporary
await db
.update(users)
.set({ twoFactorEnabled: true })
.where(eq(users.id, user.id));
const backupCodes = await generateBackupCodes();
codes = backupCodes;
for (const code of backupCodes) {
const hash = await hashPassword(code);
await db.insert(twoFactorBackupCodes).values({
userId: user.id,
codeHash: hash,
});
}
}
// TODO: send email to user confirming two-factor authentication is enabled
return response<VerifyTotpResponse>(res, {
data: { valid, backupCodes },
data: {
valid,
...(valid && codes ? { backupCodes: codes } : {}),
},
success: true,
error: false,
message: valid

View file

@ -110,20 +110,16 @@ authRouter.use(
authRouter.put("/signup", auth.signup);
authRouter.post("/login", auth.login);
authRouter.post("/logout", auth.logout);
authRouter.post("/2fa/enable", verifySessionUserMiddleware, auth.verifyTotp);
authRouter.post(
"/2fa/verify-code",
verifySessionUserMiddleware,
auth.verifyTotp,
);
authRouter.post(
"/2fa/request-secret",
"/2fa/request",
verifySessionUserMiddleware,
auth.requestTotpSecret,
);
authRouter.post("/2fa/disable", verifySessionUserMiddleware, auth.disable2fa);
authRouter.post("/verify-email", verifySessionMiddleware, auth.verifyEmail);
authRouter.post(
"/request-email-code",
"/verify-email/request",
verifySessionMiddleware,
auth.requestEmailVerificationCode,
);
@ -132,3 +128,5 @@ authRouter.post(
verifySessionUserMiddleware,
auth.changePassword,
);
authRouter.post("/reset-password/request", auth.requestPasswordReset);
authRouter.post("/reset-password/", auth.resetPassword);