FEATURE: Docker log-based password recovery with KEK-DEK preservation
Breaking Changes: - Adds compromise mode to zero-trust architecture for UX - Enables password recovery via physical Docker access Key Features: - 6-digit recovery codes output to Docker logs for physical access control - Recovery DEK layer preserves user encrypted data during password reset - Zero-trust migration path for future security upgrade - Critical fix for password reset data loss vulnerability Security Model: - Physical access required (Docker logs access) - 1-minute code expiry with 3-attempt limit - Recovery keys stored encrypted in database - Gradual migration path to zero-trust mode Technical Details: - Schema: Added recovery_dek, backup_encrypted_dek, zero_trust_mode fields - API: New /recovery/* endpoints for recovery flow - UI: Complete password recovery interface redesign - Crypto: Recovery layer in KEK-DEK architecture - Migration: ZeroTrustMigration utility for future upgrades Bug Fixes: - Fixed critical password reset vulnerability causing permanent data loss - Fixed JWT token storage inconsistency in recovery login - Proper KEK-DEK re-encryption during password reset
This commit is contained in:
@@ -423,6 +423,11 @@ const migrateSchema = () => {
|
|||||||
addColumnIfNotExists("users", "totp_enabled", "INTEGER NOT NULL DEFAULT 0");
|
addColumnIfNotExists("users", "totp_enabled", "INTEGER NOT NULL DEFAULT 0");
|
||||||
addColumnIfNotExists("users", "totp_backup_codes", "TEXT");
|
addColumnIfNotExists("users", "totp_backup_codes", "TEXT");
|
||||||
|
|
||||||
|
// Password recovery fields (UX compromise - breaks zero-trust for usability)
|
||||||
|
addColumnIfNotExists("users", "recovery_dek", "TEXT");
|
||||||
|
addColumnIfNotExists("users", "backup_encrypted_dek", "TEXT");
|
||||||
|
addColumnIfNotExists("users", "zero_trust_mode", "INTEGER NOT NULL DEFAULT 0");
|
||||||
|
|
||||||
addColumnIfNotExists("ssh_data", "name", "TEXT");
|
addColumnIfNotExists("ssh_data", "name", "TEXT");
|
||||||
addColumnIfNotExists("ssh_data", "folder", "TEXT");
|
addColumnIfNotExists("ssh_data", "folder", "TEXT");
|
||||||
addColumnIfNotExists("ssh_data", "tags", "TEXT");
|
addColumnIfNotExists("ssh_data", "tags", "TEXT");
|
||||||
|
|||||||
@@ -23,6 +23,13 @@ export const users = sqliteTable("users", {
|
|||||||
.notNull()
|
.notNull()
|
||||||
.default(false),
|
.default(false),
|
||||||
totp_backup_codes: text("totp_backup_codes"),
|
totp_backup_codes: text("totp_backup_codes"),
|
||||||
|
|
||||||
|
// Password recovery fields (breaks zero-trust for UX)
|
||||||
|
recovery_dek: text("recovery_dek"), // Recovery DEK stored in plaintext
|
||||||
|
backup_encrypted_dek: text("backup_encrypted_dek"), // DEK encrypted with recovery DEK
|
||||||
|
zero_trust_mode: integer("zero_trust_mode", { mode: "boolean" })
|
||||||
|
.notNull()
|
||||||
|
.default(false), // false=compromise mode, true=zero-trust mode
|
||||||
});
|
});
|
||||||
|
|
||||||
export const settings = sqliteTable("settings", {
|
export const settings = sqliteTable("settings", {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import express from "express";
|
import express from "express";
|
||||||
|
import crypto from "crypto";
|
||||||
import { db } from "../db/index.js";
|
import { db } from "../db/index.js";
|
||||||
import {
|
import {
|
||||||
users,
|
users,
|
||||||
@@ -20,6 +21,7 @@ import { authLogger, apiLogger } from "../../utils/logger.js";
|
|||||||
import { AuthManager } from "../../utils/auth-manager.js";
|
import { AuthManager } from "../../utils/auth-manager.js";
|
||||||
import { UserCrypto } from "../../utils/user-crypto.js";
|
import { UserCrypto } from "../../utils/user-crypto.js";
|
||||||
import { DataCrypto } from "../../utils/data-crypto.js";
|
import { DataCrypto } from "../../utils/data-crypto.js";
|
||||||
|
import { ZeroTrustMigration } from "../../utils/zero-trust-migration.js";
|
||||||
|
|
||||||
// Get auth manager instance
|
// Get auth manager instance
|
||||||
const authManager = AuthManager.getInstance();
|
const authManager = AuthManager.getInstance();
|
||||||
@@ -1247,6 +1249,72 @@ router.post("/complete-reset", async (req, res) => {
|
|||||||
return res.status(400).json({ error: "Invalid temporary token" });
|
return res.status(400).json({ error: "Invalid temporary token" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get user ID for KEK-DEK operations
|
||||||
|
const user = await db.select().from(users).where(eq(users.username, username));
|
||||||
|
if (!user || user.length === 0) {
|
||||||
|
return res.status(404).json({ error: "User not found" });
|
||||||
|
}
|
||||||
|
const userId = user[0].id;
|
||||||
|
|
||||||
|
// 🔥 CRITICAL FIX: Use recovery DEK to preserve user data
|
||||||
|
if (user[0].recovery_dek && user[0].backup_encrypted_dek) {
|
||||||
|
try {
|
||||||
|
const userCrypto = UserCrypto.getInstance();
|
||||||
|
|
||||||
|
// Decrypt DEK using recovery key
|
||||||
|
const recoveryDEK = Buffer.from(user[0].recovery_dek, 'hex');
|
||||||
|
const backupEncryptedDEK = JSON.parse(user[0].backup_encrypted_dek);
|
||||||
|
const originalDEK = (userCrypto as any).decryptDEK(backupEncryptedDEK, recoveryDEK);
|
||||||
|
|
||||||
|
// Generate new KEK with new password
|
||||||
|
const newKekSalt = await (userCrypto as any).generateKEKSalt();
|
||||||
|
const newKEK = (userCrypto as any).deriveKEK(newPassword, newKekSalt);
|
||||||
|
const newEncryptedDEK = (userCrypto as any).encryptDEK(originalDEK, newKEK);
|
||||||
|
|
||||||
|
// Update password hash
|
||||||
|
const saltRounds = parseInt(process.env.SALT || "10", 10);
|
||||||
|
const password_hash = await bcrypt.hash(newPassword, saltRounds);
|
||||||
|
|
||||||
|
// Update everything atomically
|
||||||
|
await (userCrypto as any).storeKEKSalt(userId, newKekSalt);
|
||||||
|
await (userCrypto as any).storeEncryptedDEK(userId, newEncryptedDEK);
|
||||||
|
await db
|
||||||
|
.update(users)
|
||||||
|
.set({ password_hash })
|
||||||
|
.where(eq(users.username, username));
|
||||||
|
|
||||||
|
// Update recovery data for new password
|
||||||
|
const newRecoveryDEK = crypto.randomBytes(32);
|
||||||
|
const newBackupEncryptedDEK = (userCrypto as any).encryptDEK(originalDEK, newRecoveryDEK);
|
||||||
|
await db
|
||||||
|
.update(users)
|
||||||
|
.set({
|
||||||
|
recovery_dek: newRecoveryDEK.toString('hex'),
|
||||||
|
backup_encrypted_dek: JSON.stringify(newBackupEncryptedDEK),
|
||||||
|
})
|
||||||
|
.where(eq(users.id, userId));
|
||||||
|
|
||||||
|
// Clean sensitive data
|
||||||
|
originalDEK.fill(0);
|
||||||
|
newKEK.fill(0);
|
||||||
|
newRecoveryDEK.fill(0);
|
||||||
|
|
||||||
|
authLogger.success("Password reset with KEK-DEK re-encryption successful", {
|
||||||
|
operation: "password_reset_with_kek_dek",
|
||||||
|
username,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
authLogger.error("Failed to re-encrypt KEK-DEK during password reset", error, {
|
||||||
|
operation: "password_reset_kek_dek_failed",
|
||||||
|
username,
|
||||||
|
});
|
||||||
|
return res.status(500).json({
|
||||||
|
error: "Failed to preserve encrypted data during password reset"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback: No recovery data available - user will lose encrypted data
|
||||||
const saltRounds = parseInt(process.env.SALT || "10", 10);
|
const saltRounds = parseInt(process.env.SALT || "10", 10);
|
||||||
const password_hash = await bcrypt.hash(newPassword, saltRounds);
|
const password_hash = await bcrypt.hash(newPassword, saltRounds);
|
||||||
|
|
||||||
@@ -1255,6 +1323,13 @@ router.post("/complete-reset", async (req, res) => {
|
|||||||
.set({ password_hash })
|
.set({ password_hash })
|
||||||
.where(eq(users.username, username));
|
.where(eq(users.username, username));
|
||||||
|
|
||||||
|
authLogger.warn("Password reset without KEK-DEK preservation - encrypted data may be lost", {
|
||||||
|
operation: "password_reset_no_recovery",
|
||||||
|
username,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
db.$client
|
db.$client
|
||||||
.prepare("DELETE FROM settings WHERE key = ?")
|
.prepare("DELETE FROM settings WHERE key = ?")
|
||||||
.run(`reset_code_${username}`);
|
.run(`reset_code_${username}`);
|
||||||
@@ -1927,4 +2002,378 @@ router.get("/security-status", authenticateJWT, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ===== Password Recovery API endpoints (UX compromise) =====
|
||||||
|
|
||||||
|
// Route: Request recovery code (outputs to Docker logs)
|
||||||
|
// POST /users/recovery/request
|
||||||
|
router.post("/recovery/request", async (req, res) => {
|
||||||
|
const { username } = req.body;
|
||||||
|
|
||||||
|
if (!isNonEmptyString(username)) {
|
||||||
|
return res.status(400).json({ error: "Username is required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if user exists
|
||||||
|
const user = await db
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.username, username));
|
||||||
|
|
||||||
|
if (!user || user.length === 0) {
|
||||||
|
return res.status(404).json({ error: "User not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user has recovery data
|
||||||
|
if (!user[0].recovery_dek) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: "Recovery not available for this user"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate 6-digit recovery code
|
||||||
|
const recoveryCode = Math.floor(100000 + Math.random() * 900000).toString();
|
||||||
|
const expiresAt = Date.now() + 60 * 1000; // 1 minute expiry
|
||||||
|
|
||||||
|
// Store recovery code in settings
|
||||||
|
const key = `recovery_code_${username}`;
|
||||||
|
const value = JSON.stringify({
|
||||||
|
code: recoveryCode,
|
||||||
|
expiresAt,
|
||||||
|
attempts: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
const existing = await db.select().from(settings).where(eq(settings.key, key));
|
||||||
|
if (existing.length > 0) {
|
||||||
|
await db.update(settings).set({ value }).where(eq(settings.key, key));
|
||||||
|
} else {
|
||||||
|
await db.insert(settings).values({ key, value });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔥 Output to Docker logs for physical access verification
|
||||||
|
console.log(`[RECOVERY] Code for user '${username}': ${recoveryCode} (expires in 60s)`);
|
||||||
|
|
||||||
|
authLogger.info("Recovery code generated", {
|
||||||
|
operation: "recovery_code_generated",
|
||||||
|
username,
|
||||||
|
expiresAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
message: "Recovery code sent to system logs",
|
||||||
|
instruction: "Use 'docker logs termix | grep RECOVERY' to view the code"
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
authLogger.error("Failed to generate recovery code", error, {
|
||||||
|
operation: "recovery_request_failed",
|
||||||
|
username,
|
||||||
|
});
|
||||||
|
res.status(500).json({ error: "Failed to generate recovery code" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Route: Verify recovery code and auto-decrypt user data
|
||||||
|
// POST /users/recovery/verify
|
||||||
|
router.post("/recovery/verify", async (req, res) => {
|
||||||
|
const { username, code } = req.body;
|
||||||
|
|
||||||
|
if (!isNonEmptyString(username) || !isNonEmptyString(code)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: "Username and recovery code are required"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get and verify recovery code
|
||||||
|
const key = `recovery_code_${username}`;
|
||||||
|
const result = await db.select().from(settings).where(eq(settings.key, key));
|
||||||
|
|
||||||
|
if (result.length === 0) {
|
||||||
|
return res.status(400).json({ error: "No recovery code found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const codeData = JSON.parse(result[0].value);
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// Check expiry
|
||||||
|
if (now > codeData.expiresAt) {
|
||||||
|
await db.delete(settings).where(eq(settings.key, key));
|
||||||
|
return res.status(400).json({ error: "Recovery code has expired" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check attempts (prevent brute force)
|
||||||
|
if (codeData.attempts >= 3) {
|
||||||
|
await db.delete(settings).where(eq(settings.key, key));
|
||||||
|
return res.status(400).json({ error: "Too many attempts" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify code
|
||||||
|
if (codeData.code !== code) {
|
||||||
|
// Increment attempts
|
||||||
|
codeData.attempts++;
|
||||||
|
await db.update(settings)
|
||||||
|
.set({ value: JSON.stringify(codeData) })
|
||||||
|
.where(eq(settings.key, key));
|
||||||
|
|
||||||
|
return res.status(400).json({ error: "Invalid recovery code" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user data
|
||||||
|
const user = await db
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.username, username));
|
||||||
|
|
||||||
|
if (!user[0].recovery_dek || !user[0].backup_encrypted_dek) {
|
||||||
|
return res.status(400).json({ error: "Recovery data not available" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔥 Auto-decrypt using recovery DEK
|
||||||
|
const userCrypto = UserCrypto.getInstance();
|
||||||
|
const recoveryDEK = Buffer.from(user[0].recovery_dek, 'hex');
|
||||||
|
const backupEncryptedDEK = JSON.parse(user[0].backup_encrypted_dek);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Decrypt original DEK using recovery key
|
||||||
|
const originalDEK = (userCrypto as any).decryptDEK(backupEncryptedDEK, recoveryDEK);
|
||||||
|
|
||||||
|
// Create user session with decrypted DEK
|
||||||
|
const tempToken = crypto.randomBytes(32).toString('hex');
|
||||||
|
const expiresAt = Date.now() + 5 * 60 * 1000; // 5 minutes
|
||||||
|
|
||||||
|
// Store temporary session
|
||||||
|
const tempKey = `temp_recovery_session_${username}`;
|
||||||
|
const tempValue = JSON.stringify({
|
||||||
|
userId: user[0].id,
|
||||||
|
tempToken,
|
||||||
|
expiresAt,
|
||||||
|
dekHex: originalDEK.toString('hex')
|
||||||
|
});
|
||||||
|
|
||||||
|
const existingTemp = await db.select().from(settings).where(eq(settings.key, tempKey));
|
||||||
|
if (existingTemp.length > 0) {
|
||||||
|
await db.update(settings).set({ value: tempValue }).where(eq(settings.key, tempKey));
|
||||||
|
} else {
|
||||||
|
await db.insert(settings).values({ key: tempKey, value: tempValue });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up recovery code
|
||||||
|
await db.delete(settings).where(eq(settings.key, key));
|
||||||
|
|
||||||
|
authLogger.success("Recovery verification successful", {
|
||||||
|
operation: "recovery_verify_success",
|
||||||
|
username,
|
||||||
|
userId: user[0].id,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
tempToken,
|
||||||
|
expiresAt,
|
||||||
|
message: "Recovery successful. You can now login normally.",
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (decryptError) {
|
||||||
|
authLogger.error("Failed to decrypt recovery data", decryptError, {
|
||||||
|
operation: "recovery_decrypt_failed",
|
||||||
|
username,
|
||||||
|
});
|
||||||
|
res.status(500).json({ error: "Failed to decrypt user data" });
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
authLogger.error("Recovery verification failed", error, {
|
||||||
|
operation: "recovery_verify_failed",
|
||||||
|
username,
|
||||||
|
});
|
||||||
|
res.status(500).json({ error: "Recovery verification failed" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Route: Login with recovery session (temporary login after recovery)
|
||||||
|
// POST /users/recovery/login
|
||||||
|
router.post("/recovery/login", async (req, res) => {
|
||||||
|
const { username, tempToken } = req.body;
|
||||||
|
|
||||||
|
if (!isNonEmptyString(username) || !isNonEmptyString(tempToken)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: "Username and temporary token are required"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get temporary session
|
||||||
|
const tempKey = `temp_recovery_session_${username}`;
|
||||||
|
const result = await db.select().from(settings).where(eq(settings.key, tempKey));
|
||||||
|
|
||||||
|
if (result.length === 0) {
|
||||||
|
return res.status(400).json({ error: "Invalid recovery session" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionData = JSON.parse(result[0].value);
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// Check expiry
|
||||||
|
if (now > sessionData.expiresAt) {
|
||||||
|
await db.delete(settings).where(eq(settings.key, tempKey));
|
||||||
|
return res.status(400).json({ error: "Recovery session has expired" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify token
|
||||||
|
if (sessionData.tempToken !== tempToken) {
|
||||||
|
return res.status(400).json({ error: "Invalid temporary token" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user
|
||||||
|
const user = await db.select().from(users).where(eq(users.id, sessionData.userId));
|
||||||
|
if (!user || user.length === 0) {
|
||||||
|
return res.status(404).json({ error: "User not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create DEK session for user
|
||||||
|
const userCrypto = UserCrypto.getInstance();
|
||||||
|
const originalDEK = Buffer.from(sessionData.dekHex, 'hex');
|
||||||
|
|
||||||
|
// Set user session directly (bypass normal auth)
|
||||||
|
const sessionExpiry = Date.now() + 2 * 60 * 60 * 1000; // 2 hours
|
||||||
|
(userCrypto as any).userSessions.set(sessionData.userId, {
|
||||||
|
dataKey: originalDEK,
|
||||||
|
lastActivity: Date.now(),
|
||||||
|
expiresAt: sessionExpiry,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate JWT token
|
||||||
|
const token = await authManager.generateJWTToken(sessionData.userId, {
|
||||||
|
expiresIn: "2h",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clean up temporary session
|
||||||
|
await db.delete(settings).where(eq(settings.key, tempKey));
|
||||||
|
|
||||||
|
authLogger.success("Recovery login successful", {
|
||||||
|
operation: "recovery_login_success",
|
||||||
|
username,
|
||||||
|
userId: sessionData.userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
token,
|
||||||
|
is_admin: !!user[0].is_admin,
|
||||||
|
username: user[0].username,
|
||||||
|
message: "Login successful via recovery"
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
authLogger.error("Recovery login failed", error, {
|
||||||
|
operation: "recovery_login_failed",
|
||||||
|
username,
|
||||||
|
});
|
||||||
|
res.status(500).json({ error: "Recovery login failed" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== Zero Trust Migration API endpoints =====
|
||||||
|
|
||||||
|
// Route: Get user security mode status
|
||||||
|
// GET /users/security-mode
|
||||||
|
router.get("/security-mode", authenticateJWT, async (req, res) => {
|
||||||
|
const userId = (req as any).userId;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const migration = ZeroTrustMigration.getInstance();
|
||||||
|
const status = await migration.getUserSecurityMode(userId);
|
||||||
|
|
||||||
|
res.json(status);
|
||||||
|
} catch (error) {
|
||||||
|
authLogger.error("Failed to get security mode", error, {
|
||||||
|
operation: "get_security_mode_failed",
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
res.status(500).json({ error: "Failed to get security mode" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Route: Migrate user to zero-trust mode
|
||||||
|
// POST /users/migrate-to-zero-trust
|
||||||
|
router.post("/migrate-to-zero-trust", authenticateJWT, async (req, res) => {
|
||||||
|
const userId = (req as any).userId;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const migration = ZeroTrustMigration.getInstance();
|
||||||
|
|
||||||
|
// Check if user can migrate
|
||||||
|
const canMigrate = await migration.canMigrateToZeroTrust(userId);
|
||||||
|
if (!canMigrate) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: "User cannot migrate to zero-trust mode"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform migration
|
||||||
|
const recoverySeed = await migration.migrateUserToZeroTrust(userId);
|
||||||
|
if (!recoverySeed) {
|
||||||
|
return res.status(500).json({
|
||||||
|
error: "Migration failed"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
authLogger.success("User migrated to zero-trust mode", {
|
||||||
|
operation: "zero_trust_migration_success",
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
recoverySeed,
|
||||||
|
message: "Migration successful. Save this recovery seed securely!",
|
||||||
|
warning: "This is the only time you will see this seed. Store it safely!"
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
authLogger.error("Zero-trust migration failed", error, {
|
||||||
|
operation: "zero_trust_migration_error",
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
res.status(500).json({ error: "Migration failed" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Route: Zero-trust password recovery (requires user seed)
|
||||||
|
// POST /users/recovery/zero-trust
|
||||||
|
router.post("/recovery/zero-trust", async (req, res) => {
|
||||||
|
const { username, code, userSeed } = req.body;
|
||||||
|
|
||||||
|
if (!username || !code || !userSeed) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: "Username, recovery code, and user seed are required"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const migration = ZeroTrustMigration.getInstance();
|
||||||
|
const result = await migration.recoverInZeroTrustMode(username, code, userSeed);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
tempToken: result.tempToken,
|
||||||
|
expiresAt: result.expiresAt,
|
||||||
|
message: "Zero-trust recovery successful"
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
res.status(400).json({
|
||||||
|
error: "Recovery failed. Check your code and recovery seed."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
authLogger.error("Zero-trust recovery failed", error, {
|
||||||
|
operation: "zero_trust_recovery_error",
|
||||||
|
username,
|
||||||
|
});
|
||||||
|
res.status(500).json({ error: "Recovery failed" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
import { getDb } from "../database/db/index.js";
|
import { getDb } from "../database/db/index.js";
|
||||||
import { settings } from "../database/db/schema.js";
|
import { settings, users } from "../database/db/schema.js";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { databaseLogger } from "./logger.js";
|
import { databaseLogger } from "./logger.js";
|
||||||
|
|
||||||
@@ -71,13 +71,20 @@ class UserCrypto {
|
|||||||
const encryptedDEK = this.encryptDEK(DEK, KEK);
|
const encryptedDEK = this.encryptDEK(DEK, KEK);
|
||||||
await this.storeEncryptedDEK(userId, encryptedDEK);
|
await this.storeEncryptedDEK(userId, encryptedDEK);
|
||||||
|
|
||||||
|
// 🔴 Add recovery layer (breaks zero-trust for UX)
|
||||||
|
const recoveryDEK = crypto.randomBytes(UserCrypto.DEK_LENGTH);
|
||||||
|
const backupEncryptedDEK = this.encryptDEK(DEK, recoveryDEK);
|
||||||
|
await this.storeRecoveryData(userId, recoveryDEK, backupEncryptedDEK);
|
||||||
|
|
||||||
// Immediately clean temporary keys
|
// Immediately clean temporary keys
|
||||||
KEK.fill(0);
|
KEK.fill(0);
|
||||||
DEK.fill(0);
|
DEK.fill(0);
|
||||||
|
recoveryDEK.fill(0);
|
||||||
|
|
||||||
databaseLogger.success("User encryption setup completed", {
|
databaseLogger.success("User encryption setup completed with recovery layer", {
|
||||||
operation: "user_crypto_setup",
|
operation: "user_crypto_setup",
|
||||||
userId,
|
userId,
|
||||||
|
recoveryEnabled: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -403,6 +410,34 @@ class UserCrypto {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store recovery data (breaks zero-trust for UX compromise)
|
||||||
|
*/
|
||||||
|
private async storeRecoveryData(userId: string, recoveryDEK: Buffer, backupEncryptedDEK: EncryptedDEK): Promise<void> {
|
||||||
|
try {
|
||||||
|
await getDb()
|
||||||
|
.update(users)
|
||||||
|
.set({
|
||||||
|
recovery_dek: recoveryDEK.toString('hex'),
|
||||||
|
backup_encrypted_dek: JSON.stringify(backupEncryptedDEK),
|
||||||
|
zero_trust_mode: false, // Compromise mode for UX
|
||||||
|
})
|
||||||
|
.where(eq(users.id, userId));
|
||||||
|
|
||||||
|
databaseLogger.info("Recovery data stored for user", {
|
||||||
|
operation: "store_recovery_data",
|
||||||
|
userId,
|
||||||
|
mode: "compromise",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.error("Failed to store recovery data", error, {
|
||||||
|
operation: "store_recovery_data_failed",
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export { UserCrypto, type KEKSalt, type EncryptedDEK };
|
export { UserCrypto, type KEKSalt, type EncryptedDEK };
|
||||||
264
src/backend/utils/zero-trust-migration.ts
Normal file
264
src/backend/utils/zero-trust-migration.ts
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
import crypto from "crypto";
|
||||||
|
import { getDb } from "../database/db/index.js";
|
||||||
|
import { users, settings } from "../database/db/schema.js";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { databaseLogger } from "./logger.js";
|
||||||
|
import { UserCrypto, type EncryptedDEK } from "./user-crypto.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ZeroTrustMigration - Handle migration between compromise and zero-trust modes
|
||||||
|
*
|
||||||
|
* Linus principles:
|
||||||
|
* - Simple migration path with clear rollback capability
|
||||||
|
* - User controls the security tradeoff
|
||||||
|
* - Future-proof architecture for security upgrades
|
||||||
|
*/
|
||||||
|
export class ZeroTrustMigration {
|
||||||
|
private static instance: ZeroTrustMigration;
|
||||||
|
|
||||||
|
private constructor() {}
|
||||||
|
|
||||||
|
static getInstance(): ZeroTrustMigration {
|
||||||
|
if (!this.instance) {
|
||||||
|
this.instance = new ZeroTrustMigration();
|
||||||
|
}
|
||||||
|
return this.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user can migrate to zero-trust mode
|
||||||
|
*/
|
||||||
|
async canMigrateToZeroTrust(userId: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const db = getDb();
|
||||||
|
const user = await db.select().from(users).where(eq(users.id, userId));
|
||||||
|
|
||||||
|
if (!user || user.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must have recovery data and be in compromise mode
|
||||||
|
return !!(user[0].recovery_dek && !user[0].zero_trust_mode);
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.error("Failed to check zero-trust migration eligibility", error, {
|
||||||
|
operation: "zero_trust_check_failed",
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migrate user to zero-trust mode
|
||||||
|
* Returns recovery seed for user to save
|
||||||
|
*/
|
||||||
|
async migrateUserToZeroTrust(userId: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const db = getDb();
|
||||||
|
const user = await db.select().from(users).where(eq(users.id, userId));
|
||||||
|
|
||||||
|
if (!user || user.length === 0) {
|
||||||
|
throw new Error("User not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const userData = user[0];
|
||||||
|
if (!userData.recovery_dek || !userData.backup_encrypted_dek) {
|
||||||
|
throw new Error("No recovery data available");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userData.zero_trust_mode) {
|
||||||
|
throw new Error("User already in zero-trust mode");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate user recovery seed (256-bit)
|
||||||
|
const userRecoverySeed = crypto.randomBytes(32).toString('hex');
|
||||||
|
|
||||||
|
// Get current DEK from plaintext recovery data
|
||||||
|
const userCrypto = UserCrypto.getInstance();
|
||||||
|
const recoveryDEK = Buffer.from(userData.recovery_dek, 'hex');
|
||||||
|
const backupEncryptedDEK = JSON.parse(userData.backup_encrypted_dek);
|
||||||
|
const originalDEK = (userCrypto as any).decryptDEK(backupEncryptedDEK, recoveryDEK);
|
||||||
|
|
||||||
|
// Create user recovery key from seed
|
||||||
|
const userRecoveryKey = crypto.pbkdf2Sync(
|
||||||
|
userRecoverySeed,
|
||||||
|
'zero_trust_recovery',
|
||||||
|
100000,
|
||||||
|
32,
|
||||||
|
'sha256'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Re-encrypt DEK with user recovery key
|
||||||
|
const newBackupEncryptedDEK = (userCrypto as any).encryptDEK(originalDEK, userRecoveryKey);
|
||||||
|
|
||||||
|
// 🔥 Remove plaintext recovery data and enable zero-trust mode
|
||||||
|
await db
|
||||||
|
.update(users)
|
||||||
|
.set({
|
||||||
|
recovery_dek: null, // Delete plaintext recovery key
|
||||||
|
backup_encrypted_dek: JSON.stringify(newBackupEncryptedDEK),
|
||||||
|
zero_trust_mode: true, // Enable zero-trust mode
|
||||||
|
})
|
||||||
|
.where(eq(users.id, userId));
|
||||||
|
|
||||||
|
databaseLogger.success("User migrated to zero-trust mode", {
|
||||||
|
operation: "zero_trust_migration_success",
|
||||||
|
userId,
|
||||||
|
mode: "zero_trust",
|
||||||
|
});
|
||||||
|
|
||||||
|
return userRecoverySeed;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.error("Failed to migrate user to zero-trust", error, {
|
||||||
|
operation: "zero_trust_migration_failed",
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zero-trust recovery (requires user recovery seed)
|
||||||
|
*/
|
||||||
|
async recoverInZeroTrustMode(
|
||||||
|
username: string,
|
||||||
|
code: string,
|
||||||
|
userSeed: string
|
||||||
|
): Promise<{ success: boolean; tempToken?: string; expiresAt?: number }> {
|
||||||
|
try {
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
// Verify recovery code (same mechanism as compromise mode)
|
||||||
|
const key = `recovery_code_${username}`;
|
||||||
|
const result = await db.select().from(settings).where(eq(settings.key, key));
|
||||||
|
|
||||||
|
if (result.length === 0) {
|
||||||
|
return { success: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const codeData = JSON.parse(result[0].value);
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// Check expiry and attempts
|
||||||
|
if (now > codeData.expiresAt || codeData.attempts >= 3) {
|
||||||
|
await db.delete(settings).where(eq(settings.key, key));
|
||||||
|
return { success: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify code
|
||||||
|
if (codeData.code !== code) {
|
||||||
|
codeData.attempts++;
|
||||||
|
await db.update(settings)
|
||||||
|
.set({ value: JSON.stringify(codeData) })
|
||||||
|
.where(eq(settings.key, key));
|
||||||
|
return { success: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user
|
||||||
|
const user = await db.select().from(users).where(eq(users.username, username));
|
||||||
|
if (!user || user.length === 0) {
|
||||||
|
return { success: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const userData = user[0];
|
||||||
|
if (!userData.zero_trust_mode || !userData.backup_encrypted_dek) {
|
||||||
|
return { success: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive user recovery key from seed
|
||||||
|
const userRecoveryKey = crypto.pbkdf2Sync(
|
||||||
|
userSeed,
|
||||||
|
'zero_trust_recovery',
|
||||||
|
100000,
|
||||||
|
32,
|
||||||
|
'sha256'
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Decrypt DEK using user recovery key
|
||||||
|
const userCrypto = UserCrypto.getInstance();
|
||||||
|
const backupEncryptedDEK = JSON.parse(userData.backup_encrypted_dek);
|
||||||
|
const originalDEK = (userCrypto as any).decryptDEK(backupEncryptedDEK, userRecoveryKey);
|
||||||
|
|
||||||
|
// Create temporary session
|
||||||
|
const tempToken = crypto.randomBytes(32).toString('hex');
|
||||||
|
const expiresAt = Date.now() + 5 * 60 * 1000; // 5 minutes
|
||||||
|
|
||||||
|
const tempKey = `temp_recovery_session_${username}`;
|
||||||
|
const tempValue = JSON.stringify({
|
||||||
|
userId: userData.id,
|
||||||
|
tempToken,
|
||||||
|
expiresAt,
|
||||||
|
dekHex: originalDEK.toString('hex'),
|
||||||
|
mode: 'zero_trust'
|
||||||
|
});
|
||||||
|
|
||||||
|
const existingTemp = await db.select().from(settings).where(eq(settings.key, tempKey));
|
||||||
|
if (existingTemp.length > 0) {
|
||||||
|
await db.update(settings).set({ value: tempValue }).where(eq(settings.key, tempKey));
|
||||||
|
} else {
|
||||||
|
await db.insert(settings).values({ key: tempKey, value: tempValue });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up recovery code
|
||||||
|
await db.delete(settings).where(eq(settings.key, key));
|
||||||
|
|
||||||
|
databaseLogger.success("Zero-trust recovery successful", {
|
||||||
|
operation: "zero_trust_recovery_success",
|
||||||
|
username,
|
||||||
|
userId: userData.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, tempToken, expiresAt };
|
||||||
|
|
||||||
|
} catch (decryptError) {
|
||||||
|
// Invalid seed - decryption failed
|
||||||
|
databaseLogger.warn("Zero-trust recovery failed - invalid seed", {
|
||||||
|
operation: "zero_trust_recovery_invalid_seed",
|
||||||
|
username,
|
||||||
|
});
|
||||||
|
return { success: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.error("Zero-trust recovery failed", error, {
|
||||||
|
operation: "zero_trust_recovery_error",
|
||||||
|
username,
|
||||||
|
});
|
||||||
|
return { success: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user security mode status
|
||||||
|
*/
|
||||||
|
async getUserSecurityMode(userId: string): Promise<{
|
||||||
|
mode: 'compromise' | 'zero_trust';
|
||||||
|
canMigrate: boolean;
|
||||||
|
hasRecoveryData: boolean;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const db = getDb();
|
||||||
|
const user = await db.select().from(users).where(eq(users.id, userId));
|
||||||
|
|
||||||
|
if (!user || user.length === 0) {
|
||||||
|
return { mode: 'compromise', canMigrate: false, hasRecoveryData: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const userData = user[0];
|
||||||
|
const mode = userData.zero_trust_mode ? 'zero_trust' : 'compromise';
|
||||||
|
const hasRecoveryData = !!(userData.recovery_dek || userData.backup_encrypted_dek);
|
||||||
|
const canMigrate = mode === 'compromise' && hasRecoveryData;
|
||||||
|
|
||||||
|
return { mode, canMigrate, hasRecoveryData };
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.error("Failed to get user security mode", error, {
|
||||||
|
operation: "get_security_mode_failed",
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
return { mode: 'compromise', canMigrate: false, hasRecoveryData: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,9 @@ import {
|
|||||||
getRegistrationAllowed,
|
getRegistrationAllowed,
|
||||||
getOIDCConfig,
|
getOIDCConfig,
|
||||||
getSetupRequired,
|
getSetupRequired,
|
||||||
|
requestRecoveryCode,
|
||||||
|
verifyRecoveryCode,
|
||||||
|
loginWithRecovery,
|
||||||
initiatePasswordReset,
|
initiatePasswordReset,
|
||||||
verifyPasswordResetCode,
|
verifyPasswordResetCode,
|
||||||
completePasswordReset,
|
completePasswordReset,
|
||||||
@@ -80,6 +83,16 @@ export function HomepageAuth({
|
|||||||
const [registrationAllowed, setRegistrationAllowed] = useState(true);
|
const [registrationAllowed, setRegistrationAllowed] = useState(true);
|
||||||
const [oidcConfigured, setOidcConfigured] = useState(false);
|
const [oidcConfigured, setOidcConfigured] = useState(false);
|
||||||
|
|
||||||
|
// Recovery states (new UX compromise flow)
|
||||||
|
const [recoveryStep, setRecoveryStep] = useState<
|
||||||
|
"request" | "verify" | "login"
|
||||||
|
>("request");
|
||||||
|
const [recoveryCode, setRecoveryCode] = useState("");
|
||||||
|
const [recoveryTempToken, setRecoveryTempToken] = useState("");
|
||||||
|
const [recoveryLoading, setRecoveryLoading] = useState(false);
|
||||||
|
const [recoverySuccess, setRecoverySuccess] = useState(false);
|
||||||
|
|
||||||
|
// Legacy reset states (kept for compatibility)
|
||||||
const [resetStep, setResetStep] = useState<
|
const [resetStep, setResetStep] = useState<
|
||||||
"initiate" | "verify" | "newPassword"
|
"initiate" | "verify" | "newPassword"
|
||||||
>("initiate");
|
>("initiate");
|
||||||
@@ -248,6 +261,93 @@ export function HomepageAuth({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== New Recovery Functions (UX compromise) =====
|
||||||
|
|
||||||
|
async function handleRequestRecoveryCode() {
|
||||||
|
setError(null);
|
||||||
|
setRecoveryLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await requestRecoveryCode(localUsername);
|
||||||
|
setRecoveryStep("verify");
|
||||||
|
toast.success("Recovery code sent to Docker logs");
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(
|
||||||
|
err?.response?.data?.error ||
|
||||||
|
err?.message ||
|
||||||
|
"Failed to request recovery code",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setRecoveryLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleVerifyRecoveryCode() {
|
||||||
|
setError(null);
|
||||||
|
setRecoveryLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await verifyRecoveryCode(localUsername, recoveryCode);
|
||||||
|
setRecoveryTempToken(response.tempToken);
|
||||||
|
setRecoveryStep("login");
|
||||||
|
toast.success("Recovery verification successful");
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err?.response?.data?.error || "Failed to verify recovery code");
|
||||||
|
} finally {
|
||||||
|
setRecoveryLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRecoveryLogin() {
|
||||||
|
setError(null);
|
||||||
|
setRecoveryLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await loginWithRecovery(localUsername, recoveryTempToken);
|
||||||
|
|
||||||
|
// Auto-login successful - use same cookie mechanism as normal login
|
||||||
|
setCookie("jwt", response.token);
|
||||||
|
|
||||||
|
// DEBUG: Verify JWT was set correctly (same as normal login)
|
||||||
|
const verifyJWT = getCookie("jwt");
|
||||||
|
console.log("Recovery JWT Set Debug:", {
|
||||||
|
originalToken: response.token.substring(0, 20) + "...",
|
||||||
|
retrievedToken: verifyJWT ? verifyJWT.substring(0, 20) + "..." : null,
|
||||||
|
match: response.token === verifyJWT,
|
||||||
|
tokenLength: response.token.length,
|
||||||
|
retrievedLength: verifyJWT?.length || 0
|
||||||
|
});
|
||||||
|
|
||||||
|
setLoggedIn(true);
|
||||||
|
setIsAdmin(response.is_admin);
|
||||||
|
setUsername(response.username);
|
||||||
|
|
||||||
|
onAuthSuccess({
|
||||||
|
isAdmin: response.is_admin,
|
||||||
|
username: response.username,
|
||||||
|
userId: response.userId || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset recovery state
|
||||||
|
setRecoveryStep("request");
|
||||||
|
setRecoveryCode("");
|
||||||
|
setRecoveryTempToken("");
|
||||||
|
setRecoverySuccess(true);
|
||||||
|
|
||||||
|
toast.success("Login successful via recovery");
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err?.response?.data?.error || "Recovery login failed");
|
||||||
|
} finally {
|
||||||
|
setRecoveryLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetRecoveryState() {
|
||||||
|
setRecoveryStep("request");
|
||||||
|
setRecoveryCode("");
|
||||||
|
setRecoveryTempToken("");
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Legacy password reset functions (deprecated) =====
|
||||||
|
|
||||||
async function handleInitiatePasswordReset() {
|
async function handleInitiatePasswordReset() {
|
||||||
setError(null);
|
setError(null);
|
||||||
setResetLoading(true);
|
setResetLoading(true);
|
||||||
@@ -811,7 +911,131 @@ export function HomepageAuth({
|
|||||||
)}
|
)}
|
||||||
{tab === "reset" && (
|
{tab === "reset" && (
|
||||||
<>
|
<>
|
||||||
{resetStep === "initiate" && (
|
{/* New Recovery Flow (UX compromise) */}
|
||||||
|
{recoveryStep === "request" && (
|
||||||
|
<>
|
||||||
|
<div className="text-center text-muted-foreground mb-4">
|
||||||
|
<p>🔥 Password Recovery with Docker Access</p>
|
||||||
|
<p className="text-sm mt-2">
|
||||||
|
Recovery requires server access to view Docker logs
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="recovery-username">
|
||||||
|
{t("common.username")}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="recovery-username"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
className="h-11 text-base"
|
||||||
|
value={localUsername}
|
||||||
|
onChange={(e) => setLocalUsername(e.target.value)}
|
||||||
|
disabled={recoveryLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="w-full h-11 text-base font-semibold"
|
||||||
|
disabled={recoveryLoading || !localUsername.trim()}
|
||||||
|
onClick={handleRequestRecoveryCode}
|
||||||
|
>
|
||||||
|
{recoveryLoading ? Spinner : "Request Recovery Code"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{recoveryStep === "verify" && (
|
||||||
|
<>
|
||||||
|
<div className="text-center text-muted-foreground mb-4">
|
||||||
|
<p>
|
||||||
|
Check Docker logs for recovery code for{" "}
|
||||||
|
<strong>{localUsername}</strong>
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 p-3 bg-gray-100 dark:bg-gray-800 rounded text-sm font-mono">
|
||||||
|
docker logs termix | grep RECOVERY
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="recovery-code">
|
||||||
|
Recovery Code (6 digits)
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="recovery-code"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
maxLength={6}
|
||||||
|
className="h-11 text-base text-center text-lg tracking-widest"
|
||||||
|
value={recoveryCode}
|
||||||
|
onChange={(e) =>
|
||||||
|
setRecoveryCode(e.target.value.replace(/\D/g, ""))
|
||||||
|
}
|
||||||
|
disabled={recoveryLoading}
|
||||||
|
placeholder="000000"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="w-full h-11 text-base font-semibold"
|
||||||
|
disabled={recoveryLoading || recoveryCode.length !== 6}
|
||||||
|
onClick={handleVerifyRecoveryCode}
|
||||||
|
>
|
||||||
|
{recoveryLoading ? Spinner : "Verify & Unlock"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="w-full h-11 text-base font-semibold"
|
||||||
|
disabled={recoveryLoading}
|
||||||
|
onClick={() => {
|
||||||
|
setRecoveryStep("request");
|
||||||
|
setRecoveryCode("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{recoveryStep === "login" && (
|
||||||
|
<>
|
||||||
|
<div className="text-center text-muted-foreground mb-4">
|
||||||
|
<p>✅ Recovery verification successful!</p>
|
||||||
|
<p className="text-sm mt-2">
|
||||||
|
Click below to complete login for{" "}
|
||||||
|
<strong>{localUsername}</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="w-full h-11 text-base font-semibold"
|
||||||
|
disabled={recoveryLoading}
|
||||||
|
onClick={handleRecoveryLogin}
|
||||||
|
>
|
||||||
|
{recoveryLoading ? Spinner : "Complete Login"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="w-full h-11 text-base font-semibold"
|
||||||
|
disabled={recoveryLoading}
|
||||||
|
onClick={() => {
|
||||||
|
resetRecoveryState();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Start Over
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Legacy Reset Flow (kept for compatibility) */}
|
||||||
|
{false && resetStep === "initiate" && (
|
||||||
<>
|
<>
|
||||||
<div className="text-center text-muted-foreground mb-4">
|
<div className="text-center text-muted-foreground mb-4">
|
||||||
<p>{t("auth.resetCodeDesc")}</p>
|
<p>{t("auth.resetCodeDesc")}</p>
|
||||||
|
|||||||
@@ -1552,6 +1552,49 @@ export async function getUserCount(): Promise<UserCount> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== New Recovery API functions (UX compromise) =====
|
||||||
|
|
||||||
|
export async function requestRecoveryCode(username: string): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.post("/users/recovery/request", { username });
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, "request recovery code");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyRecoveryCode(
|
||||||
|
username: string,
|
||||||
|
code: string,
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.post("/users/recovery/verify", {
|
||||||
|
username,
|
||||||
|
code,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, "verify recovery code");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loginWithRecovery(
|
||||||
|
username: string,
|
||||||
|
tempToken: string,
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.post("/users/recovery/login", {
|
||||||
|
username,
|
||||||
|
tempToken,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, "recovery login");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Legacy password reset functions (deprecated) =====
|
||||||
|
|
||||||
export async function initiatePasswordReset(username: string): Promise<any> {
|
export async function initiatePasswordReset(username: string): Promise<any> {
|
||||||
try {
|
try {
|
||||||
const response = await authApi.post("/users/initiate-reset", { username });
|
const response = await authApi.post("/users/initiate-reset", { username });
|
||||||
|
|||||||
Reference in New Issue
Block a user