reset password flow

This commit is contained in:
Milo Schwartz 2024-12-22 16:59:30 -05:00
parent 9c37036a39
commit f224bfa4ee
No known key found for this signature in database
22 changed files with 739 additions and 184 deletions

View file

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

View file

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

View file

@ -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" }),

View file

@ -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">
Youve 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 didnt request this, you can safely ignore
this email.
</Text>
</Container>
</Body>
</Tailwind>
</Html>
);
};
export default ResetPasswordCode;

View file

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

View file

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

View file

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

View file

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

View file

@ -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 ||
!isWithinExpirationDate(new Date(resetRequest[0].expiresAt))
) {
if (!resetRequest || !resetRequest.length) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invalid or expired password reset token"
"Invalid password reset token"
)
);
}
if (!isWithinExpirationDate(new Date(resetRequest[0].expiresAt))) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"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

View file

@ -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 {

View file

@ -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,

View file

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

View file

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

View file

@ -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(

View file

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

View file

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

View file

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

View file

@ -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!,

View file

@ -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(