web - refactor core modules, harden authorization, simplify endpoints

This commit is contained in:
2026-08-29 11:15:54 +02:00
parent e1109a07ef
commit 79d857d686
71 changed files with 8269 additions and 9119 deletions
@@ -0,0 +1,313 @@
<?php
const AUTH_RESET_TTL_SECONDS = 600;
const AUTH_RESET_MAX_ATTEMPTS = 5;
const AUTH_DUMMY_PASSWORD_HASH = '$2y$12$7v2tTTu3R6UkMcX7N9FvJeetK1LEkkkFe/qbj./2EdWVKKT0LS7RS';
function authorization_start_session(): void
{
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
}
function authorization_send_security_headers(): void
{
header('Cache-Control: no-store, max-age=0');
header('Pragma: no-cache');
header('Referrer-Policy: no-referrer');
header('X-Content-Type-Options: nosniff');
header('X-Frame-Options: DENY');
}
function authorization_html($value): string
{
return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
}
function authorization_csrf_token(): string
{
authorization_start_session();
if (empty($_SESSION['authorization_csrf'])) {
$_SESSION['authorization_csrf'] = bin2hex(random_bytes(32));
}
return (string) $_SESSION['authorization_csrf'];
}
function authorization_csrf_is_valid($postedToken): bool
{
authorization_start_session();
$sessionToken = $_SESSION['authorization_csrf'] ?? '';
return is_string($postedToken)
&& is_string($sessionToken)
&& $sessionToken !== ''
&& hash_equals($sessionToken, $postedToken);
}
function authorization_redirect(string $location, int $status = 303): never
{
header('Location: ' . $location, true, $status);
exit;
}
function authorization_password_error(string $password, ?string $confirmation = null): ?string
{
if (strlen($password) < 8 || !preg_match('/[a-z]/i', $password) || !preg_match('/[0-9]/', $password)) {
return 'Passwort muss mindestens 8 Zeichen, einen Buchstaben und eine Zahl enthalten.';
}
if ($confirmation !== null && !hash_equals($password, $confirmation)) {
return 'Passwörter stimmen nicht überein.';
}
return null;
}
function authorization_normalize_phone(string $phone): string
{
return preg_replace('/\D+/', '', trim($phone)) ?? '';
}
function authorization_registration_input(array $input): array
{
return [
'first_name' => trim((string) ($input['first_name'] ?? '')),
'last_name' => trim((string) ($input['last_name'] ?? '')),
'email' => trim((string) ($input['email'] ?? '')),
'phone' => authorization_normalize_phone((string) ($input['phone'] ?? '')),
'password' => (string) ($input['password'] ?? ''),
'password_confirmation' => (string) ($input['password_confirmation'] ?? ''),
];
}
function authorization_text_length(string $value): int
{
return function_exists('mb_strlen') ? mb_strlen($value) : strlen($value);
}
function authorization_registration_error(array $input): ?string
{
if ($input['first_name'] === '' || authorization_text_length($input['first_name']) > 100) {
return 'Bitte geben Sie einen gültigen Vornamen ein.';
}
if ($input['last_name'] === '' || authorization_text_length($input['last_name']) > 100) {
return 'Bitte geben Sie einen gültigen Nachnamen ein.';
}
if (strlen($input['email']) > 254 || !filter_var($input['email'], FILTER_VALIDATE_EMAIL)) {
return 'Bitte geben Sie eine gültige E-Mail-Adresse ein.';
}
if (!preg_match('/^[1-9][0-9]{5,14}$/', $input['phone'])) {
return 'Bitte geben Sie eine gültige Telefonnummer ohne führende 0 ein.';
}
return authorization_password_error($input['password'], $input['password_confirmation']);
}
function authorization_authenticate(PDO $pdo, string $email, string $password): ?int
{
$stmt = $pdo->prepare(
'SELECT u.user_id, l.password_hash '
. 'FROM user_general_information u '
. 'INNER JOIN user_login l ON l.user_id = u.user_id '
. 'WHERE LOWER(u.mail) = LOWER(?) '
. 'LIMIT 1'
);
$stmt->execute([trim($email)]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
$storedHash = is_array($user) && is_string($user['password_hash'] ?? null)
? $user['password_hash']
: AUTH_DUMMY_PASSWORD_HASH;
$valid = password_verify($password, $storedHash);
if (!$valid || !is_array($user)) {
return null;
}
$userId = (int) $user['user_id'];
if ($userId <= 0) {
return null;
}
if (password_needs_rehash($storedHash, PASSWORD_DEFAULT)) {
$newHash = password_hash($password, PASSWORD_DEFAULT);
$update = $pdo->prepare('UPDATE user_login SET password_hash = ? WHERE user_id = ?');
$update->execute([$newHash, $userId]);
}
return $userId;
}
/**
* @return array{status:string,pending_id?:int,token?:string}
*/
function authorization_register(PDO $pdo, array $input): array
{
$passwordHash = password_hash($input['password'], PASSWORD_DEFAULT);
$email = $input['email'];
$pdo->beginTransaction();
try {
// Serialisiert parallele Registrierungsversuche derselben E-Mail ohne neues Schema.
$lock = $pdo->prepare('SELECT pg_advisory_xact_lock(hashtext(LOWER(?)))');
$lock->execute([$email]);
$stmt = $pdo->prepare(
'SELECT user_id FROM user_general_information '
. 'WHERE LOWER(mail) = LOWER(?) LIMIT 1 FOR UPDATE'
);
$stmt->execute([$email]);
$userId = (int) ($stmt->fetchColumn() ?: 0);
if ($userId > 0) {
$stmt = $pdo->prepare('SELECT 1 FROM user_login WHERE user_id = ? LIMIT 1');
$stmt->execute([$userId]);
if ($stmt->fetchColumn()) {
$pdo->commit();
return ['status' => 'existing'];
}
$stmt = $pdo->prepare('INSERT INTO user_login (user_id, password_hash) VALUES (?, ?)');
$stmt->execute([$userId, $passwordHash]);
$stmt = $pdo->prepare(
'UPDATE user_general_information '
. 'SET first_name = ?, last_name = ?, phone = ? WHERE user_id = ?'
);
$stmt->execute([$input['first_name'], $input['last_name'], $input['phone'], $userId]);
$pdo->commit();
return ['status' => 'activated'];
}
$stmt = $pdo->prepare(
"SELECT id FROM pending_registrations WHERE LOWER(email) = LOWER(?) AND status = 'pending' LIMIT 1 FOR UPDATE"
);
$stmt->execute([$email]);
if ($stmt->fetchColumn()) {
$pdo->commit();
return ['status' => 'existing'];
}
// Token bleibt für Schema-/Admin-Kompatibilität erhalten, wird aber nicht mehr in admin_messages dupliziert.
$token = bin2hex(random_bytes(32));
$stmt = $pdo->prepare(
'INSERT INTO pending_registrations '
. '(first_name, last_name, email, phone, password_hash, token) '
. 'VALUES (?, ?, ?, ?, ?, ?)'
);
$stmt->execute([
$input['first_name'],
$input['last_name'],
$email,
$input['phone'],
$passwordHash,
$token,
]);
$pendingId = (int) $pdo->lastInsertId();
$subject = "Neue Registrierungsanfrage: {$input['first_name']} {$input['last_name']}";
$message = "Neue Registrierungsanfrage:\n\n"
. "Name: {$input['first_name']} {$input['last_name']}\n"
. "E-Mail: {$email}\n\n"
. 'Bitte im Adminpanel freischalten.';
$dataJson = json_encode(['pending_id' => $pendingId], JSON_THROW_ON_ERROR);
$senderName = trim($input['first_name'] . ' ' . $input['last_name']);
$stmt = $pdo->prepare(
"INSERT INTO admin_messages (type, sender_name, sender_email, subject, message, data_json) "
. "VALUES ('registration', ?, ?, ?, ?, ?)"
);
$stmt->execute([$senderName, $email, $subject, $message, $dataJson]);
$pdo->commit();
return ['status' => 'pending', 'pending_id' => $pendingId];
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $e;
}
}
function authorization_public_url(string $path): string
{
require_once $_SERVER['DOCUMENT_ROOT'] . '/intranet_repo/general_files/ini/runtime_env.php';
$host = intranet_env('INTRANET_FQDN');
return 'https://' . $host . '/' . ltrim($path, '/');
}
function authorization_clear_reset_state(): void
{
authorization_start_session();
unset(
$_SESSION['auth_reset_user_id'],
$_SESSION['auth_reset_started_at'],
$_SESSION['auth_reset_attempts'],
$_SESSION['auth_reset_authorized_until']
);
}
function authorization_reset_request_is_active(): bool
{
authorization_start_session();
$startedAt = (int) ($_SESSION['auth_reset_started_at'] ?? 0);
return $startedAt > 0 && ($startedAt + AUTH_RESET_TTL_SECONDS) >= time();
}
function authorization_reset_is_authorized(): bool
{
authorization_start_session();
$userId = (int) ($_SESSION['auth_reset_user_id'] ?? 0);
$authorizedUntil = (int) ($_SESSION['auth_reset_authorized_until'] ?? 0);
return $userId > 0 && $authorizedUntil >= time();
}
function authorization_masked_reset_message(): string
{
return 'Wenn ein passendes Konto vorhanden ist, wurde ein 6-stelliger Bestätigungscode an die hinterlegten Kontaktwege gesendet.';
}
function authorization_send_reset_sms(string $phone, string $otp): bool
{
if ($phone === '') {
return false;
}
require_once $_SERVER['DOCUMENT_ROOT'] . '/intranet_repo/general_files/ini/runtime_env.php';
$payload = json_encode([
'phone' => '+49' . $phone,
'message' => "Intranet NAK Reinickendorf\n\nIhr Bestaetigungscode: {$otp}\n\nDer Code ist 10 Minuten gueltig.",
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($payload === false) {
return false;
}
$context = stream_context_create([
'http' => [
'header' => "Content-Type: application/json; charset=utf-8\r\n",
'method' => 'POST',
'content' => $payload,
'timeout' => 10,
'ignore_errors' => true,
],
]);
try {
$gatewayUrl = intranet_env('SMS_GATEWAY_URL');
} catch (RuntimeException $e) {
error_log('SMS gateway unavailable: ' . $e->getMessage());
return false;
}
$result = @file_get_contents(rtrim($gatewayUrl, '/') . '/send_sms', false, $context);
if ($result === false) {
return false;
}
$statusLine = $http_response_header[0] ?? '';
return preg_match('/\s2\d\d\s/', $statusLine) === 1;
}