386 lines
11 KiB
PHP
386 lines
11 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
require_once __DIR__ . '/../db.php';
|
|
|
|
const AUTH_SESSION_NAME = 'erp_naurua_session';
|
|
const AUTH_LOGIN_RETURN_TO_KEY = 'auth_return_to';
|
|
const AUTH_CSRF_TOKEN_KEY = 'auth_csrf_token';
|
|
const AUTH_SESSION_USER_ID_KEY = 'auth_user_id';
|
|
const AUTH_FAILED_LOGIN_LIMIT = 5;
|
|
const AUTH_LOCK_DURATION_SECONDS = 900;
|
|
|
|
function auth_is_https_request(): bool
|
|
{
|
|
if (!empty($_SERVER['HTTPS']) && strtolower((string) $_SERVER['HTTPS']) !== 'off') {
|
|
return true;
|
|
}
|
|
|
|
$forwardedProto = strtolower((string) ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? ''));
|
|
if ($forwardedProto === 'https') {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function auth_bootstrap_session(): void
|
|
{
|
|
if (session_status() === PHP_SESSION_ACTIVE) {
|
|
return;
|
|
}
|
|
|
|
session_name(AUTH_SESSION_NAME);
|
|
session_set_cookie_params([
|
|
'lifetime' => 0,
|
|
'path' => '/',
|
|
'domain' => '',
|
|
'secure' => auth_is_https_request(),
|
|
'httponly' => true,
|
|
'samesite' => 'Lax',
|
|
]);
|
|
ini_set('session.use_only_cookies', '1');
|
|
ini_set('session.use_strict_mode', '1');
|
|
ini_set('session.use_trans_sid', '0');
|
|
session_start();
|
|
|
|
if (!isset($_SESSION[AUTH_CSRF_TOKEN_KEY]) || !is_string($_SESSION[AUTH_CSRF_TOKEN_KEY]) || $_SESSION[AUTH_CSRF_TOKEN_KEY] === '') {
|
|
$_SESSION[AUTH_CSRF_TOKEN_KEY] = bin2hex(random_bytes(32));
|
|
}
|
|
}
|
|
|
|
function auth_csrf_token(): string
|
|
{
|
|
auth_bootstrap_session();
|
|
return (string) $_SESSION[AUTH_CSRF_TOKEN_KEY];
|
|
}
|
|
|
|
function auth_validate_csrf_token(?string $token): bool
|
|
{
|
|
auth_bootstrap_session();
|
|
if (!is_string($token) || $token === '') {
|
|
return false;
|
|
}
|
|
|
|
return hash_equals((string) $_SESSION[AUTH_CSRF_TOKEN_KEY], $token);
|
|
}
|
|
|
|
function auth_escape_html(string $value): string
|
|
{
|
|
return htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
|
|
}
|
|
|
|
function auth_normalize_identifier(string $identifier): string
|
|
{
|
|
return trim($identifier);
|
|
}
|
|
|
|
function auth_normalize_return_to(?string $returnTo): string
|
|
{
|
|
$candidate = trim((string) $returnTo);
|
|
if ($candidate === '' || str_starts_with($candidate, '//') || str_contains($candidate, '://')) {
|
|
return '/';
|
|
}
|
|
|
|
if (!str_starts_with($candidate, '/')) {
|
|
return '/';
|
|
}
|
|
|
|
return $candidate;
|
|
}
|
|
|
|
function auth_set_return_to(string $returnTo): void
|
|
{
|
|
auth_bootstrap_session();
|
|
$_SESSION[AUTH_LOGIN_RETURN_TO_KEY] = auth_normalize_return_to($returnTo);
|
|
}
|
|
|
|
function auth_take_return_to(): string
|
|
{
|
|
auth_bootstrap_session();
|
|
$returnTo = auth_normalize_return_to($_SESSION[AUTH_LOGIN_RETURN_TO_KEY] ?? '/');
|
|
unset($_SESSION[AUTH_LOGIN_RETURN_TO_KEY]);
|
|
return $returnTo;
|
|
}
|
|
|
|
function auth_ensure_schema(PDO $pdo): void
|
|
{
|
|
$requiredColumns = [
|
|
'id',
|
|
'username',
|
|
'email',
|
|
'password_hash',
|
|
'role',
|
|
'is_active',
|
|
'failed_login_count',
|
|
'locked_until',
|
|
'last_login_at',
|
|
'created_by_user_id',
|
|
'updated_by_user_id',
|
|
'created_at',
|
|
'updated_at',
|
|
];
|
|
|
|
$stmt = $pdo->query(
|
|
"SELECT column_name
|
|
FROM information_schema.columns
|
|
WHERE table_schema = 'public'
|
|
AND table_name = 'system_user'"
|
|
);
|
|
$columns = $stmt->fetchAll(PDO::FETCH_COLUMN);
|
|
$existing = array_map('strval', $columns ?: []);
|
|
$missing = array_values(array_diff($requiredColumns, $existing));
|
|
|
|
if ($missing !== []) {
|
|
throw new RuntimeException('DB schema not initialized. Missing auth columns: ' . implode(', ', $missing));
|
|
}
|
|
}
|
|
|
|
function auth_fetch_user_row_by_id(PDO $pdo, int $userId): ?array
|
|
{
|
|
$stmt = $pdo->prepare(
|
|
'SELECT id, username, email, role, is_active, locked_until, last_login_at, created_at, updated_at
|
|
FROM public.system_user
|
|
WHERE id = :id
|
|
LIMIT 1'
|
|
);
|
|
$stmt->execute([':id' => $userId]);
|
|
$row = $stmt->fetch();
|
|
|
|
return is_array($row) ? $row : null;
|
|
}
|
|
|
|
function auth_current_user(PDO $pdo): ?array
|
|
{
|
|
auth_bootstrap_session();
|
|
|
|
$userId = $_SESSION[AUTH_SESSION_USER_ID_KEY] ?? null;
|
|
if (!is_int($userId) && !ctype_digit((string) $userId)) {
|
|
return null;
|
|
}
|
|
|
|
$user = auth_fetch_user_row_by_id($pdo, (int) $userId);
|
|
if ($user === null) {
|
|
unset($_SESSION[AUTH_SESSION_USER_ID_KEY]);
|
|
return null;
|
|
}
|
|
|
|
if (!((bool) $user['is_active'])) {
|
|
unset($_SESSION[AUTH_SESSION_USER_ID_KEY]);
|
|
return null;
|
|
}
|
|
|
|
$lockedUntil = $user['locked_until'] ?? null;
|
|
if ($lockedUntil !== null && $lockedUntil !== '') {
|
|
try {
|
|
$lockedUntilAt = new DateTimeImmutable((string) $lockedUntil);
|
|
if ($lockedUntilAt > new DateTimeImmutable('now')) {
|
|
unset($_SESSION[AUTH_SESSION_USER_ID_KEY]);
|
|
return null;
|
|
}
|
|
} catch (Throwable) {
|
|
unset($_SESSION[AUTH_SESSION_USER_ID_KEY]);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
return $user;
|
|
}
|
|
|
|
function auth_require_user(PDO $pdo): array
|
|
{
|
|
$user = auth_current_user($pdo);
|
|
if ($user !== null) {
|
|
return $user;
|
|
}
|
|
|
|
$requestUri = (string) ($_SERVER['REQUEST_URI'] ?? '/');
|
|
auth_set_return_to($requestUri);
|
|
header('Location: /');
|
|
exit;
|
|
}
|
|
|
|
function auth_find_user_by_identifier(PDO $pdo, string $identifier): ?array
|
|
{
|
|
$stmt = $pdo->prepare(
|
|
'SELECT id, username, email, password_hash, role, is_active, failed_login_count, locked_until
|
|
FROM public.system_user
|
|
WHERE LOWER(username) = LOWER(:identifier_username)
|
|
OR LOWER(email) = LOWER(:identifier_email)
|
|
LIMIT 1
|
|
FOR UPDATE'
|
|
);
|
|
$stmt->execute([
|
|
':identifier_username' => $identifier,
|
|
':identifier_email' => $identifier,
|
|
]);
|
|
$row = $stmt->fetch();
|
|
|
|
return is_array($row) ? $row : null;
|
|
}
|
|
|
|
function auth_login(PDO $pdo, string $identifier, string $password): array
|
|
{
|
|
auth_ensure_schema($pdo);
|
|
auth_bootstrap_session();
|
|
|
|
$normalizedIdentifier = auth_normalize_identifier($identifier);
|
|
$password = (string) $password;
|
|
$errors = [
|
|
'identifier' => null,
|
|
'password' => null,
|
|
];
|
|
|
|
if ($normalizedIdentifier === '') {
|
|
$errors['identifier'] = 'Bitte Benutzername oder E-Mail eingeben.';
|
|
}
|
|
|
|
if ($password === '') {
|
|
$errors['password'] = 'Bitte Passwort eingeben.';
|
|
}
|
|
|
|
if ($errors['identifier'] !== null || $errors['password'] !== null) {
|
|
return [
|
|
'ok' => false,
|
|
'errors' => $errors,
|
|
];
|
|
}
|
|
|
|
$pdo->beginTransaction();
|
|
|
|
try {
|
|
$user = auth_find_user_by_identifier($pdo, $normalizedIdentifier);
|
|
if ($user === null) {
|
|
$pdo->commit();
|
|
|
|
return [
|
|
'ok' => false,
|
|
'errors' => [
|
|
'identifier' => 'Benutzername oder E-Mail ist unbekannt.',
|
|
'password' => null,
|
|
],
|
|
];
|
|
}
|
|
|
|
$userId = (int) $user['id'];
|
|
if (!((bool) $user['is_active'])) {
|
|
$pdo->commit();
|
|
|
|
return [
|
|
'ok' => false,
|
|
'errors' => [
|
|
'identifier' => 'Benutzerkonto ist deaktiviert.',
|
|
'password' => null,
|
|
],
|
|
];
|
|
}
|
|
|
|
$lockedUntil = $user['locked_until'] ?? null;
|
|
if ($lockedUntil !== null && $lockedUntil !== '') {
|
|
try {
|
|
$lockedUntilAt = new DateTimeImmutable((string) $lockedUntil);
|
|
if ($lockedUntilAt > new DateTimeImmutable('now')) {
|
|
$pdo->commit();
|
|
|
|
return [
|
|
'ok' => false,
|
|
'errors' => [
|
|
'identifier' => 'Benutzerkonto ist vorübergehend gesperrt. Bitte später erneut versuchen.',
|
|
'password' => null,
|
|
],
|
|
];
|
|
}
|
|
} catch (Throwable) {
|
|
$pdo->commit();
|
|
|
|
return [
|
|
'ok' => false,
|
|
'errors' => [
|
|
'identifier' => 'Benutzerkonto ist vorübergehend gesperrt. Bitte später erneut versuchen.',
|
|
'password' => null,
|
|
],
|
|
];
|
|
}
|
|
}
|
|
|
|
if (!password_verify($password, (string) $user['password_hash'])) {
|
|
$failedCount = (int) ($user['failed_login_count'] ?? 0) + 1;
|
|
$lockedUntilSql = null;
|
|
if ($failedCount >= AUTH_FAILED_LOGIN_LIMIT) {
|
|
$lockedUntilSql = 'NOW() + INTERVAL \'' . AUTH_LOCK_DURATION_SECONDS . ' seconds\'';
|
|
}
|
|
|
|
$updateSql = 'UPDATE public.system_user
|
|
SET failed_login_count = :failed_login_count,
|
|
locked_until = ' . ($lockedUntilSql !== null ? $lockedUntilSql : 'NULL') . ',
|
|
updated_at = NOW(),
|
|
updated_by_user_id = NULL
|
|
WHERE id = :id';
|
|
$stmt = $pdo->prepare($updateSql);
|
|
$stmt->execute([
|
|
':failed_login_count' => $failedCount,
|
|
':id' => $userId,
|
|
]);
|
|
|
|
$pdo->commit();
|
|
|
|
return [
|
|
'ok' => false,
|
|
'errors' => [
|
|
'identifier' => null,
|
|
'password' => 'Passwort ist falsch.',
|
|
],
|
|
];
|
|
}
|
|
|
|
$stmt = $pdo->prepare(
|
|
'UPDATE public.system_user
|
|
SET failed_login_count = 0,
|
|
locked_until = NULL,
|
|
last_login_at = NOW(),
|
|
updated_at = NOW(),
|
|
updated_by_user_id = NULL
|
|
WHERE id = :id'
|
|
);
|
|
$stmt->execute([':id' => $userId]);
|
|
|
|
session_regenerate_id(true);
|
|
$_SESSION[AUTH_SESSION_USER_ID_KEY] = $userId;
|
|
$pdo->commit();
|
|
|
|
return [
|
|
'ok' => true,
|
|
'errors' => [
|
|
'identifier' => null,
|
|
'password' => null,
|
|
],
|
|
];
|
|
} catch (Throwable $exception) {
|
|
if ($pdo->inTransaction()) {
|
|
$pdo->rollBack();
|
|
}
|
|
|
|
throw $exception;
|
|
}
|
|
}
|
|
|
|
function auth_logout(): void
|
|
{
|
|
auth_bootstrap_session();
|
|
|
|
$_SESSION = [];
|
|
|
|
if (ini_get('session.use_cookies')) {
|
|
$params = session_get_cookie_params();
|
|
setcookie(session_name(), '', time() - 42000, [
|
|
'path' => $params['path'] ?? '/',
|
|
'domain' => $params['domain'] ?? '',
|
|
'secure' => (bool) ($params['secure'] ?? false),
|
|
'httponly' => (bool) ($params['httponly'] ?? true),
|
|
'samesite' => $params['samesite'] ?? 'Lax',
|
|
]);
|
|
}
|
|
|
|
session_destroy();
|
|
}
|