Fix: Password Change/Reset Credential Preservation (#383)
* Improve password reset to preserve encrypted data during active sessions Enhances password reset logic to preserve encrypted user data when the user is logged in and has an active session. Introduces a fallback mechanism to create a new data encryption key (DEK) if preservation fails, ensuring user data integrity. Adds a dedicated method for preserving the DEK during password reset and updates related session management. Includes improved logging for better tracking of password reset operations and potential data loss scenarios. Fixes inefficiencies in password change and reset workflows by persisting encryption key changes promptly and cleaning up sensitive data from memory. * fix(auth): preserve user credentials during password change/reset - Maintain session during password change to prevent credential loss - Add intelligent password reset that preserves data when logged in - Improve Buffer handling and session cleanup - Remove dead code that could fail for OIDC users The DEK is now properly maintained in session memory when password changes, preventing apparent data loss. Password reset intelligently detects active sessions and preserves credentials when possible. * Removes redundant comments to improve code readability
This commit was merged in pull request #383.
This commit is contained in:
@@ -18,7 +18,6 @@ import QRCode from "qrcode";
|
||||
import type { Request, Response } from "express";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { UserCrypto } from "../../utils/user-crypto.js";
|
||||
import { DataCrypto } from "../../utils/data-crypto.js";
|
||||
import { LazyFieldEncryption } from "../../utils/lazy-field-encryption.js";
|
||||
|
||||
@@ -1318,8 +1317,48 @@ router.post("/complete-reset", async (req, res) => {
|
||||
.where(eq(users.username, username));
|
||||
|
||||
try {
|
||||
await authManager.registerUser(userId, newPassword);
|
||||
authManager.logoutUser(userId);
|
||||
const hasActiveSession = authManager.isUserUnlocked(userId);
|
||||
|
||||
if (hasActiveSession) {
|
||||
const success = await authManager.resetUserPasswordWithPreservedDEK(
|
||||
userId,
|
||||
newPassword,
|
||||
);
|
||||
|
||||
if (!success) {
|
||||
authLogger.warn(
|
||||
`Failed to preserve DEK during password reset for ${username}. Creating new DEK - data will be lost.`,
|
||||
{
|
||||
operation: "password_reset_preserve_failed",
|
||||
userId,
|
||||
username,
|
||||
},
|
||||
);
|
||||
await authManager.registerUser(userId, newPassword);
|
||||
authManager.logoutUser(userId);
|
||||
} else {
|
||||
authLogger.success(
|
||||
`Password reset completed for user: ${username}. Data preserved using existing session.`,
|
||||
{
|
||||
operation: "password_reset_data_preserved",
|
||||
userId,
|
||||
username,
|
||||
},
|
||||
);
|
||||
}
|
||||
} else {
|
||||
await authManager.registerUser(userId, newPassword);
|
||||
authManager.logoutUser(userId);
|
||||
|
||||
authLogger.warn(
|
||||
`Password reset completed for user: ${username}. Existing encrypted data is now inaccessible and will need to be re-entered.`,
|
||||
{
|
||||
operation: "password_reset_data_inaccessible",
|
||||
userId,
|
||||
username,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
await db
|
||||
.update(users)
|
||||
@@ -1329,15 +1368,6 @@ router.post("/complete-reset", async (req, res) => {
|
||||
totp_backup_codes: null,
|
||||
})
|
||||
.where(eq(users.id, userId));
|
||||
|
||||
authLogger.warn(
|
||||
`Password reset completed for user: ${username}. Existing encrypted data is now inaccessible and will need to be re-entered.`,
|
||||
{
|
||||
operation: "password_reset_data_inaccessible",
|
||||
userId,
|
||||
username,
|
||||
},
|
||||
);
|
||||
} catch (encryptionError) {
|
||||
authLogger.error(
|
||||
"Failed to re-encrypt user data after password reset",
|
||||
@@ -2014,6 +2044,9 @@ router.post("/change-password", authenticateJWT, async (req, res) => {
|
||||
.set({ password_hash: newPasswordHash })
|
||||
.where(eq(users.id, userId));
|
||||
|
||||
const { saveMemoryDatabaseToFile } = await import("../db/index.js");
|
||||
await saveMemoryDatabaseToFile();
|
||||
|
||||
authLogger.success("User password changed successfully", {
|
||||
operation: "password_change_success",
|
||||
userId,
|
||||
|
||||
@@ -295,6 +295,16 @@ class AuthManager {
|
||||
newPassword,
|
||||
);
|
||||
}
|
||||
|
||||
async resetUserPasswordWithPreservedDEK(
|
||||
userId: string,
|
||||
newPassword: string,
|
||||
): Promise<boolean> {
|
||||
return await this.userCrypto.resetUserPasswordWithPreservedDEK(
|
||||
userId,
|
||||
newPassword,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export { AuthManager, type AuthenticationResult, type JWTPayload };
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import crypto from "crypto";
|
||||
import { getDb } from "../database/db/index.js";
|
||||
import { settings, users } from "../database/db/schema.js";
|
||||
import { settings } from "../database/db/schema.js";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { databaseLogger } from "./logger.js";
|
||||
|
||||
@@ -263,19 +263,82 @@ class UserCrypto {
|
||||
|
||||
const newKekSalt = await this.generateKEKSalt();
|
||||
const newKEK = this.deriveKEK(newPassword, newKekSalt);
|
||||
|
||||
const newEncryptedDEK = this.encryptDEK(DEK, newKEK);
|
||||
|
||||
await this.storeKEKSalt(userId, newKekSalt);
|
||||
await this.storeEncryptedDEK(userId, newEncryptedDEK);
|
||||
|
||||
const { saveMemoryDatabaseToFile } = await import("../database/db/index.js");
|
||||
await saveMemoryDatabaseToFile();
|
||||
|
||||
oldKEK.fill(0);
|
||||
newKEK.fill(0);
|
||||
DEK.fill(0);
|
||||
|
||||
this.logoutUser(userId);
|
||||
const dekCopy = Buffer.from(DEK);
|
||||
|
||||
const now = Date.now();
|
||||
const oldSession = this.userSessions.get(userId);
|
||||
if (oldSession) {
|
||||
oldSession.dataKey.fill(0);
|
||||
}
|
||||
|
||||
this.userSessions.set(userId, {
|
||||
dataKey: dekCopy,
|
||||
lastActivity: now,
|
||||
expiresAt: now + UserCrypto.SESSION_DURATION,
|
||||
});
|
||||
|
||||
DEK.fill(0);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
databaseLogger.error("Password change failed", error, {
|
||||
operation: "password_change_error",
|
||||
userId,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async resetUserPasswordWithPreservedDEK(
|
||||
userId: string,
|
||||
newPassword: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const existingDEK = this.getUserDataKey(userId);
|
||||
if (!existingDEK) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const newKekSalt = await this.generateKEKSalt();
|
||||
const newKEK = this.deriveKEK(newPassword, newKekSalt);
|
||||
|
||||
const newEncryptedDEK = this.encryptDEK(existingDEK, newKEK);
|
||||
|
||||
await this.storeKEKSalt(userId, newKekSalt);
|
||||
await this.storeEncryptedDEK(userId, newEncryptedDEK);
|
||||
|
||||
const { saveMemoryDatabaseToFile } = await import(
|
||||
"../database/db/index.js"
|
||||
);
|
||||
await saveMemoryDatabaseToFile();
|
||||
|
||||
newKEK.fill(0);
|
||||
|
||||
const session = this.userSessions.get(userId);
|
||||
if (session) {
|
||||
session.lastActivity = Date.now();
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
databaseLogger.error("Password reset with preserved DEK failed", error, {
|
||||
operation: "password_reset_preserve_error",
|
||||
userId,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user