mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-17 05:43:36 +00:00
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:
@@ -892,13 +892,6 @@ class AuthManager {
|
||||
return this.userKeys.tryGetUserDEK(userId) !== null;
|
||||
}
|
||||
|
||||
async resetUserPasswordWithPreservedDEK(
|
||||
userId: string,
|
||||
_newPassword?: string,
|
||||
): Promise<boolean> {
|
||||
return this.userKeys.tryGetUserDEK(userId) !== null;
|
||||
}
|
||||
|
||||
async isTrustedDevice(
|
||||
userId: string,
|
||||
deviceFingerprint: string,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user