reset password flow
This commit is contained in:
parent
9c37036a39
commit
f224bfa4ee
22 changed files with 739 additions and 184 deletions
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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
server/emails/templates/ResetPasswordCode.tsx
Normal file
70
server/emails/templates/ResetPasswordCode.tsx
Normal 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">
|
||||
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;
|
|
@ -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
|
||||
|
|
|
@ -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({
|
||||
export const loginBodySchema = z
|
||||
.object({
|
||||
email: z.string().email(),
|
||||
password: z.string(),
|
||||
code: z.string().optional(),
|
||||
}).strict();
|
||||
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);
|
||||
|
|
|
@ -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"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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!,
|
||||
|
|
|
@ -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
src/app/auth/reset-password/ResetPasswordForm.tsx
Normal file
470
src/app/auth/reset-password/ResetPasswordForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
32
src/app/auth/reset-password/page.tsx
Normal file
32
src/app/auth/reset-password/page.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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,6 +152,8 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
|
|||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
|
@ -168,6 +171,17 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
|
|||
</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>
|
||||
|
|
Loading…
Add table
Reference in a new issue