201 lines
7.3 KiB
PHP
201 lines
7.3 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
final class Auth
|
|
{
|
|
private static ?array $user = null;
|
|
public static function start(): void
|
|
{
|
|
if (session_status() === PHP_SESSION_ACTIVE) {
|
|
return;
|
|
}
|
|
ini_set('session.use_strict_mode', '1');
|
|
ini_set('session.use_only_cookies', '1');
|
|
ini_set('session.cookie_httponly', '1');
|
|
session_name('globinours_session');
|
|
session_set_cookie_params([
|
|
'httponly' => true,
|
|
'secure' => self::isSecureRequest(),
|
|
'samesite' => 'Strict',
|
|
'path' => '/',
|
|
]);
|
|
session_start();
|
|
$now = time();
|
|
$idle = max(300, (int) (getenv('GLOBINOURS_SESSION_IDLE') ?: 1800));
|
|
$absolute = max($idle, (int) (getenv('GLOBINOURS_SESSION_ABSOLUTE') ?: 43200));
|
|
if (
|
|
!empty($_SESSION['user_id']) &&
|
|
((isset($_SESSION['last_activity']) && $now - (int) $_SESSION['last_activity'] > $idle) ||
|
|
(isset($_SESSION['authenticated_at']) && $now - (int) $_SESSION['authenticated_at'] > $absolute))
|
|
) {
|
|
self::logout(false);
|
|
session_regenerate_id(true);
|
|
}
|
|
if (
|
|
empty($_SESSION['user_id']) &&
|
|
class_exists(TrustedDeviceService::class) &&
|
|
($id = TrustedDeviceService::authenticate())
|
|
) {
|
|
session_regenerate_id(true);
|
|
$_SESSION['user_id'] = $id;
|
|
$_SESSION['authenticated_at'] = $now;
|
|
}
|
|
if (!empty($_SESSION['user_id'])) {
|
|
$_SESSION['last_activity'] = $now;
|
|
}
|
|
}
|
|
public static function countUsers(): int
|
|
{
|
|
return (int) DB::pdo()->query('SELECT COUNT(*) FROM users')->fetchColumn();
|
|
}
|
|
public static function user(): ?array
|
|
{
|
|
if (self::$user !== null) {
|
|
return self::$user;
|
|
}
|
|
$id = (int) ($_SESSION['user_id'] ?? 0);
|
|
if (!$id) {
|
|
return null;
|
|
}
|
|
$s = DB::pdo()->prepare(
|
|
'SELECT id,username,display_name,role,active,last_login_at,animal_list_view,agenda_view FROM users WHERE id=:id AND active=1',
|
|
);
|
|
$s->execute([':id' => $id]);
|
|
$u = $s->fetch(PDO::FETCH_ASSOC);
|
|
if (!$u) {
|
|
self::logout(false);
|
|
return null;
|
|
}
|
|
return self::$user = $u;
|
|
}
|
|
public static function id(): ?int
|
|
{
|
|
return ($u = self::user()) ? (int) $u['id'] : null;
|
|
}
|
|
public static function login(
|
|
string $username,
|
|
string $password,
|
|
bool $remember = false,
|
|
string $deviceName = '',
|
|
): bool {
|
|
$db = DB::pdo();
|
|
$username = trim($username);
|
|
$ip = mb_substr((string) ($_SERVER['REMOTE_ADDR'] ?? ''), 0, 64);
|
|
$db->exec("DELETE FROM login_attempts WHERE attempted_at<datetime('now','-7 days')");
|
|
$rate = $db->prepare(
|
|
"SELECT COUNT(*) FROM login_attempts WHERE succeeded=0 AND ip_address=? AND attempted_at>=datetime('now','-15 minutes')",
|
|
);
|
|
$rate->execute([$ip ?: null]);
|
|
if ((int) $rate->fetchColumn() >= 20) {
|
|
self::recordAttempt($username, $ip, false);
|
|
return false;
|
|
}
|
|
$s = $db->prepare('SELECT * FROM users WHERE lower(username)=lower(:u) LIMIT 1');
|
|
$s->execute([':u' => $username]);
|
|
$u = $s->fetch(PDO::FETCH_ASSOC);
|
|
$valid =
|
|
$u &&
|
|
(int) $u['active'] === 1 &&
|
|
(empty($u['locked_until']) || strtotime($u['locked_until']) <= time()) &&
|
|
password_verify($password, (string) $u['password_hash']);
|
|
if (!$u) {
|
|
password_verify($password, '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2uheWG/igi.');
|
|
}
|
|
if (!$valid) {
|
|
if ($u) {
|
|
$failed = (int) $u['failed_attempts'] + 1;
|
|
$lock = $failed >= 5 ? date('Y-m-d H:i:s', time() + 900) : null;
|
|
$db->prepare('UPDATE users SET failed_attempts=:f,locked_until=:l WHERE id=:id')->execute([
|
|
':f' => $failed,
|
|
':l' => $lock,
|
|
':id' => $u['id'],
|
|
]);
|
|
}
|
|
self::recordAttempt($username, $ip, false);
|
|
return false;
|
|
}
|
|
session_regenerate_id(true);
|
|
$_SESSION['user_id'] = (int) $u['id'];
|
|
$_SESSION['authenticated_at'] = time();
|
|
$_SESSION['last_activity'] = time();
|
|
self::$user = null;
|
|
$db->prepare(
|
|
"UPDATE users SET failed_attempts=0,locked_until=NULL,last_login_at=datetime('now') WHERE id=:id",
|
|
)->execute([':id' => $u['id']]);
|
|
self::recordAttempt($username, $ip, true);
|
|
if ($remember && class_exists(TrustedDeviceService::class)) {
|
|
TrustedDeviceService::create((int) $u['id'], $deviceName);
|
|
}
|
|
return true;
|
|
}
|
|
public static function logout(bool $destroy = true): void
|
|
{
|
|
if ($destroy && class_exists(TrustedDeviceService::class)) {
|
|
TrustedDeviceService::revokeCurrent();
|
|
}
|
|
self::$user = null;
|
|
unset($_SESSION['user_id']);
|
|
if ($destroy) {
|
|
$_SESSION = [];
|
|
if (ini_get('session.use_cookies')) {
|
|
$p = session_get_cookie_params();
|
|
setcookie(
|
|
session_name(),
|
|
'',
|
|
time() - 42000,
|
|
$p['path'],
|
|
$p['domain'] ?? '',
|
|
(bool) $p['secure'],
|
|
(bool) $p['httponly'],
|
|
);
|
|
}
|
|
session_destroy();
|
|
}
|
|
}
|
|
public static function csrf(): string
|
|
{
|
|
if (empty($_SESSION['csrf'])) {
|
|
$_SESSION['csrf'] = bin2hex(random_bytes(32));
|
|
}
|
|
return (string) $_SESSION['csrf'];
|
|
}
|
|
public static function validCsrf(?string $token): bool
|
|
{
|
|
return is_string($token) && hash_equals(self::csrf(), $token);
|
|
}
|
|
public static function is(string $role): bool
|
|
{
|
|
return (self::user()['role'] ?? '') === $role;
|
|
}
|
|
public static function canWrite(string $path): bool
|
|
{
|
|
$module = PermissionService::moduleForPath($path);
|
|
return $module === null || PermissionService::can($module, 'edit');
|
|
}
|
|
public static function isSecureRequest(): bool
|
|
{
|
|
if (!empty($_SERVER['HTTPS']) && strtolower((string) $_SERVER['HTTPS']) !== 'off') {
|
|
return true;
|
|
}
|
|
if (str_starts_with(strtolower((string) getenv('APP_URL')), 'https://')) {
|
|
return true;
|
|
}
|
|
$remote = (string) ($_SERVER['REMOTE_ADDR'] ?? '');
|
|
$trusted = array_filter(array_map('trim', explode(',', (string) getenv('GLOBINOURS_TRUSTED_PROXIES'))));
|
|
if (in_array($remote, ['127.0.0.1', '::1'], true) || in_array($remote, $trusted, true)) {
|
|
return strtolower(trim(explode(',', (string) ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? ''))[0] ?? '')) ===
|
|
'https';
|
|
}
|
|
return false;
|
|
}
|
|
private static function recordAttempt(string $username, string $ip, bool $success): void
|
|
{
|
|
try {
|
|
DB::pdo()
|
|
->prepare('INSERT INTO login_attempts(username_hash,ip_address,succeeded) VALUES(?,?,?)')
|
|
->execute([hash('sha256', mb_strtolower($username)), $ip ?: null, $success ? 1 : 0]);
|
|
} catch (Throwable) {
|
|
}
|
|
}
|
|
}
|