add password expiry enforcement

This commit is contained in:
miloschwartz
2025-10-24 17:11:39 -07:00
parent 39d6b93d42
commit 1e70e4289b
17 changed files with 1028 additions and 71 deletions

View File

@@ -5,7 +5,6 @@ import { fromError } from "zod-validation-error";
import { z } from "zod";
import { db } from "@server/db";
import { User, users } from "@server/db";
import { eq } from "drizzle-orm";
import { response } from "@server/lib/response";
import {
hashPassword,
@@ -15,6 +14,8 @@ import { verifyTotpCode } from "@server/auth/totp";
import logger from "@server/logger";
import { unauthorized } from "@server/auth/unauthorizedResponse";
import { invalidateAllSessions } from "@server/auth/sessions/app";
import { sessions, resourceSessions } from "@server/db";
import { and, eq, ne, inArray } from "drizzle-orm";
import { passwordSchema } from "@server/auth/passwordSchema";
import { UserType } from "@server/types/UserTypes";
@@ -32,6 +33,46 @@ export type ChangePasswordResponse = {
codeRequested?: boolean;
};
async function invalidateAllSessionsExceptCurrent(
userId: string,
currentSessionId: string
): Promise<void> {
try {
await db.transaction(async (trx) => {
// Get all user sessions except the current one
const userSessions = await trx
.select()
.from(sessions)
.where(
and(
eq(sessions.userId, userId),
ne(sessions.sessionId, currentSessionId)
)
);
// Delete resource sessions for the sessions we're invalidating
if (userSessions.length > 0) {
await trx.delete(resourceSessions).where(
inArray(
resourceSessions.userSessionId,
userSessions.map((s) => s.sessionId)
)
);
}
// Delete the user sessions (except current)
await trx.delete(sessions).where(
and(
eq(sessions.userId, userId),
ne(sessions.sessionId, currentSessionId)
)
);
});
} catch (e) {
logger.error("Failed to invalidate user sessions except current", e);
}
}
export async function changePassword(
req: Request,
res: Response,
@@ -109,11 +150,13 @@ export async function changePassword(
await db
.update(users)
.set({
passwordHash: hash
passwordHash: hash,
lastPasswordChange: new Date().getTime()
})
.where(eq(users.userId, user.userId));
await invalidateAllSessions(user.userId);
// Invalidate all sessions except the current one
await invalidateAllSessionsExceptCurrent(user.userId, req.session.sessionId);
// TODO: send email to user confirming password change

View File

@@ -19,10 +19,7 @@ import { passwordSchema } from "@server/auth/passwordSchema";
export const resetPasswordBody = z
.object({
email: z
.string()
.toLowerCase()
.email(),
email: z.string().toLowerCase().email(),
token: z.string(), // reset secret code
newPassword: passwordSchema,
code: z.string().optional() // 2fa code
@@ -152,7 +149,7 @@ export async function resetPassword(
await db.transaction(async (trx) => {
await trx
.update(users)
.set({ passwordHash })
.set({ passwordHash, lastPasswordChange: new Date().getTime() })
.where(eq(users.userId, resetRequest[0].userId));
await trx

View File

@@ -98,7 +98,8 @@ export async function setServerAdmin(
passwordHash,
dateCreated: moment().toISOString(),
serverAdmin: true,
emailVerified: true
emailVerified: true,
lastPasswordChange: new Date().getTime()
});
});

View File

@@ -23,10 +23,7 @@ import { passwordSchema } from "@server/auth/passwordSchema";
import { UserType } from "@server/types/UserTypes";
import { createUserAccountOrg } from "@server/lib/createUserAccountOrg";
import { build } from "@server/build";
import resend, {
AudienceIds,
moveEmailToAudience
} from "#dynamic/lib/resend";
import resend, { AudienceIds, moveEmailToAudience } from "#dynamic/lib/resend";
export const signupBodySchema = z.object({
email: z.string().toLowerCase().email(),
@@ -183,7 +180,8 @@ export async function signup(
passwordHash,
dateCreated: moment().toISOString(),
termsAcceptedTimestamp: termsAcceptedTimestamp || null,
termsVersion: "1"
termsVersion: "1",
lastPasswordChange: new Date().getTime()
});
// give the user their default permissions:

View File

@@ -973,11 +973,11 @@ authRouter.post(
auth.requestEmailVerificationCode
);
// authRouter.post(
// "/change-password",
// verifySessionUserMiddleware,
// auth.changePassword
// );
authRouter.post(
"/change-password",
verifySessionUserMiddleware,
auth.changePassword
);
authRouter.post(
"/reset-password/request",

View File

@@ -25,7 +25,8 @@ const updateOrgBodySchema = z
.object({
name: z.string().min(1).max(255).optional(),
requireTwoFactor: z.boolean().optional(),
maxSessionLengthHours: z.number().nullable().optional()
maxSessionLengthHours: z.number().nullable().optional(),
passwordExpiryDays: z.number().nullable().optional()
})
.strict()
.refine((data) => Object.keys(data).length > 0, {
@@ -82,6 +83,7 @@ export async function updateOrg(
if (!isLicensed) {
parsedBody.data.requireTwoFactor = undefined;
parsedBody.data.maxSessionLengthHours = undefined;
parsedBody.data.passwordExpiryDays = undefined;
}
if (
@@ -103,7 +105,8 @@ export async function updateOrg(
.set({
name: parsedBody.data.name,
requireTwoFactor: parsedBody.data.requireTwoFactor,
maxSessionLengthHours: parsedBody.data.maxSessionLengthHours
maxSessionLengthHours: parsedBody.data.maxSessionLengthHours,
passwordExpiryDays: parsedBody.data.passwordExpiryDays
})
.where(eq(orgs.orgId, orgId))
.returning();