mirror of
https://github.com/fosrl/pangolin.git
synced 2025-08-15 23:17:55 +02:00
added change password endpoint
This commit is contained in:
parent
86fb43d570
commit
e7080c4aa8
9 changed files with 207 additions and 71 deletions
113
server/routers/auth/changePassword.ts
Normal file
113
server/routers/auth/changePassword.ts
Normal file
|
@ -0,0 +1,113 @@
|
||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import lucia, { unauthorized } from "@server/auth";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "@server/db";
|
||||||
|
import { User, users } from "@server/db/schema";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { response } from "@server/utils";
|
||||||
|
import { hashPassword, verifyPassword } from "./password";
|
||||||
|
import { verifyTotpCode } from "./verifyTotpCode";
|
||||||
|
import { passwordSchema } from "./passwordSchema";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
|
||||||
|
export const changePasswordBody = z.object({
|
||||||
|
oldPassword: z.string(),
|
||||||
|
newPassword: passwordSchema,
|
||||||
|
code: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ChangePasswordBody = z.infer<typeof changePasswordBody>;
|
||||||
|
|
||||||
|
export type ChangePasswordResponse = {
|
||||||
|
codeRequested?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function changePassword(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction,
|
||||||
|
): Promise<any> {
|
||||||
|
const parsedBody = changePasswordBody.safeParse(req.body);
|
||||||
|
|
||||||
|
if (!parsedBody.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedBody.error).toString(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { newPassword, oldPassword, code } = parsedBody.data;
|
||||||
|
const user = req.user as User;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (newPassword === oldPassword) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"New password cannot be the same as the old password",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const validPassword = await verifyPassword(
|
||||||
|
oldPassword,
|
||||||
|
user.passwordHash,
|
||||||
|
);
|
||||||
|
if (!validPassword) {
|
||||||
|
return next(unauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.twoFactorEnabled) {
|
||||||
|
if (!code) {
|
||||||
|
return response<{ codeRequested: boolean }>(res, {
|
||||||
|
data: { codeRequested: true },
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Two-factor authentication required",
|
||||||
|
status: HttpCode.ACCEPTED,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const validOTP = await verifyTotpCode(code!, user.twoFactorSecret!);
|
||||||
|
|
||||||
|
if (!validOTP) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"The two-factor code you entered is incorrect",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hash = await hashPassword(newPassword);
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(users)
|
||||||
|
.set({
|
||||||
|
passwordHash: hash,
|
||||||
|
})
|
||||||
|
.where(eq(users.id, user.id));
|
||||||
|
|
||||||
|
await lucia.invalidateUserSessions(user.id);
|
||||||
|
|
||||||
|
return response(res, {
|
||||||
|
data: null,
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Password changed successfully",
|
||||||
|
status: HttpCode.OK,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"Failed to authenticate user",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -4,13 +4,12 @@ import HttpCode from "@server/types/HttpCode";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { unauthorized } from "@server/auth";
|
import { unauthorized } from "@server/auth";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { verify } from "@node-rs/argon2";
|
|
||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
import { User, users } from "@server/db/schema";
|
import { User, users } from "@server/db/schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { response } from "@server/utils";
|
import { response } from "@server/utils";
|
||||||
import { decodeHex } from "oslo/encoding";
|
import { verifyPassword } from "./password";
|
||||||
import { TOTPController } from "oslo/otp";
|
import { verifyTotpCode } from "./verifyTotpCode";
|
||||||
|
|
||||||
export const disable2faBody = z.object({
|
export const disable2faBody = z.object({
|
||||||
password: z.string(),
|
password: z.string(),
|
||||||
|
@ -43,14 +42,8 @@ export async function disable2fa(
|
||||||
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) {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 250)); // delay to prevent brute force attacks
|
|
||||||
return next(unauthorized());
|
return next(unauthorized());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -73,22 +66,9 @@ export async function disable2fa(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user.twoFactorSecret) {
|
const validOTP = await verifyTotpCode(code, user.twoFactorSecret!);
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.INTERNAL_SERVER_ERROR,
|
|
||||||
"Failed to authenticate user",
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const validOTP = await new TOTPController().verify(
|
|
||||||
code,
|
|
||||||
decodeHex(user.twoFactorSecret),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!validOTP) {
|
if (!validOTP) {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 250)); // delay to prevent brute force attacks
|
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.BAD_REQUEST,
|
HttpCode.BAD_REQUEST,
|
||||||
|
|
|
@ -11,3 +11,4 @@ export * from "./verifyResourceAccess";
|
||||||
export * from "./verifyTargetAccess";
|
export * from "./verifyTargetAccess";
|
||||||
export * from "./verifyEmail";
|
export * from "./verifyEmail";
|
||||||
export * from "./requestEmailVerificationCode";
|
export * from "./requestEmailVerificationCode";
|
||||||
|
export * from "./changePassword";
|
||||||
|
|
|
@ -14,6 +14,7 @@ import { decodeHex } from "oslo/encoding";
|
||||||
import { TOTPController } from "oslo/otp";
|
import { TOTPController } from "oslo/otp";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
|
import { verifyTotpCode } from "./verifyTotpCode";
|
||||||
|
|
||||||
export const loginBodySchema = z.object({
|
export const loginBodySchema = z.object({
|
||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
|
@ -104,22 +105,12 @@ export async function login(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!existingUser.twoFactorSecret) {
|
const validOTP = await verifyTotpCode(
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.INTERNAL_SERVER_ERROR,
|
|
||||||
"Failed to authenticate user",
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const validOTP = await new TOTPController().verify(
|
|
||||||
code,
|
code,
|
||||||
decodeHex(existingUser.twoFactorSecret),
|
existingUser.twoFactorSecret!,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!validOTP) {
|
if (!validOTP) {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 250)); // delay to prevent brute force attacks
|
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.BAD_REQUEST,
|
HttpCode.BAD_REQUEST,
|
||||||
|
|
29
server/routers/auth/password.ts
Normal file
29
server/routers/auth/password.ts
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
import { hash, verify } from "@node-rs/argon2";
|
||||||
|
|
||||||
|
export async function verifyPassword(
|
||||||
|
password: string,
|
||||||
|
hash: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const validPassword = await verify(hash, password, {
|
||||||
|
memoryCost: 19456,
|
||||||
|
timeCost: 2,
|
||||||
|
outputLen: 32,
|
||||||
|
parallelism: 1,
|
||||||
|
});
|
||||||
|
if (!validPassword) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 250)); // delay to prevent brute force attacks
|
||||||
|
}
|
||||||
|
|
||||||
|
return validPassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function hashPassword(password: string): Promise<string> {
|
||||||
|
const passwordHash = await hash(password, {
|
||||||
|
memoryCost: 19456,
|
||||||
|
timeCost: 2,
|
||||||
|
outputLen: 32,
|
||||||
|
parallelism: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
return passwordHash;
|
||||||
|
}
|
13
server/routers/auth/passwordSchema.ts
Normal file
13
server/routers/auth/passwordSchema.ts
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
import z from "zod";
|
||||||
|
|
||||||
|
export const passwordSchema = z
|
||||||
|
.string()
|
||||||
|
.min(8, { message: "Password must be at least 8 characters long" })
|
||||||
|
.max(64, { message: "Password must be at most 64 characters long" })
|
||||||
|
.regex(/^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])(?=.*?[#?!@$%^&*-]).*$/, {
|
||||||
|
message: `Your password must meet the following conditions:
|
||||||
|
- At least one uppercase English letter.
|
||||||
|
- At least one lowercase English letter.
|
||||||
|
- At least one digit.
|
||||||
|
- At least one special character.`,
|
||||||
|
});
|
|
@ -11,21 +11,11 @@ import createHttpError from "http-errors";
|
||||||
import response from "@server/utils/response";
|
import response from "@server/utils/response";
|
||||||
import { SqliteError } from "better-sqlite3";
|
import { SqliteError } from "better-sqlite3";
|
||||||
import { sendEmailVerificationCode } from "./sendEmailVerificationCode";
|
import { sendEmailVerificationCode } from "./sendEmailVerificationCode";
|
||||||
import logger from "@server/logger";
|
import { passwordSchema } from "./passwordSchema";
|
||||||
|
|
||||||
export const signupBodySchema = z.object({
|
export const signupBodySchema = z.object({
|
||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
password: z
|
password: passwordSchema,
|
||||||
.string()
|
|
||||||
.min(8, { message: "Password must be at least 8 characters long" })
|
|
||||||
.max(64, { message: "Password must be at most 64 characters long" })
|
|
||||||
.regex(/^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])(?=.*?[#?!@$%^&*-]).*$/, {
|
|
||||||
message: `Your password must meet the following conditions:
|
|
||||||
- At least one uppercase English letter.
|
|
||||||
- At least one lowercase English letter.
|
|
||||||
- At least one digit.
|
|
||||||
- At least one special character.`,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type SignUpBody = z.infer<typeof signupBodySchema>;
|
export type SignUpBody = z.infer<typeof signupBodySchema>;
|
||||||
|
@ -53,7 +43,6 @@ export async function signup(
|
||||||
const { email, password } = parsedBody.data;
|
const { email, password } = parsedBody.data;
|
||||||
|
|
||||||
const passwordHash = await hash(password, {
|
const passwordHash = await hash(password, {
|
||||||
// recommended minimum parameters
|
|
||||||
memoryCost: 19456,
|
memoryCost: 19456,
|
||||||
timeCost: 2,
|
timeCost: 2,
|
||||||
outputLen: 32,
|
outputLen: 32,
|
||||||
|
|
15
server/routers/auth/verifyTotpCode.ts
Normal file
15
server/routers/auth/verifyTotpCode.ts
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
import { decodeHex } from "oslo/encoding";
|
||||||
|
import { TOTPController } from "oslo/otp";
|
||||||
|
|
||||||
|
export async function verifyTotpCode(
|
||||||
|
code: string,
|
||||||
|
secret: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const validOTP = await new TOTPController().verify(code, decodeHex(secret));
|
||||||
|
|
||||||
|
if (!validOTP) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 250)); // delay to prevent brute force attack
|
||||||
|
}
|
||||||
|
|
||||||
|
return validOTP;
|
||||||
|
}
|
|
@ -29,16 +29,6 @@ unauthenticated.get("/", (_, res) => {
|
||||||
// Authenticated Root routes
|
// Authenticated Root routes
|
||||||
export const authenticated = Router();
|
export const authenticated = Router();
|
||||||
authenticated.use(verifySessionUserMiddleware);
|
authenticated.use(verifySessionUserMiddleware);
|
||||||
unauthenticated.use(
|
|
||||||
rateLimitMiddleware({
|
|
||||||
windowMin: 60,
|
|
||||||
max: 5,
|
|
||||||
type: "IP_AND_PATH",
|
|
||||||
skipCondition: (req) => {
|
|
||||||
return !["/auth/request-email-code"].includes(req.path);
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
authenticated.put("/org", getUserOrgs, org.createOrg);
|
authenticated.put("/org", getUserOrgs, org.createOrg);
|
||||||
authenticated.get("/orgs", getUserOrgs, org.listOrgs); // TODO we need to check the orgs here
|
authenticated.get("/orgs", getUserOrgs, org.listOrgs); // TODO we need to check the orgs here
|
||||||
|
@ -107,19 +97,34 @@ authenticated.get("/user/:userId", user.getUser);
|
||||||
authenticated.delete("/user/:userId", user.deleteUser);
|
authenticated.delete("/user/:userId", user.deleteUser);
|
||||||
|
|
||||||
// Auth routes
|
// Auth routes
|
||||||
unauthenticated.put("/auth/signup", auth.signup);
|
export const authRouter = Router();
|
||||||
unauthenticated.post("/auth/login", auth.login);
|
unauthenticated.use("/auth", authRouter);
|
||||||
unauthenticated.post("/auth/logout", auth.logout);
|
authRouter.use(
|
||||||
authenticated.post("/auth/verify-totp", auth.verifyTotp);
|
rateLimitMiddleware({
|
||||||
authenticated.post("/auth/request-totp-secret", auth.requestTotpSecret);
|
windowMin: 10,
|
||||||
authenticated.post("/auth/disable-2fa", auth.disable2fa);
|
max: 15,
|
||||||
unauthenticated.post(
|
type: "IP_AND_PATH",
|
||||||
"/auth/verify-email",
|
}),
|
||||||
verifySessionMiddleware,
|
|
||||||
auth.verifyEmail,
|
|
||||||
);
|
);
|
||||||
unauthenticated.post(
|
|
||||||
"/auth/request-email-code",
|
authRouter.put("/signup", auth.signup);
|
||||||
|
authRouter.post("/login", auth.login);
|
||||||
|
authRouter.post("/logout", auth.logout);
|
||||||
|
authRouter.post("/verify-totp", verifySessionUserMiddleware, auth.verifyTotp);
|
||||||
|
authRouter.post(
|
||||||
|
"/request-totp-secret",
|
||||||
|
verifySessionUserMiddleware,
|
||||||
|
auth.requestTotpSecret,
|
||||||
|
);
|
||||||
|
authRouter.post("/disable-2fa", verifySessionUserMiddleware, auth.disable2fa);
|
||||||
|
authRouter.post("/verify-email", verifySessionMiddleware, auth.verifyEmail);
|
||||||
|
authRouter.post(
|
||||||
|
"/request-email-code",
|
||||||
verifySessionMiddleware,
|
verifySessionMiddleware,
|
||||||
auth.requestEmailVerificationCode,
|
auth.requestEmailVerificationCode,
|
||||||
);
|
);
|
||||||
|
authRouter.post(
|
||||||
|
"/change-password",
|
||||||
|
verifySessionUserMiddleware,
|
||||||
|
auth.changePassword,
|
||||||
|
);
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue