feat(auth): non-destructive password resets and admin reset endpoint

Password resets no longer destroy user data: the DEK is system-wrapped, so
forgot-password and admin resets are just a hash update plus session revoke.
The wipe branch survives only for accounts that never logged in since the
encryption upgrade and now requires explicit confirmDataWipe (surfaced as a
409 DATA_WIPE_REQUIRED; the reset UI asks for confirmation). Adds
POST /users/admin/reset-password and removes the dead re-encryption paths.
This commit is contained in:
LukeGus
2026-07-16 00:36:14 -05:00
parent 1032a31e25
commit a2f6c90eae
8 changed files with 361 additions and 288 deletions
@@ -483,4 +483,127 @@ export function registerUserAdminRoutes(
res.status(500).json({ error: "Failed to create user" }); res.status(500).json({ error: "Failed to create user" });
} }
}); });
/**
* @openapi
* /users/admin/reset-password:
* post:
* summary: Reset a user's password (admin only)
* description: >
* Resets another user's password. Data is preserved for users whose
* encryption key has been migrated to the system wrap. Users who never
* logged in since the encryption upgrade require confirmDataWipe,
* which deletes their encrypted data.
* tags:
* - Users
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - newPassword
* properties:
* userId:
* type: string
* username:
* type: string
* newPassword:
* type: string
* confirmDataWipe:
* type: boolean
* responses:
* 200:
* description: Password reset; dataWiped indicates whether encrypted data was deleted.
* 400:
* description: Missing or invalid parameters.
* 403:
* description: Admin access required.
* 404:
* description: User not found.
* 409:
* description: Reset would wipe the user's data and confirmDataWipe was not set.
* 500:
* description: Failed to reset password.
*/
router.post("/admin/reset-password", authenticateJWT, async (req, res) => {
const adminId = (req as AuthenticatedRequest).userId;
const { userId: targetUserId, username, newPassword } = req.body;
const resolvedUserId = isNonEmptyString(targetUserId)
? targetUserId.trim()
: null;
const resolvedUsername = isNonEmptyString(username)
? username.trim()
: null;
if (!resolvedUserId && !resolvedUsername) {
return res.status(400).json({ error: "User ID or username is required" });
}
if (!isNonEmptyString(newPassword)) {
return res.status(400).json({ error: "New password is required" });
}
try {
const userRepository = createCurrentUserRepository();
const adminUser = await userRepository.findById(adminId);
if (!adminUser?.isAdmin) {
return res.status(403).json({ error: "Admin access required" });
}
const targetUser = await getUserByPreferredIdentifier(
userRepository,
resolvedUserId,
resolvedUsername,
);
if (!targetUser) {
return res.status(404).json({ error: "User not found" });
}
if (targetUser.isOidc && !targetUser.passwordHash) {
return res.status(400).json({
error: "This user authenticates through an external provider",
});
}
const { resetUserPassword } =
await import("./user-password-reset-routes.js");
const outcome = await resetUserPassword(AuthManager.getInstance(), {
userId: targetUser.id,
username: targetUser.username,
newPassword,
confirmDataWipe: req.body?.confirmDataWipe === true,
});
if (outcome.status === "wipe_confirmation_required") {
return res.status(409).json({
error:
"This user has not logged in since the encryption upgrade, so their data cannot be recovered. Set confirmDataWipe to reset anyway and delete their hosts, credentials and snippets.",
code: "DATA_WIPE_REQUIRED",
});
}
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId: adminId,
username: adminUser.username ?? adminId,
action: "admin_reset_password",
resourceType: "user",
resourceId: targetUser.id,
resourceName: targetUser.username,
ipAddress,
userAgent,
success: true,
});
await DatabaseSaveTrigger.forceSave("admin_password_reset");
res.json({
message: "Password reset",
dataWiped: outcome.dataWiped,
});
} catch (err) {
authLogger.error("Failed to reset user password", err);
res.status(500).json({ error: "Failed to reset password" });
}
});
} }
@@ -0,0 +1,123 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { AuthManager } from "../../utils/auth-manager.js";
const calls = vi.hoisted(() => ({
userUpdates: [] as Array<[string, Record<string, unknown>]>,
deletedFor: [] as string[],
rotatedFor: [] as string[],
legacyWrapsDeletedFor: [] as string[],
}));
function deletingRepo(label: string) {
return () => ({
deleteByUserId: async (userId: string) => {
calls.deletedFor.push(`${label}:${userId}`);
return 0;
},
});
}
vi.mock("../repositories/factory.js", () => ({
createCurrentUserRepository: () => ({
update: async (userId: string, update: Record<string, unknown>) => {
calls.userUpdates.push([userId, update]);
return { id: userId };
},
}),
createCurrentSettingsRepository: () => ({}),
createCurrentSshCredentialUsageRepository: deletingRepo("usage"),
createCurrentFileManagerBookmarkRepository: deletingRepo("bookmarks"),
createCurrentRecentActivityRepository: deletingRepo("activity"),
createCurrentDismissedAlertRepository: deletingRepo("alerts"),
createCurrentSnippetRepository: deletingRepo("snippets"),
createCurrentHostRepository: deletingRepo("hosts"),
createCurrentCredentialRepository: deletingRepo("credentials"),
}));
vi.mock("../../utils/user-keys.js", () => ({
UserKeyManager: {
getInstance: () => ({
rotateUserDEK: async (userId: string) => {
calls.rotatedFor.push(userId);
return Buffer.alloc(32);
},
}),
},
}));
vi.mock("../../utils/crypto-migration/dek-migration.js", () => ({
deleteLegacyWraps: async (userId: string) => {
calls.legacyWrapsDeletedFor.push(userId);
},
}));
import { resetUserPassword } from "./user-password-reset-routes.js";
function fakeAuthManager(unlocked: boolean): AuthManager {
return {
isUserUnlocked: () => unlocked,
logoutUser: vi.fn(async () => {}),
} as unknown as AuthManager;
}
beforeEach(() => {
calls.userUpdates = [];
calls.deletedFor = [];
calls.rotatedFor = [];
calls.legacyWrapsDeletedFor = [];
});
describe("resetUserPassword", () => {
it("preserves data for users with a server-wrapped key", async () => {
const outcome = await resetUserPassword(fakeAuthManager(true), {
userId: "user-1",
username: "alice",
newPassword: "new-password",
confirmDataWipe: false,
});
expect(outcome).toEqual({ status: "reset", dataWiped: false });
expect(calls.userUpdates).toHaveLength(1);
expect(calls.userUpdates[0][1]).toHaveProperty("passwordHash");
expect(calls.deletedFor).toEqual([]);
expect(calls.rotatedFor).toEqual([]);
});
it("requires explicit consent before wiping an unmigrated user", async () => {
const outcome = await resetUserPassword(fakeAuthManager(false), {
userId: "user-1",
username: "alice",
newPassword: "new-password",
confirmDataWipe: false,
});
expect(outcome).toEqual({ status: "wipe_confirmation_required" });
expect(calls.userUpdates).toEqual([]);
expect(calls.deletedFor).toEqual([]);
});
it("wipes data and rotates the key when consent is given", async () => {
const outcome = await resetUserPassword(fakeAuthManager(false), {
userId: "user-1",
username: "alice",
newPassword: "new-password",
confirmDataWipe: true,
});
expect(outcome).toEqual({ status: "reset", dataWiped: true });
expect(calls.deletedFor).toEqual([
"usage:user-1",
"bookmarks:user-1",
"activity:user-1",
"alerts:user-1",
"snippets:user-1",
"hosts:user-1",
"credentials:user-1",
]);
expect(calls.rotatedFor).toEqual(["user-1"]);
expect(calls.legacyWrapsDeletedFor).toEqual(["user-1"]);
expect(
calls.userUpdates.some(([, update]) => update.totpEnabled === false),
).toBe(true);
});
});
@@ -25,6 +25,82 @@ function isNonEmptyString(val: unknown): val is string {
return typeof val === "string" && val.trim().length > 0; return typeof val === "string" && val.trim().length > 0;
} }
export type PasswordResetOutcome =
| { status: "reset"; dataWiped: false }
| { status: "reset"; dataWiped: true }
| { status: "wipe_confirmation_required" };
// Resets a user's password. The DEK is wrapped by the system key, so for any
// user migrated to the v3 wrap this is just a hash update and their data
// survives. Users who never logged in after the encryption upgrade still have
// a password-wrapped DEK that a reset cannot recover; their encrypted data
// must be wiped, which callers have to confirm explicitly.
export async function resetUserPassword(
authManager: AuthManager,
options: {
userId: string;
username: string;
newPassword: string;
confirmDataWipe: boolean;
},
): Promise<PasswordResetOutcome> {
const { userId, username, newPassword, confirmDataWipe } = options;
const passwordHash = await bcrypt.hash(newPassword, 10);
const userRepository = createCurrentUserRepository();
if (authManager.isUserUnlocked(userId)) {
await userRepository.update(userId, { passwordHash });
await authManager.logoutUser(userId);
authLogger.success(
`Password reset (data preserved) for user: ${username}`,
{
operation: "password_reset_preserved",
userId,
username,
},
);
return { status: "reset", dataWiped: false };
}
if (!confirmDataWipe) {
return { status: "wipe_confirmation_required" };
}
await userRepository.update(userId, { passwordHash });
await createCurrentSshCredentialUsageRepository().deleteByUserId(userId);
await createCurrentFileManagerBookmarkRepository().deleteByUserId(userId);
await createCurrentRecentActivityRepository().deleteByUserId(userId);
await createCurrentDismissedAlertRepository().deleteByUserId(userId);
await createCurrentSnippetRepository().deleteByUserId(userId);
await createCurrentHostRepository().deleteByUserId(userId);
await createCurrentCredentialRepository().deleteByUserId(userId);
const { UserKeyManager } = await import("../../utils/user-keys.js");
await UserKeyManager.getInstance().rotateUserDEK(userId);
const { deleteLegacyWraps } =
await import("../../utils/crypto-migration/dek-migration.js");
await deleteLegacyWraps(userId);
await authManager.logoutUser(userId);
await userRepository.update(userId, {
totpEnabled: false,
totpSecret: null,
totpBackupCodes: null,
});
authLogger.warn(
`Password reset completed for user: ${username}. All encrypted data has been deleted because the old key was unrecoverable.`,
{
operation: "password_reset_data_deleted",
userId,
username,
},
);
return { status: "reset", dataWiped: true };
}
export function registerUserPasswordResetRoutes( export function registerUserPasswordResetRoutes(
router: Router, router: Router,
{ authManager }: UserPasswordResetRoutesDeps, { authManager }: UserPasswordResetRoutesDeps,
@@ -332,111 +408,19 @@ export function registerUserPasswordResetRoutes(
} }
const userId = user.id; const userId = user.id;
const password_hash = await bcrypt.hash(newPassword, 10); const outcome = await resetUserPassword(authManager, {
userId,
username,
newPassword,
confirmDataWipe: req.body?.confirmDataWipe === true,
});
let userIdFromJwt: string | null = null; if (outcome.status === "wipe_confirmation_required") {
const cookie = req.cookies?.jwt; return res.status(409).json({
let header: string | undefined; error:
if (req.headers?.authorization?.startsWith("Bearer ")) { "This account has not logged in since the encryption upgrade, so its stored data cannot be recovered without the old password. Resetting will permanently delete its hosts, credentials and snippets.",
header = req.headers?.authorization?.split(" ")[1]; code: "DATA_WIPE_REQUIRED",
}
const token = cookie || header;
if (token) {
const payload = await authManager.verifyJWTToken(token);
if (payload) {
userIdFromJwt = payload.userId;
}
}
if (userIdFromJwt === userId) {
try {
const success = await authManager.resetUserPasswordWithPreservedDEK(
userId,
newPassword,
);
if (!success) {
throw new Error(
"Failed to re-encrypt user data with new password.",
);
}
await createCurrentUserRepository().update(userId, {
passwordHash: password_hash,
});
authManager.logoutUser(userId);
authLogger.success(
`Password reset (data preserved) for user: ${username}`,
{
operation: "password_reset_preserved",
userId,
username,
},
);
} catch (encryptionError) {
authLogger.error(
"Failed to setup user data encryption after password reset",
encryptionError,
{
operation: "password_reset_encryption_failed_preserved",
userId,
username,
},
);
return res.status(500).json({
error: "Password reset failed. Please contact administrator.",
});
}
} else {
await createCurrentUserRepository().update(userId, {
passwordHash: password_hash,
}); });
try {
await createCurrentSshCredentialUsageRepository().deleteByUserId(
userId,
);
await createCurrentFileManagerBookmarkRepository().deleteByUserId(
userId,
);
await createCurrentRecentActivityRepository().deleteByUserId(userId);
await createCurrentDismissedAlertRepository().deleteByUserId(userId);
await createCurrentSnippetRepository().deleteByUserId(userId);
await createCurrentHostRepository().deleteByUserId(userId);
await createCurrentCredentialRepository().deleteByUserId(userId);
await authManager.registerUser(userId, newPassword);
authManager.logoutUser(userId);
await createCurrentUserRepository().update(userId, {
totpEnabled: false,
totpSecret: null,
totpBackupCodes: null,
});
authLogger.warn(
`Password reset completed for user: ${username}. All encrypted data has been deleted due to lost encryption key.`,
{
operation: "password_reset_data_deleted",
userId,
username,
},
);
} catch (encryptionError) {
authLogger.error(
"Failed to setup user data encryption after password reset",
encryptionError,
{
operation: "password_reset_encryption_failed",
userId,
username,
},
);
return res.status(500).json({
error: "Password reset failed. Please contact administrator.",
});
}
} }
authLogger.success(`Password successfully reset for user: ${username}`); authLogger.success(`Password successfully reset for user: ${username}`);
@@ -445,7 +429,10 @@ export function registerUserPasswordResetRoutes(
await settingsRepository.delete(`reset_code_${username}`); await settingsRepository.delete(`reset_code_${username}`);
await settingsRepository.delete(`temp_reset_token_${username}`); await settingsRepository.delete(`temp_reset_token_${username}`);
res.json({ message: "Password has been successfully reset" }); res.json({
message: "Password has been successfully reset",
dataWiped: outcome.dataWiped,
});
} catch (err) { } catch (err) {
authLogger.error("Failed to complete password reset", err); authLogger.error("Failed to complete password reset", err);
res.status(500).json({ error: "Failed to complete password reset" }); res.status(500).json({ error: "Failed to complete password reset" });
-7
View File
@@ -892,13 +892,6 @@ class AuthManager {
return this.userKeys.tryGetUserDEK(userId) !== null; return this.userKeys.tryGetUserDEK(userId) !== null;
} }
async resetUserPasswordWithPreservedDEK(
userId: string,
_newPassword?: string,
): Promise<boolean> {
return this.userKeys.tryGetUserDEK(userId) !== null;
}
async isTrustedDevice( async isTrustedDevice(
userId: string, userId: string,
deviceFingerprint: string, deviceFingerprint: string,
-175
View File
@@ -8,7 +8,6 @@ import {
RawSqliteUserEncryptionMigrationStore, RawSqliteUserEncryptionMigrationStore,
type LegacyDatabaseInstance, type LegacyDatabaseInstance,
type UserEncryptionMigrationStore, type UserEncryptionMigrationStore,
type UserEncryptionMigrationRecord,
} from "./user-encryption-migration-store.js"; } from "./user-encryption-migration-store.js";
class DataCrypto { class DataCrypto {
@@ -238,180 +237,6 @@ class DataCrypto {
return this.userKeys.tryGetUserDEK(userId); return this.userKeys.tryGetUserDEK(userId);
} }
static async reencryptUserDataAfterPasswordReset(
userId: string,
newUserDataKey: Buffer,
db: LegacyDatabaseInstance,
): Promise<{
success: boolean;
reencryptedTables: string[];
reencryptedFieldsCount: number;
errors: string[];
}> {
const result = {
success: false,
reencryptedTables: [] as string[],
reencryptedFieldsCount: 0,
errors: [] as string[],
};
try {
const store = new RawSqliteUserEncryptionMigrationStore(db);
type PasswordResetTable = Parameters<
UserEncryptionMigrationStore["updatePasswordResetFields"]
>[0];
const tablesToReencrypt: Array<{
table: PasswordResetTable;
fields: string[];
}> = [
{
table: "ssh_data",
fields: [
"password",
"key",
"key_password",
"sudo_password",
"autostart_password",
"autostart_key",
"autostart_key_password",
],
},
{
table: "ssh_credentials",
fields: [
"password",
"key",
"private_key",
"public_key",
"key_password",
],
},
{
table: "users",
fields: [
"client_secret",
"totp_secret",
"totp_backup_codes",
"oidc_identifier",
],
},
];
for (const { table, fields } of tablesToReencrypt) {
try {
const records =
table === "ssh_data"
? store.listHostRecords(userId)
: table === "ssh_credentials"
? store.listCredentialRecords(userId)
: [store.getUserRecord(userId)].filter(
(record): record is UserEncryptionMigrationRecord =>
Boolean(record),
);
for (const record of records) {
const recordId = record.id.toString();
const updatedRecord: UserEncryptionMigrationRecord = { ...record };
let needsUpdate = false;
for (const fieldName of fields) {
const fieldValue = record[fieldName];
if (
fieldValue &&
typeof fieldValue === "string" &&
fieldValue.trim() !== ""
) {
try {
const reencryptedValue = FieldCrypto.encryptField(
fieldValue,
newUserDataKey,
recordId,
fieldName,
);
updatedRecord[fieldName] = reencryptedValue;
needsUpdate = true;
result.reencryptedFieldsCount++;
} catch (error) {
const errorMsg = `Failed to re-encrypt ${fieldName} for ${table} record ${recordId}: ${error instanceof Error ? error.message : "Unknown error"}`;
result.errors.push(errorMsg);
databaseLogger.warn(
"Field re-encryption failed during password reset",
{
operation: "password_reset_reencrypt_failed",
userId,
table,
recordId,
fieldName,
error:
error instanceof Error
? error.message
: "Unknown error",
},
);
}
}
}
if (needsUpdate) {
const updateFields = fields.filter(
(field) => updatedRecord[field] !== record[field],
);
if (updateFields.length > 0) {
store.updatePasswordResetFields(
table,
record.id,
updateFields,
updatedRecord,
);
if (!result.reencryptedTables.includes(table)) {
result.reencryptedTables.push(table);
}
}
}
}
} catch (tableError) {
const errorMsg = `Failed to re-encrypt table ${table}: ${tableError instanceof Error ? tableError.message : "Unknown error"}`;
result.errors.push(errorMsg);
databaseLogger.error(
"Table re-encryption failed during password reset",
tableError,
{
operation: "password_reset_table_reencrypt_failed",
userId,
table,
error:
tableError instanceof Error
? tableError.message
: "Unknown error",
},
);
}
}
result.success = result.errors.length === 0;
return result;
} catch (error) {
databaseLogger.error(
"User data re-encryption failed after password reset",
error,
{
operation: "password_reset_reencrypt_failed",
userId,
error: error instanceof Error ? error.message : "Unknown error",
},
);
result.errors.push(
`Critical error during re-encryption: ${error instanceof Error ? error.message : "Unknown error"}`,
);
return result;
}
}
static validateUserAccess(userId: string): Buffer { static validateUserAccess(userId: string): Buffer {
return this.userKeys.getUserDEK(userId); return this.userKeys.getUserDEK(userId);
} }
+20 -1
View File
@@ -511,7 +511,26 @@ export function Auth({
} }
try { try {
await completePasswordReset(localUsername, tempToken, newPassword); try {
await completePasswordReset(localUsername, tempToken, newPassword);
} catch (err: unknown) {
const error = err as {
response?: { data?: { code?: string } };
};
if (error?.response?.data?.code !== "DATA_WIPE_REQUIRED") {
throw err;
}
if (!window.confirm(t("auth.confirmResetDataWipe"))) {
setResetLoading(false);
return;
}
await completePasswordReset(
localUsername,
tempToken,
newPassword,
true,
);
}
setResetStep("initiate"); setResetStep("initiate");
setResetCode(""); setResetCode("");
+2 -1
View File
@@ -1950,7 +1950,8 @@
"passwordLoginDisabledDesc": "Password login is disabled. Use a passkey or an external authentication provider.", "passwordLoginDisabledDesc": "Password login is disabled. Use a passkey or an external authentication provider.",
"signInWithPasskey": "Sign in with passkey", "signInWithPasskey": "Sign in with passkey",
"passkeyLoginFailed": "Passkey login failed", "passkeyLoginFailed": "Passkey login failed",
"attemptsRemaining": "{{count}} attempts remaining" "attemptsRemaining": "{{count}} attempts remaining",
"confirmResetDataWipe": "This account has not logged in since the encryption upgrade, so its stored data cannot be recovered without the old password. Resetting will permanently delete its hosts, credentials and snippets. Continue?"
}, },
"hostKey": { "hostKey": {
"verifyNewHost": "Verify SSH Host Key", "verifyNewHost": "Verify SSH Host Key",
+2
View File
@@ -1888,12 +1888,14 @@ export async function completePasswordReset(
username: string, username: string,
tempToken: string, tempToken: string,
newPassword: string, newPassword: string,
confirmDataWipe = false,
): Promise<Record<string, unknown>> { ): Promise<Record<string, unknown>> {
try { try {
const response = await authApi.post("/users/complete-reset", { const response = await authApi.post("/users/complete-reset", {
username, username,
tempToken, tempToken,
newPassword, newPassword,
confirmDataWipe,
}); });
return response.data; return response.data;
} catch (error) { } catch (error) {