Phase 5: CSRF, bcrypt hashing, audit logging, export re-auth
- Add CSRF token per session, validated on all state-changing requests (POST/PUT/DELETE) - Upgrade password hashing from PBKDF2 to bcrypt; auto-upgrade old hashes on login - Add audit_log table tracking all security events (login, export, delete, etc.) - Add /reauth endpoint requiring master password before export - Client-side: re-auth modal before export, X-CSRF-Token header on mutations
This commit is contained in:
@@ -1,17 +1,26 @@
|
||||
<?php
|
||||
error_reporting(0);
|
||||
ini_set('display_errors', 0);
|
||||
set_exception_handler(function($e) {
|
||||
|
||||
$logFile = __DIR__ . '/vault-error.log';
|
||||
|
||||
set_exception_handler(function($e) use ($logFile) {
|
||||
file_put_contents($logFile, '[' . date('Y-m-d H:i:s') . '] FATAL: ' . $e->getMessage() . PHP_EOL, FILE_APPEND);
|
||||
http_response_code(500);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['error' => 'Server error: ' . $e->getMessage()]);
|
||||
echo json_encode(['error' => 'Internal server error']);
|
||||
exit;
|
||||
});
|
||||
|
||||
header('Content-Type: application/json');
|
||||
header('Access-Control-Allow-Origin: *');
|
||||
header('Strict-Transport-Security: max-age=31536000; includeSubDomains');
|
||||
|
||||
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
|
||||
if ($origin && preg_match('#^https?://(localhost|127\.0\.0\.1)(:\d+)?$#', $origin)) {
|
||||
header('Access-Control-Allow-Origin: ' . $origin);
|
||||
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
|
||||
header('Access-Control-Allow-Headers: Content-Type, Authorization');
|
||||
}
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') exit(0);
|
||||
|
||||
@@ -58,6 +67,18 @@ $db->exec("
|
||||
expires_at DATETIME NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS login_attempts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ip TEXT NOT NULL,
|
||||
attempted_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER,
|
||||
action TEXT NOT NULL,
|
||||
ip TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
");
|
||||
|
||||
try { $db->exec("ALTER TABLE vault_entries ADD COLUMN encryption_method TEXT DEFAULT 'server'"); } catch (Exception $e) {}
|
||||
@@ -65,8 +86,63 @@ try { $db->exec("ALTER TABLE vault_entries ADD COLUMN folder TEXT DEFAULT 'All'"
|
||||
try { $db->exec("ALTER TABLE vault_entries ADD COLUMN deleted INTEGER DEFAULT 0"); } catch (Exception $e) {}
|
||||
try { $db->exec("ALTER TABLE vault_entries ADD COLUMN deleted_at DATETIME"); } catch (Exception $e) {}
|
||||
try { $db->exec("ALTER TABLE users DROP COLUMN encryption_key"); } catch (Exception $e) {}
|
||||
try { $db->exec("ALTER TABLE users ADD COLUMN hash_algo TEXT DEFAULT 'pbkdf2'"); } catch (Exception $e) {}
|
||||
try { $db->exec("ALTER TABLE sessions ADD COLUMN csrf_token TEXT"); } catch (Exception $e) {}
|
||||
|
||||
$db->exec("DELETE FROM sessions WHERE expires_at < datetime('now')");
|
||||
$db->exec("DELETE FROM login_attempts WHERE attempted_at < datetime('now', '-15 minutes')");
|
||||
$db->exec("DELETE FROM audit_log WHERE created_at < datetime('now', '-30 days')");
|
||||
|
||||
function getClientIP() {
|
||||
$headers = getallheaders();
|
||||
return $headers['X-Forwarded-For'] ?? $_SERVER['REMOTE_ADDR'] ?? 'unknown';
|
||||
}
|
||||
|
||||
function checkRateLimit($db) {
|
||||
$ip = getClientIP();
|
||||
$window = gmdate('Y-m-d H:i:s', strtotime('-15 minutes'));
|
||||
$st = $db->prepare('SELECT COUNT(*) as cnt FROM login_attempts WHERE ip=:ip AND attempted_at > :window');
|
||||
$st->bindValue(':ip', $ip, SQLITE3_TEXT);
|
||||
$st->bindValue(':window', $window, SQLITE3_TEXT);
|
||||
$row = $st->execute()->fetchArray(SQLITE3_ASSOC);
|
||||
return (int)($row['cnt'] ?? 0);
|
||||
}
|
||||
|
||||
function recordAttempt($db) {
|
||||
$ip = getClientIP();
|
||||
$st = $db->prepare('INSERT INTO login_attempts (ip) VALUES (:ip)');
|
||||
$st->bindValue(':ip', $ip, SQLITE3_TEXT);
|
||||
$st->execute();
|
||||
}
|
||||
|
||||
function clearAttempts($db) {
|
||||
$ip = getClientIP();
|
||||
$st = $db->prepare('DELETE FROM login_attempts WHERE ip=:ip');
|
||||
$st->bindValue(':ip', $ip, SQLITE3_TEXT);
|
||||
$st->execute();
|
||||
}
|
||||
|
||||
function logAudit($db, $userId, $action) {
|
||||
$ip = getClientIP();
|
||||
$st = $db->prepare('INSERT INTO audit_log (user_id, action, ip) VALUES (:uid, :action, :ip)');
|
||||
$st->bindValue(':uid', $userId, SQLITE3_INTEGER);
|
||||
$st->bindValue(':action', $action, SQLITE3_TEXT);
|
||||
$st->bindValue(':ip', $ip, SQLITE3_TEXT);
|
||||
$st->execute();
|
||||
}
|
||||
|
||||
function requireCSRF($db, $userId) {
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'GET') return;
|
||||
$headers = getallheaders();
|
||||
$csrfToken = $headers['X-CSRF-Token'] ?? '';
|
||||
if (!$csrfToken) { http_response_code(403); echo json_encode(['error'=>'Missing CSRF token']); exit; }
|
||||
$st = $db->prepare('SELECT csrf_token FROM sessions WHERE user_id=:uid AND expires_at > datetime(\'now\') ORDER BY created_at DESC LIMIT 1');
|
||||
$st->bindValue(':uid', $userId, SQLITE3_INTEGER);
|
||||
$row = $st->execute()->fetchArray(SQLITE3_ASSOC);
|
||||
if (!$row || !hash_equals($row['csrf_token'], $csrfToken)) {
|
||||
http_response_code(403); echo json_encode(['error'=>'Invalid CSRF token']); exit;
|
||||
}
|
||||
}
|
||||
|
||||
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
|
||||
$path = str_replace('/password-manager/api.php', '', $path);
|
||||
@@ -95,6 +171,7 @@ function authenticate($db) {
|
||||
try {
|
||||
switch (true) {
|
||||
case ($path === '/register' && $method === 'POST'):
|
||||
if (checkRateLimit($db) >= 5) { http_response_code(429); echo json_encode(['error'=>'Too many attempts. Try again later.']); break; }
|
||||
$u = trim($input['username'] ?? '');
|
||||
$p = $input['masterPassword'] ?? '';
|
||||
if (strlen($u) < 3 || strlen($p) < 8) { http_response_code(400); echo json_encode(['error'=>'Min 3/8 chars']); break; }
|
||||
@@ -102,11 +179,12 @@ try {
|
||||
$st->bindValue(':u', $u, SQLITE3_TEXT);
|
||||
if ($st->execute()->fetchArray()) { http_response_code(409); echo json_encode(['error'=>'Username exists']); break; }
|
||||
$salt = bin2hex(random_bytes(32));
|
||||
$hash = hash_pbkdf2('sha256', $p, $salt, 100000);
|
||||
$st = $db->prepare('INSERT INTO users (username, password_hash, salt) VALUES (:u, :h, :s)');
|
||||
$hash = password_hash($p, PASSWORD_BCRYPT);
|
||||
$st = $db->prepare('INSERT INTO users (username, password_hash, salt, hash_algo) VALUES (:u, :h, :s, :algo)');
|
||||
$st->bindValue(':u', $u, SQLITE3_TEXT);
|
||||
$st->bindValue(':h', $hash, SQLITE3_TEXT);
|
||||
$st->bindValue(':s', $salt, SQLITE3_TEXT);
|
||||
$st->bindValue(':algo', 'bcrypt', SQLITE3_TEXT);
|
||||
$st->execute();
|
||||
$uid = $db->lastInsertRowID();
|
||||
$defaultFolders = ['All', 'Social', 'Banking', 'Work', 'Personal'];
|
||||
@@ -118,25 +196,42 @@ try {
|
||||
}
|
||||
$token = bin2hex(random_bytes(32));
|
||||
$tokenHash = hash('sha256', $token);
|
||||
$csrfToken = bin2hex(random_bytes(32));
|
||||
$expires = date('Y-m-d H:i:s', strtotime('+24 hours'));
|
||||
$stS = $db->prepare('INSERT INTO sessions (user_id, token_hash, expires_at) VALUES (:uid, :th, :exp)');
|
||||
$stS = $db->prepare('INSERT INTO sessions (user_id, token_hash, csrf_token, expires_at) VALUES (:uid, :th, :csrf, :exp)');
|
||||
$stS->bindValue(':uid', $uid, SQLITE3_INTEGER);
|
||||
$stS->bindValue(':th', $tokenHash, SQLITE3_TEXT);
|
||||
$stS->bindValue(':csrf', $csrfToken, SQLITE3_TEXT);
|
||||
$stS->bindValue(':exp', $expires, SQLITE3_TEXT);
|
||||
$stS->execute();
|
||||
echo json_encode(['message'=>'OK','token'=>$token,'userId'=>$uid,'salt'=>$salt]);
|
||||
logAudit($db, $uid, 'register');
|
||||
echo json_encode(['message'=>'OK','token'=>$token,'userId'=>$uid,'salt'=>$salt,'csrfToken'=>$csrfToken]);
|
||||
break;
|
||||
|
||||
case ($path === '/login' && $method === 'POST'):
|
||||
if (checkRateLimit($db) >= 10) { http_response_code(429); echo json_encode(['error'=>'Too many attempts. Try again later.']); break; }
|
||||
$u = trim($input['username'] ?? '');
|
||||
$p = $input['masterPassword'] ?? '';
|
||||
$st = $db->prepare('SELECT * FROM users WHERE username=:u');
|
||||
$st->bindValue(':u', $u, SQLITE3_TEXT);
|
||||
$user = $st->execute()->fetchArray(SQLITE3_ASSOC);
|
||||
if (!$user) { http_response_code(401); echo json_encode(['error'=>'Invalid credentials']); break; }
|
||||
if (!hash_equals($user['password_hash'], hash_pbkdf2('sha256', $p, $user['salt'], 100000))) {
|
||||
http_response_code(401); echo json_encode(['error'=>'Invalid credentials']); break;
|
||||
if (!$user) { recordAttempt($db); http_response_code(401); echo json_encode(['error'=>'Invalid credentials']); break; }
|
||||
$algo = $user['hash_algo'] ?? 'pbkdf2';
|
||||
if ($algo === 'bcrypt') {
|
||||
$valid = password_verify($p, $user['password_hash']);
|
||||
} else {
|
||||
$valid = hash_equals($user['password_hash'], hash_pbkdf2('sha256', $p, $user['salt'], 100000));
|
||||
}
|
||||
if (!$valid) { recordAttempt($db); logAudit($db, $user['id'], 'failed_login'); http_response_code(401); echo json_encode(['error'=>'Invalid credentials']); break; }
|
||||
if ($algo !== 'bcrypt') {
|
||||
$newHash = password_hash($p, PASSWORD_BCRYPT);
|
||||
$upd = $db->prepare('UPDATE users SET password_hash=:h, hash_algo=:algo WHERE id=:uid');
|
||||
$upd->bindValue(':h', $newHash, SQLITE3_TEXT);
|
||||
$upd->bindValue(':algo', 'bcrypt', SQLITE3_TEXT);
|
||||
$upd->bindValue(':uid', $user['id'], SQLITE3_INTEGER);
|
||||
$upd->execute();
|
||||
}
|
||||
clearAttempts($db);
|
||||
$defaultFolders = ['All', 'Social', 'Banking', 'Work', 'Personal'];
|
||||
$stFolder = $db->prepare('INSERT OR IGNORE INTO folders (user_id, name) VALUES (:uid, :name)');
|
||||
foreach ($defaultFolders as $name) {
|
||||
@@ -146,13 +241,16 @@ try {
|
||||
}
|
||||
$token = bin2hex(random_bytes(32));
|
||||
$tokenHash = hash('sha256', $token);
|
||||
$csrfToken = bin2hex(random_bytes(32));
|
||||
$expires = date('Y-m-d H:i:s', strtotime('+24 hours'));
|
||||
$stS = $db->prepare('INSERT INTO sessions (user_id, token_hash, expires_at) VALUES (:uid, :th, :exp)');
|
||||
$stS = $db->prepare('INSERT INTO sessions (user_id, token_hash, csrf_token, expires_at) VALUES (:uid, :th, :csrf, :exp)');
|
||||
$stS->bindValue(':uid', $user['id'], SQLITE3_INTEGER);
|
||||
$stS->bindValue(':th', $tokenHash, SQLITE3_TEXT);
|
||||
$stS->bindValue(':csrf', $csrfToken, SQLITE3_TEXT);
|
||||
$stS->bindValue(':exp', $expires, SQLITE3_TEXT);
|
||||
$stS->execute();
|
||||
echo json_encode(['message'=>'OK','token'=>$token,'userId'=>$user['id'],'salt'=>$user['salt']]);
|
||||
logAudit($db, $user['id'], 'login');
|
||||
echo json_encode(['message'=>'OK','token'=>$token,'userId'=>$user['id'],'salt'=>$user['salt'],'csrfToken'=>$csrfToken]);
|
||||
break;
|
||||
|
||||
case ($path === '/folders' && $method === 'GET'):
|
||||
@@ -167,6 +265,7 @@ try {
|
||||
|
||||
case ($path === '/folders' && $method === 'POST'):
|
||||
$auth = authenticate($db);
|
||||
requireCSRF($db, $auth['userId']);
|
||||
$name = trim($input['name'] ?? '');
|
||||
if (!$name) { http_response_code(400); echo json_encode(['error'=>'Folder name required']); break; }
|
||||
if (strtolower($name) === 'all') { http_response_code(400); echo json_encode(['error'=>'Cannot use All']); break; }
|
||||
@@ -174,11 +273,13 @@ try {
|
||||
$st->bindValue(':uid', $auth['userId'], SQLITE3_INTEGER);
|
||||
$st->bindValue(':name', $name, SQLITE3_TEXT);
|
||||
try { $st->execute(); } catch (Exception $e) { http_response_code(409); echo json_encode(['error'=>'Folder exists']); break; }
|
||||
logAudit($db, $auth['userId'], 'add_folder');
|
||||
echo json_encode(['message'=>'Created','name'=>$name]);
|
||||
break;
|
||||
|
||||
case (preg_match('/^\/folders\/(.+)$/', $path, $m) && $method === 'DELETE'):
|
||||
$auth = authenticate($db);
|
||||
requireCSRF($db, $auth['userId']);
|
||||
$folderName = urldecode($m[1]);
|
||||
if ($folderName === 'All') { http_response_code(400); echo json_encode(['error'=>'Cannot delete All']); break; }
|
||||
$st = $db->prepare('DELETE FROM folders WHERE user_id=:uid AND name=:name');
|
||||
@@ -190,6 +291,7 @@ try {
|
||||
$stUp->bindValue(':uid', $auth['userId'], SQLITE3_INTEGER);
|
||||
$stUp->bindValue(':f', $folderName, SQLITE3_TEXT);
|
||||
$stUp->execute();
|
||||
logAudit($db, $auth['userId'], 'delete_folder');
|
||||
echo json_encode(['message'=>'Deleted']);
|
||||
break;
|
||||
|
||||
@@ -228,6 +330,7 @@ try {
|
||||
|
||||
case ($path === '/entries' && $method === 'POST'):
|
||||
$auth = authenticate($db);
|
||||
requireCSRF($db, $auth['userId']);
|
||||
$site = trim($input['site'] ?? '');
|
||||
$username = trim($input['username'] ?? '');
|
||||
$folder = trim($input['folder'] ?? 'All');
|
||||
@@ -245,11 +348,13 @@ try {
|
||||
$st->bindValue(':f', $folder, SQLITE3_TEXT);
|
||||
$st->bindValue(':c', $now, SQLITE3_TEXT);
|
||||
$st->execute();
|
||||
logAudit($db, $auth['userId'], 'add_entry');
|
||||
echo json_encode(['id'=>$db->lastInsertRowID(), 'site'=>$site, 'username'=>$username, 'folder'=>$folder]);
|
||||
break;
|
||||
|
||||
case (preg_match('/^\/entries\/(\d+)$/', $path, $m) && $method === 'PUT'):
|
||||
$auth = authenticate($db);
|
||||
requireCSRF($db, $auth['userId']);
|
||||
$site = trim($input['site'] ?? '');
|
||||
$username = trim($input['username'] ?? '');
|
||||
$encPwd = $input['encrypted_password'] ?? '';
|
||||
@@ -267,12 +372,14 @@ try {
|
||||
$st->bindValue(':id', $m[1], SQLITE3_INTEGER);
|
||||
$st->bindValue(':uid', $auth['userId'], SQLITE3_INTEGER);
|
||||
$st->execute();
|
||||
logAudit($db, $auth['userId'], 'edit_entry');
|
||||
echo json_encode(['message'=>'Updated']);
|
||||
break;
|
||||
|
||||
// Soft delete
|
||||
case (preg_match('/^\/entries\/(\d+)$/', $path, $m) && $method === 'DELETE'):
|
||||
$auth = authenticate($db);
|
||||
requireCSRF($db, $auth['userId']);
|
||||
$permanent = $_GET['permanent'] ?? '0';
|
||||
if ($permanent === '1') {
|
||||
$st = $db->prepare('DELETE FROM vault_entries WHERE id=:id AND user_id=:uid');
|
||||
@@ -282,21 +389,25 @@ try {
|
||||
$st->bindValue(':id', $m[1], SQLITE3_INTEGER);
|
||||
$st->bindValue(':uid', $auth['userId'], SQLITE3_INTEGER);
|
||||
$st->execute();
|
||||
logAudit($db, $auth['userId'], $permanent === '1' ? 'permanent_delete' : 'delete_entry');
|
||||
echo json_encode(['message'=>'Deleted']);
|
||||
break;
|
||||
|
||||
// Restore
|
||||
case (preg_match('/^\/entries\/(\d+)\/restore$/', $path, $m) && $method === 'POST'):
|
||||
$auth = authenticate($db);
|
||||
requireCSRF($db, $auth['userId']);
|
||||
$st = $db->prepare('UPDATE vault_entries SET deleted=0, deleted_at=NULL, updated_at=datetime(\'now\') WHERE id=:id AND user_id=:uid');
|
||||
$st->bindValue(':id', $m[1], SQLITE3_INTEGER);
|
||||
$st->bindValue(':uid', $auth['userId'], SQLITE3_INTEGER);
|
||||
$st->execute();
|
||||
logAudit($db, $auth['userId'], 'restore_entry');
|
||||
echo json_encode(['message'=>'Restored']);
|
||||
break;
|
||||
|
||||
// Empty trash (permanently delete all soft-deleted)
|
||||
case ($path === '/logout' && $method === 'POST'):
|
||||
$auth = authenticate($db);
|
||||
requireCSRF($db, $auth['userId']);
|
||||
$headers = getallheaders();
|
||||
$token = str_replace('Bearer ', '', $headers['Authorization'] ?? '');
|
||||
if ($token) {
|
||||
@@ -305,24 +416,47 @@ try {
|
||||
$del->bindValue(':th', $tokenHash, SQLITE3_TEXT);
|
||||
$del->execute();
|
||||
}
|
||||
logAudit($db, $auth['userId'], 'logout');
|
||||
echo json_encode(['message'=>'Logged out']);
|
||||
break;
|
||||
|
||||
case ($path === '/entries/trash/empty' && $method === 'DELETE'):
|
||||
$auth = authenticate($db);
|
||||
requireCSRF($db, $auth['userId']);
|
||||
$st = $db->prepare('DELETE FROM vault_entries WHERE user_id=:uid AND deleted=1');
|
||||
$st->bindValue(':uid', $auth['userId'], SQLITE3_INTEGER);
|
||||
$st->execute();
|
||||
logAudit($db, $auth['userId'], 'empty_trash');
|
||||
echo json_encode(['message'=>'Trash emptied']);
|
||||
break;
|
||||
|
||||
case ($path === '/reauth' && $method === 'POST'):
|
||||
$auth = authenticate($db);
|
||||
requireCSRF($db, $auth['userId']);
|
||||
$p = $input['masterPassword'] ?? '';
|
||||
$st = $db->prepare('SELECT * FROM users WHERE id=:uid');
|
||||
$st->bindValue(':uid', $auth['userId'], SQLITE3_INTEGER);
|
||||
$user = $st->execute()->fetchArray(SQLITE3_ASSOC);
|
||||
if (!$user) { http_response_code(401); echo json_encode(['error'=>'User not found']); break; }
|
||||
$algo = $user['hash_algo'] ?? 'pbkdf2';
|
||||
if ($algo === 'bcrypt') {
|
||||
$valid = password_verify($p, $user['password_hash']);
|
||||
} else {
|
||||
$valid = hash_equals($user['password_hash'], hash_pbkdf2('sha256', $p, $user['salt'], 100000));
|
||||
}
|
||||
if (!$valid) { logAudit($db, $auth['userId'], 'failed_reauth'); http_response_code(401); echo json_encode(['error'=>'Invalid password']); break; }
|
||||
logAudit($db, $auth['userId'], 'reauth');
|
||||
echo json_encode(['message'=>'OK']);
|
||||
break;
|
||||
|
||||
default:
|
||||
http_response_code(404);
|
||||
echo json_encode(['error'=>'Not found']);
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
file_put_contents($logFile, '[' . date('Y-m-d H:i:s') . '] ' . $e->getMessage() . PHP_EOL, FILE_APPEND);
|
||||
http_response_code(500);
|
||||
echo json_encode(['error' => $e->getMessage()]);
|
||||
echo json_encode(['error' => 'Internal server error']);
|
||||
}
|
||||
|
||||
$db->close();
|
||||
|
||||
+1
-1
@@ -166,7 +166,7 @@
|
||||
<div style="text-align:center;color:var(--text2);padding:2rem;grid-column:1/-1">📭 No entries</div>
|
||||
</div>
|
||||
<div style="margin-top:.6rem;text-align:right">
|
||||
<button class="btn btn-outline btn-sm" onclick="exportPasswords()">📤 Export</button>
|
||||
<button class="btn btn-outline btn-sm" onclick="showExportModal()">📤 Export</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const API = '/password-manager/api.php';
|
||||
let token = sessionStorage.getItem('authToken');
|
||||
let csrfToken = sessionStorage.getItem('csrfToken') || '';
|
||||
let curUser = sessionStorage.getItem('currentUsername');
|
||||
let view = localStorage.getItem('vaultView') || 'grid';
|
||||
let showView = localStorage.getItem('showViewBtn') !== 'false';
|
||||
@@ -157,7 +158,7 @@ async function addFolderToServer(name) {
|
||||
try {
|
||||
const r = await fetch(API + '/folders', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
|
||||
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken },
|
||||
body: JSON.stringify({ name })
|
||||
});
|
||||
if (r.ok) { await loadFolders(); return true; }
|
||||
@@ -171,7 +172,7 @@ async function deleteFolderFromServer(name) {
|
||||
try {
|
||||
const r = await fetch(API + '/folders/' + encodeURIComponent(name), {
|
||||
method: 'DELETE',
|
||||
headers: { 'Authorization': 'Bearer ' + token }
|
||||
headers: { 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken }
|
||||
});
|
||||
if (r.ok) {
|
||||
await loadFolders();
|
||||
@@ -251,15 +252,15 @@ function toggleTrash() {
|
||||
playSound('click');
|
||||
}
|
||||
async function restoreEntry(id) {
|
||||
try { const r = await fetch(API + '/entries/' + id + '/restore', { method: 'POST', headers: { 'Authorization': 'Bearer ' + token } }); if (r.ok) { toast('✅ Restored!'); await loadEntries(); playSound('success'); } } catch (e) { toast('⚠️ Error', 'error'); }
|
||||
try { const r = await fetch(API + '/entries/' + id + '/restore', { method: 'POST', headers: { 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken } }); if (r.ok) { toast('✅ Restored!'); await loadEntries(); playSound('success'); } } catch (e) { toast('⚠️ Error', 'error'); }
|
||||
}
|
||||
async function permanentDelete(id) {
|
||||
if (!confirm('Permanently delete?')) return;
|
||||
try { const r = await fetch(API + '/entries/' + id + '?permanent=1', { method: 'DELETE', headers: { 'Authorization': 'Bearer ' + token } }); if (r.ok) { toast('🗑️ Permanently deleted'); await loadEntries(); playSound('error'); } } catch (e) { toast('⚠️ Error', 'error'); }
|
||||
try { const r = await fetch(API + '/entries/' + id + '?permanent=1', { method: 'DELETE', headers: { 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken } }); if (r.ok) { toast('🗑️ Permanently deleted'); await loadEntries(); playSound('error'); } } catch (e) { toast('⚠️ Error', 'error'); }
|
||||
}
|
||||
async function emptyTrash() {
|
||||
if (!confirm('Delete ALL trashed entries?')) return;
|
||||
try { const r = await fetch(API + '/entries/trash/empty', { method: 'DELETE', headers: { 'Authorization': 'Bearer ' + token } }); if (r.ok) { toast('🗑️ Trash emptied'); await loadEntries(); playSound('error'); } } catch (e) { toast('⚠️ Error', 'error'); }
|
||||
try { const r = await fetch(API + '/entries/trash/empty', { method: 'DELETE', headers: { 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken } }); if (r.ok) { toast('🗑️ Trash emptied'); await loadEntries(); playSound('error'); } } catch (e) { toast('⚠️ Error', 'error'); }
|
||||
}
|
||||
function timeAgo(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
@@ -389,10 +390,11 @@ async function login() {
|
||||
const r = await fetch(API + '/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: u, masterPassword: p }) });
|
||||
const d = await r.json();
|
||||
if (r.ok) {
|
||||
token = d.token; curUser = u;
|
||||
token = d.token; csrfToken = d.csrfToken || ''; curUser = u;
|
||||
cryptoKey = await deriveKey(p, d.salt);
|
||||
persistCryptoKey();
|
||||
sessionStorage.setItem('authToken', token);
|
||||
sessionStorage.setItem('csrfToken', csrfToken);
|
||||
sessionStorage.setItem('currentUsername', u);
|
||||
await loadFolders();
|
||||
toast('✅ Login!');
|
||||
@@ -414,10 +416,11 @@ async function register() {
|
||||
const r = await fetch(API + '/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: u, masterPassword: p }) });
|
||||
const d = await r.json();
|
||||
if (r.ok) {
|
||||
token = d.token; curUser = u;
|
||||
token = d.token; csrfToken = d.csrfToken || ''; curUser = u;
|
||||
cryptoKey = await deriveKey(p, d.salt);
|
||||
persistCryptoKey();
|
||||
sessionStorage.setItem('authToken', token);
|
||||
sessionStorage.setItem('csrfToken', csrfToken);
|
||||
sessionStorage.setItem('currentUsername', u);
|
||||
await loadFolders();
|
||||
toast('✅ Created!');
|
||||
@@ -432,11 +435,11 @@ async function register() {
|
||||
async function doLogout() {
|
||||
saveUsername();
|
||||
if (token) {
|
||||
try { await fetch(API + '/logout', { method: 'POST', headers: { 'Authorization': 'Bearer ' + token } }); } catch (e) {}
|
||||
try { await fetch(API + '/logout', { method: 'POST', headers: { 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken } }); } catch (e) {}
|
||||
}
|
||||
clearTimeout(idleT); clearTimeout(warnT); clearInterval(countT);
|
||||
document.getElementById('idleWarning').classList.remove('show');
|
||||
token = null; curUser = null; entries = []; cryptoKey = null; folders = ['All']; showTrash = false;
|
||||
token = null; csrfToken = ''; curUser = null; entries = []; cryptoKey = null; folders = ['All']; showTrash = false;
|
||||
sessionStorage.clear();
|
||||
document.getElementById('authSection').classList.remove('hidden');
|
||||
document.getElementById('vaultSection').classList.add('hidden');
|
||||
@@ -739,7 +742,7 @@ async function saveEdit() {
|
||||
if (!site || !password) { toast('Site and password required', 'error'); return; }
|
||||
try {
|
||||
const enc = await encryptPwd(password);
|
||||
const r = await fetch(API + '/entries/' + id, { method: 'PUT', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token }, body: JSON.stringify({ site, username, encrypted_password: enc.encrypted, iv: enc.iv, folder }) });
|
||||
const r = await fetch(API + '/entries/' + id, { method: 'PUT', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken }, body: JSON.stringify({ site, username, encrypted_password: enc.encrypted, iv: enc.iv, folder }) });
|
||||
if (r.ok) { toast('✅ Updated!'); closeEdit(); loadEntries(); playSound('success'); }
|
||||
else { const d = await r.json(); toast('❌ ' + (d.error || 'Failed'), 'error'); }
|
||||
} catch (e) { toast('⚠️ Error', 'error'); }
|
||||
@@ -827,7 +830,7 @@ async function batchMove() {
|
||||
const enc = await encryptPwd(e.password);
|
||||
await fetch(API + '/entries/' + id, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
|
||||
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken },
|
||||
body: JSON.stringify({ site: e.site, username: e.username, encrypted_password: enc.encrypted, iv: enc.iv, folder })
|
||||
});
|
||||
}
|
||||
@@ -850,7 +853,7 @@ async function addEntry() {
|
||||
const folder = document.getElementById('addFolder').value;
|
||||
const r = await fetch(API + '/entries', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
|
||||
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken },
|
||||
body: JSON.stringify({ site, username: user, encrypted_password: enc.encrypted, iv: enc.iv, encryption_method: 'client', folder })
|
||||
});
|
||||
if (r.ok) {
|
||||
@@ -867,7 +870,7 @@ async function addEntry() {
|
||||
}
|
||||
async function delEntry(id) {
|
||||
try {
|
||||
const r = await fetch(API + '/entries/' + id, { method: 'DELETE', headers: { 'Authorization': 'Bearer ' + token } });
|
||||
const r = await fetch(API + '/entries/' + id, { method: 'DELETE', headers: { 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken } });
|
||||
if (r.ok) { order = order.filter(x => x != id); localStorage.setItem('entryOrder', JSON.stringify(order)); toast('📦 Moved to trash'); await loadEntries(); playSound('delete'); }
|
||||
} catch (e) { toast('Error', 'error'); }
|
||||
}
|
||||
@@ -879,7 +882,39 @@ function searchEntries() {
|
||||
if (btn) btn.style.display = input.value.trim() ? 'block' : 'none';
|
||||
loadEntries(input.value);
|
||||
}
|
||||
function exportPasswords() { if (!confirm('Export plain text?')) return; const b = new Blob([JSON.stringify(entries, null, 2)], { type: 'application/json' }); const a = document.createElement('a'); a.href = URL.createObjectURL(b); a.download = 'vault-' + new Date().toISOString().slice(0, 10) + '.json'; a.click(); URL.revokeObjectURL(a.href); toast('Exported!'); }
|
||||
function showExportModal() {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'custom-modal-overlay show';
|
||||
overlay.innerHTML = `<div class="custom-modal"><h3>📤 Export Passwords</h3><p style="color:var(--text2);margin-bottom:1rem;">Re-enter master password to export plaintext passwords</p><input type="password" id="exportPassword" placeholder="Master password" style="width:100%"><div class="modal-actions" style="margin-top:1rem"><button class="btn btn-outline btn-sm" id="cancelExport">Cancel</button><button class="btn btn-sm" id="confirmExport">Export</button></div></div>`;
|
||||
document.body.appendChild(overlay);
|
||||
document.getElementById('cancelExport').onclick = () => overlay.remove();
|
||||
document.getElementById('confirmExport').onclick = async () => {
|
||||
const pwd = document.getElementById('exportPassword').value;
|
||||
if (!pwd) { toast('Enter your master password', 'error'); return; }
|
||||
try {
|
||||
const r = await fetch(API + '/reauth', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken },
|
||||
body: JSON.stringify({ masterPassword: pwd })
|
||||
});
|
||||
if (r.ok) {
|
||||
overlay.remove();
|
||||
const b = new Blob([JSON.stringify(entries, null, 2)], { type: 'application/json' });
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(b);
|
||||
a.download = 'vault-' + new Date().toISOString().slice(0, 10) + '.json';
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
toast('Exported!');
|
||||
playSound('success');
|
||||
} else {
|
||||
toast('❌ Invalid password', 'error');
|
||||
}
|
||||
} catch (e) { toast('⚠️ Error', 'error'); }
|
||||
};
|
||||
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
|
||||
playSound('open');
|
||||
}
|
||||
function esc(t) { const d = document.createElement('div'); d.textContent = t; return d.innerHTML; }
|
||||
function clearSearch() {
|
||||
const input = document.getElementById('searchInput');
|
||||
|
||||
Reference in New Issue
Block a user