diff --git a/api.php b/api.php index 608e500..8a097b9 100644 --- a/api.php +++ b/api.php @@ -1,17 +1,26 @@ 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('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS'); -header('Access-Control-Allow-Headers: Content-Type, Authorization'); +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(); diff --git a/index.html b/index.html index 36e6df1..2756ccb 100644 --- a/index.html +++ b/index.html @@ -166,7 +166,7 @@
Re-enter master password to export plaintext passwords