Add shared auth login flow
This commit is contained in:
@@ -0,0 +1,385 @@
|
||||
<?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();
|
||||
}
|
||||
Reference in New Issue
Block a user