Globinours/app/Services/Auth.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) {
}
}
}