diff --git a/src/backend/database/routes/user-admin-routes.ts b/src/backend/database/routes/user-admin-routes.ts index 437d6de3..dd58bac5 100644 --- a/src/backend/database/routes/user-admin-routes.ts +++ b/src/backend/database/routes/user-admin-routes.ts @@ -483,4 +483,127 @@ export function registerUserAdminRoutes( 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" }); + } + }); } diff --git a/src/backend/database/routes/user-password-reset-routes.test.ts b/src/backend/database/routes/user-password-reset-routes.test.ts new file mode 100644 index 00000000..e0b26b43 --- /dev/null +++ b/src/backend/database/routes/user-password-reset-routes.test.ts @@ -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]>, + 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) => { + 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); + }); +}); diff --git a/src/backend/database/routes/user-password-reset-routes.ts b/src/backend/database/routes/user-password-reset-routes.ts index 22924574..95171a34 100644 --- a/src/backend/database/routes/user-password-reset-routes.ts +++ b/src/backend/database/routes/user-password-reset-routes.ts @@ -25,6 +25,82 @@ function isNonEmptyString(val: unknown): val is string { 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 { + 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( router: Router, { authManager }: UserPasswordResetRoutesDeps, @@ -332,111 +408,19 @@ export function registerUserPasswordResetRoutes( } 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; - const cookie = req.cookies?.jwt; - let header: string | undefined; - if (req.headers?.authorization?.startsWith("Bearer ")) { - header = req.headers?.authorization?.split(" ")[1]; - } - 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, + if (outcome.status === "wipe_confirmation_required") { + return res.status(409).json({ + error: + "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.", + code: "DATA_WIPE_REQUIRED", }); - - 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}`); @@ -445,7 +429,10 @@ export function registerUserPasswordResetRoutes( await settingsRepository.delete(`reset_code_${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) { authLogger.error("Failed to complete password reset", err); res.status(500).json({ error: "Failed to complete password reset" }); diff --git a/src/backend/utils/auth-manager.ts b/src/backend/utils/auth-manager.ts index fd6f0563..f09797ae 100644 --- a/src/backend/utils/auth-manager.ts +++ b/src/backend/utils/auth-manager.ts @@ -892,13 +892,6 @@ class AuthManager { return this.userKeys.tryGetUserDEK(userId) !== null; } - async resetUserPasswordWithPreservedDEK( - userId: string, - _newPassword?: string, - ): Promise { - return this.userKeys.tryGetUserDEK(userId) !== null; - } - async isTrustedDevice( userId: string, deviceFingerprint: string, diff --git a/src/backend/utils/data-crypto.ts b/src/backend/utils/data-crypto.ts index 13d0017c..a92de14b 100644 --- a/src/backend/utils/data-crypto.ts +++ b/src/backend/utils/data-crypto.ts @@ -8,7 +8,6 @@ import { RawSqliteUserEncryptionMigrationStore, type LegacyDatabaseInstance, type UserEncryptionMigrationStore, - type UserEncryptionMigrationRecord, } from "./user-encryption-migration-store.js"; class DataCrypto { @@ -238,180 +237,6 @@ class DataCrypto { 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 { return this.userKeys.getUserDEK(userId); } diff --git a/src/ui/auth/LoginPage.tsx b/src/ui/auth/LoginPage.tsx index bd36ecf7..4efd8497 100644 --- a/src/ui/auth/LoginPage.tsx +++ b/src/ui/auth/LoginPage.tsx @@ -511,7 +511,26 @@ export function Auth({ } 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"); setResetCode(""); diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index 76e59aca..0be8e994 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -1950,7 +1950,8 @@ "passwordLoginDisabledDesc": "Password login is disabled. Use a passkey or an external authentication provider.", "signInWithPasskey": "Sign in with passkey", "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": { "verifyNewHost": "Verify SSH Host Key", diff --git a/src/ui/main-axios.ts b/src/ui/main-axios.ts index d38694cf..bdd5b82f 100644 --- a/src/ui/main-axios.ts +++ b/src/ui/main-axios.ts @@ -1888,12 +1888,14 @@ export async function completePasswordReset( username: string, tempToken: string, newPassword: string, + confirmDataWipe = false, ): Promise> { try { const response = await authApi.post("/users/complete-reset", { username, tempToken, newPassword, + confirmDataWipe, }); return response.data; } catch (error) {