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(), 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 // Define the model types for type inference
export type Org = InferSelectModel<typeof orgs>; export type Org = InferSelectModel<typeof orgs>;
export type User = InferSelectModel<typeof users>; export type User = InferSelectModel<typeof users>;
@ -133,3 +142,4 @@ export type EmailVerificationCode = InferSelectModel<
typeof emailVerificationCodes typeof emailVerificationCodes
>; >;
export type TwoFactorBackupCode = InferSelectModel<typeof twoFactorBackupCodes>; export type TwoFactorBackupCode = InferSelectModel<typeof twoFactorBackupCodes>;
export type PasswordResetToken = InferSelectModel<typeof passwordResetTokens>;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -10,10 +10,6 @@ export async function verifyPassword(
outputLen: 32, outputLen: 32,
parallelism: 1, parallelism: 1,
}); });
if (!validPassword) {
await new Promise((resolve) => setTimeout(resolve, 250)); // delay to prevent brute force attacks
}
return validPassword; 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, parallelism: 1,
}); });
if (!validPassword) { if (!validPassword) {
await new Promise((resolve) => setTimeout(resolve, 250)); // delay to prevent brute force attacks
return next(unauthorized()); 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 = { export type VerifyTotpResponse = {
valid: boolean; valid: boolean;
backupCodes: string[]; backupCodes?: string[];
}; };
export async function verifyTotp( export async function verifyTotp(
@ -63,7 +63,16 @@ export async function verifyTotp(
try { try {
const valid = await verifyTotpCode(code, user.twoFactorSecret, user.id); const valid = await verifyTotpCode(code, user.twoFactorSecret, user.id);
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(); const backupCodes = await generateBackupCodes();
codes = backupCodes;
for (const code of backupCodes) { for (const code of backupCodes) {
const hash = await hashPassword(code); const hash = await hashPassword(code);
@ -72,17 +81,15 @@ export async function verifyTotp(
codeHash: hash, codeHash: hash,
}); });
} }
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));
} }
// TODO: send email to user confirming two-factor authentication is enabled
return response<VerifyTotpResponse>(res, { return response<VerifyTotpResponse>(res, {
data: { valid, backupCodes }, data: {
valid,
...(valid && codes ? { backupCodes: codes } : {}),
},
success: true, success: true,
error: false, error: false,
message: valid message: valid

View file

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