web - refactor core modules, harden authorization, simplify endpoints
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user