Files
erp_naurua/modules/shared/auth/service.php
T

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();
}