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

View file

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

View file

@ -150,6 +150,7 @@ export const emailVerificationCodes = sqliteTable("emailVerificationCodes", {
export const passwordResetTokens = sqliteTable("passwordResetTokens", { export const passwordResetTokens = sqliteTable("passwordResetTokens", {
tokenId: integer("id").primaryKey({ autoIncrement: true }), tokenId: integer("id").primaryKey({ autoIncrement: true }),
email: text("email").notNull(),
userId: text("userId") userId: text("userId")
.notNull() .notNull()
.references(() => users.userId, { onDelete: "cascade" }), .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 { fromError } from "zod-validation-error";
import logger from "@server/logger"; import logger from "@server/logger";
import { createDate, TimeSpan } from "oslo"; import { createDate, TimeSpan } from "oslo";
import { hashPassword } from "@server/auth/password";
export const generateAccessTokenBodySchema = z export const generateAccessTokenBodySchema = z
.object({ .object({
@ -91,12 +92,7 @@ export async function generateAccessToken(
const token = generateIdFromEntropySize(25); const token = generateIdFromEntropySize(25);
const tokenHash = await hash(token, { const tokenHash = await hashPassword(token);
memoryCost: 19456,
timeCost: 2,
outputLen: 32,
parallelism: 1
});
const id = generateId(15); const id = generateId(15);
const [result] = await db const [result] = await db

View file

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

View file

@ -7,16 +7,22 @@ import { response } from "@server/utils";
import { db } from "@server/db"; import { db } from "@server/db";
import { passwordResetTokens, users } from "@server/db/schema"; import { passwordResetTokens, users } from "@server/db/schema";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { sha256 } from "oslo/crypto"; import { alphabet, generateRandomString, sha256 } from "oslo/crypto";
import { encodeHex } from "oslo/encoding"; import { encodeHex } from "oslo/encoding";
import { createDate } from "oslo"; import { createDate } from "oslo";
import logger from "@server/logger"; import logger from "@server/logger";
import { generateIdFromEntropySize } from "@server/auth"; import { generateIdFromEntropySize } from "@server/auth";
import { TimeSpan } from "oslo"; 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({ export const requestPasswordResetBody = z
email: z.string().email(), .object({
}).strict(); email: z.string().email()
})
.strict();
export type RequestPasswordResetBody = z.infer<typeof requestPasswordResetBody>; export type RequestPasswordResetBody = z.infer<typeof requestPasswordResetBody>;
@ -27,7 +33,7 @@ export type RequestPasswordResetResponse = {
export async function requestPasswordReset( export async function requestPasswordReset(
req: Request, req: Request,
res: Response, res: Response,
next: NextFunction, next: NextFunction
): Promise<any> { ): Promise<any> {
const parsedBody = requestPasswordResetBody.safeParse(req.body); const parsedBody = requestPasswordResetBody.safeParse(req.body);
@ -35,8 +41,8 @@ export async function requestPasswordReset(
return next( return next(
createHttpError( createHttpError(
HttpCode.BAD_REQUEST, HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString(), fromError(parsedBody.error).toString()
), )
); );
} }
@ -52,8 +58,8 @@ export async function requestPasswordReset(
return next( return next(
createHttpError( createHttpError(
HttpCode.BAD_REQUEST, 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) .delete(passwordResetTokens)
.where(eq(passwordResetTokens.userId, existingUser[0].userId)); .where(eq(passwordResetTokens.userId, existingUser[0].userId));
const token = generateIdFromEntropySize(25); const token = generateRandomString(8, alphabet("0-9", "A-Z", "a-z"));
const tokenHash = encodeHex( const tokenHash = await hashPassword(token);
await sha256(new TextEncoder().encode(token)),
);
await db.insert(passwordResetTokens).values({ await db.insert(passwordResetTokens).values({
userId: existingUser[0].userId, userId: existingUser[0].userId,
email: existingUser[0].email,
tokenHash, 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 const url = `${config.app.base_url}/auth/reset-password?email=${email}&token=${token}`;
// something like: https://example.com/auth/reset-password?email=${email}&?token=${token}
// for now, just log the token await sendEmail(
ResetPasswordCode({
email,
code: token,
link: url
}),
{
from: config.email?.no_reply,
to: email,
subject: "Reset your password"
}
);
return response<RequestPasswordResetResponse>(res, { return response<RequestPasswordResetResponse>(res, {
data: { data: {
sentEmail: true, sentEmail: true
}, },
success: true, success: true,
error: false, error: false,
message: "Password reset email sent", message: "Password reset requested",
status: HttpCode.OK, status: HttpCode.OK
}); });
} catch (e) { } catch (e) {
logger.error(e); logger.error(e);
return next( return next(
createHttpError( createHttpError(
HttpCode.INTERNAL_SERVER_ERROR, 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 { createTOTPKeyURI } from "oslo/otp";
import config from "@server/config"; import config from "@server/config";
import logger from "@server/logger"; import logger from "@server/logger";
import { verifyPassword } from "@server/auth/password";
export const requestTotpSecretBody = z export const requestTotpSecretBody = z
.object({ .object({
@ -47,12 +48,7 @@ export async function requestTotpSecret(
const user = req.user as User; const user = req.user as User;
try { try {
const validPassword = await verify(user.passwordHash, password, { const validPassword = await verifyPassword(password, user.passwordHash);
memoryCost: 19456,
timeCost: 2,
outputLen: 32,
parallelism: 1
});
if (!validPassword) { if (!validPassword) {
return next(unauthorized()); return next(unauthorized());
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -35,6 +35,7 @@ import {
InputOTPSeparator, InputOTPSeparator,
InputOTPSlot InputOTPSlot
} from "./ui/input-otp"; } from "./ui/input-otp";
import Link from "next/link";
type LoginFormProps = { type LoginFormProps = {
redirect?: string; redirect?: string;
@ -79,7 +80,7 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
async function onSubmit(values: any) { async function onSubmit(values: any) {
const { email, password } = form.getValues(); const { email, password } = form.getValues();
const { code } = mfaForm.getValues() const { code } = mfaForm.getValues();
setLoading(true); setLoading(true);
@ -151,6 +152,8 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
</FormItem> </FormItem>
)} )}
/> />
<div className="space-y-4">
<FormField <FormField
control={form.control} control={form.control}
name="password" name="password"
@ -168,6 +171,17 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
</FormItem> </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 && ( {error && (
<Alert variant="destructive"> <Alert variant="destructive">
<AlertDescription>{error}</AlertDescription> <AlertDescription>{error}</AlertDescription>