<?php
/**
 * CSRF-Schutz nach dem Synchronizer-Token-Pattern.
 *
 * Pro Session wird ein einziges, zufälliges Token erzeugt,
 * das in jedem Formular als verstecktes Feld mitgeschickt und bei jeder
 * zustandsändernden Anfrage serverseitig mit dem Session-Token verglichen
 * wird.
 *
 * @author Niklas Ortmann
 */

if (session_status() === PHP_SESSION_NONE) {
    session_start();
}

/**
 * Gibt das aktuelle CSRF-Token der Session zurück.
 *
 * Existiert noch kein Token, wird eines erzeugt und in der Session
 * gespeichert.
 *
 * @return string Das CSRF-Token
 */
function csrf_token(): string
{
    if (empty($_SESSION["csrf_token"]) || !is_string($_SESSION["csrf_token"])) {
        $_SESSION["csrf_token"] = bin2hex(random_bytes(32));
    }

    return $_SESSION["csrf_token"];
}

/**
 * Gibt ein verstecktes Formularfeld mit dem aktuellen CSRF-Token aus.
 *
 * Wird in jedem Formular benötigt, das eine zustandsändernde
 * Aktion auslöst.
 *
 * @return void
 */
function csrf_field(): void
{
    echo '<input type="hidden" name="csrf_token" value="'
        . htmlspecialchars(csrf_token())
        . '">';
}

/**
 * Prüft, ob das per POST gesendete CSRF-Token zum Session-Token passt.
 *
 * Der Vergleich erfolgt zeitkonstant über hash_equals(), um
 * Timing-Angriffe auf den Vergleich selbst auszuschließen.
 *
 * @return bool true, wenn das Token gültig ist
 */
function csrf_verify(): bool
{
    $sentToken = $_POST["csrf_token"] ?? "";
    $sessionToken = $_SESSION["csrf_token"] ?? "";

    if (!is_string($sentToken) || $sentToken === "" || $sessionToken === "") {
        return false;
    }

    return hash_equals($sessionToken, $sentToken);
}

/**
 * Bricht die Anfrage ab und leitet mit einer Fehlermeldung um,
 * wenn das mitgesendete CSRF-Token ungültig oder nicht vorhanden ist.
 *
 * Muss am Anfang jeder zustandsändernden POST-Aktion aufgerufen werden,
 * bevor irgendeine Änderung an Daten vorgenommen wird.
 *
 * @param string $redirectTo Ziel-URL, zu der bei ungültigem Token
 *                           weitergeleitet wird
 * @return void
 */
function csrf_require_valid(string $redirectTo = "index.php"): void
{
    if (!csrf_verify()) {
        http_response_code(403);
        $_SESSION["message"] = "invalid_csrf_token";
        header("Location: " . $redirectTo);
        exit();
    }
}