auth.php 3.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116
  1. <?php
  2. /**
  3. * Admin authentication: file-based credentials, session login,
  4. * brute-force throttling, and online password change.
  5. */
  6. declare(strict_types=1);
  7. const AUTH_MAX_FAILURES = 5;
  8. const AUTH_LOCK_SECONDS = 900; // 15 minutes
  9. function credentials_load(): array
  10. {
  11. $file = CONFIG_DIR . '/credentials.php';
  12. if (!is_file($file)) {
  13. http_response_code(500);
  14. exit('Missing config/credentials.php — copy config/credentials.sample.php.');
  15. }
  16. return require $file;
  17. }
  18. function auth_check(): bool
  19. {
  20. session_boot();
  21. return !empty($_SESSION['admin']);
  22. }
  23. /** Gatekeeper at the top of every admin page. */
  24. function auth_require(): void
  25. {
  26. if (!auth_check()) {
  27. redirect('login.php');
  28. }
  29. }
  30. function auth_throttle_file(): string
  31. {
  32. return DATA_DIR . '/login-throttle.json';
  33. }
  34. /** Seconds until login is allowed again, 0 if not locked. */
  35. function auth_locked_for(): int
  36. {
  37. $t = json_read(auth_throttle_file(), ['failures' => 0, 'last' => 0]);
  38. if (($t['failures'] ?? 0) < AUTH_MAX_FAILURES) {
  39. return 0;
  40. }
  41. $remaining = ($t['last'] ?? 0) + AUTH_LOCK_SECONDS - time();
  42. return max(0, $remaining);
  43. }
  44. function auth_attempt(string $username, string $password): bool
  45. {
  46. session_boot();
  47. if (auth_locked_for() > 0) {
  48. return false;
  49. }
  50. $cred = credentials_load();
  51. $ok = hash_equals($cred['username'], $username)
  52. && password_verify($password, $cred['password_hash']);
  53. if ($ok) {
  54. if (is_file(auth_throttle_file())) {
  55. @unlink(auth_throttle_file());
  56. }
  57. session_regenerate_id(true);
  58. $_SESSION['admin'] = true;
  59. return true;
  60. }
  61. $t = json_read(auth_throttle_file(), ['failures' => 0, 'last' => 0]);
  62. // A stale lock window restarts the count.
  63. if (time() - ($t['last'] ?? 0) > AUTH_LOCK_SECONDS) {
  64. $t['failures'] = 0;
  65. }
  66. $t['failures'] = ($t['failures'] ?? 0) + 1;
  67. $t['last'] = time();
  68. json_write(auth_throttle_file(), $t);
  69. return false;
  70. }
  71. function auth_logout(): void
  72. {
  73. session_boot();
  74. $_SESSION = [];
  75. session_destroy();
  76. }
  77. /**
  78. * Change the admin password: verifies the current one, then atomically
  79. * rewrites config/credentials.php. Returns an error message or null on success.
  80. */
  81. function auth_change_password(string $current, string $new): ?string
  82. {
  83. $cred = credentials_load();
  84. if (!password_verify($current, $cred['password_hash'])) {
  85. return 'Current password is incorrect.';
  86. }
  87. if (strlen($new) < 8) {
  88. return 'New password must be at least 8 characters.';
  89. }
  90. $cred['password_hash'] = password_hash($new, PASSWORD_DEFAULT);
  91. $file = CONFIG_DIR . '/credentials.php';
  92. $php = "<?php\n// Rewritten by the admin settings page on " . date('c') . "\n"
  93. . "return " . var_export($cred, true) . ";\n";
  94. $tmp = $file . '.' . bin2hex(random_bytes(6)) . '.tmp';
  95. if (file_put_contents($tmp, $php, LOCK_EX) === false || !rename($tmp, $file)) {
  96. @unlink($tmp);
  97. return 'Could not write credentials file — check that config/ is writable.';
  98. }
  99. if (function_exists('opcache_invalidate')) {
  100. @opcache_invalidate($file, true);
  101. }
  102. return null;
  103. }