CSRF.php

<?php

namespace Lia\Utility;


/**
 * Print and validate CSRF tokens for forms.
 */
class CSRF {

    /** 
     * Array of form_id:token pairs that have been successfully validated during this request.
     *
     * @key string concatenating form id & token: `form_id:token`
     * @value true, always true
     */
    static protected array $valid_requests = [];

    /**
     * Array of csrf entries that have been generated during this request. There can only be one for each form id.
     *
     * @key string form_id
     * @value array one csrf entry. See `get_csrf_value()` body for structure
     */  
    static protected array $generated_csrf = [];


    /**
     * Get a form input to use for CSRF protection. Print this in your form. Both the input's name and value are randomly generated.
     * The SESSION will be set with the CSRF Key/Value pair for the given form_id
     *
     * @param $form_id string indicating which form the csrf token is for.
     * @return string hidden html input tag with name & value set
     */
    static public function get_csrf_input(string $form_id, string $uri_path = '', int $expiry_minutes = 60): string {
        $key = static::get_csrf_key($form_id); 
        $value = static::get_csrf_value($form_id, $uri_path, $expiry_minutes);
        return '<input type="hidden" name="'.$key.'" value="'.$value.'">';
    }

    /** 
     * Check if a valid CSRF Key/Value pair is present within form_data for the given form_id
     *
     * @param $form_id string identifying the form for which CSRF is being validated
     * @param $form_data array, generally $_POST
     * @throw Exception with message `\Lia\Exception::CSRF_SESSION_NOT_STARTED` if session fails to start.
     * @throw Exception with message `sprintf(\Lia\Exception::CSRF_TOKEN_MISMATCH, $form_id)` (*this error should never happen and would indicate a bug in the CSRF class*)
     * @throw Exception with message `\Lia\Exception::CSRF_HTTPHOST_NOT_SET` if `$_SERVER['HTTP_HOST']` isn't set. This would likely be a server configuration issue.
     */
    static public function is_request_valid(string $form_id, array $form_data): bool {
        if (!isset($form_data['csrf-'.$form_id])){
            error_log(sprintf(\Lia\Exception::CSRF_TOKEN_NOT_SUBMITTED, $form_id));
            return false;
        }
        $token = $form_data['csrf-'.$form_id];
        if (isset(static::$valid_requests[$form_id.':'.$token]))return true;

        $session_key = 'lia-csrf:'.$form_id;

        if (session_status()==PHP_SESSION_NONE)session_start();
        if (session_status()!=PHP_SESSION_ACTIVE)throw new \Exception(\Lia\Exception::CSRF_SESSION_NOT_STARTED);

        if (!isset($_SESSION[$session_key][$token])
            ||$_SESSION[$session_key][$token] == null
        ){
            error_log(sprintf(\Lia\Exception::CSRF_TOKEN_NOT_IN_SESSION, $form_id));
            return false;
        }
        $csrf_entry = $_SESSION[$session_key][$token];
        unset($_SESSION[$session_key][$token]);

        if (time() > $csrf_entry['expires_at']){
            error_log(sprintf(\Lia\Exception::CSRF_TOKEN_EXPIRED, $form_id));
            return false;
        }

        if ($csrf_entry['value'] != $token
        ){
            throw new \Exception(sprintf(\Lia\Exception::CSRF_TOKEN_MISMATCH, $form_id));
        } else if ($csrf_entry['form_id'] != $form_id
        ){
            throw new \Exception(sprintf(\Lia\Exception::CSRF_FORMID_MISMATCH, $form_id));
        }

        $post_path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);


        if ($csrf_entry['uri_path'] != ''
            &&$csrf_entry['uri_path'] != $post_path
        ){
            error_log(sprintf(\Lia\Exception::CSRF_PATH_INCORRECT, $form_id, $csrf_entry['uri_path'], html_entity_decode($post_path??'')));
            return false;
        }



        if (!isset($_SERVER['HTTP_REFERER'])
            ||empty($_SERVER['HTTP_REFERER'])
        ){
            error_log(sprintf(\Lia\Exception::CSRF_NO_REFERER, $form_id, html_entity_decode($post_path??'')));
            return false;
        }
        $referer_domain = parse_url($_SERVER['HTTP_REFERER'], PHP_URL_HOST);

        if (!isset($_SERVER['HTTP_HOST'])
            ||empty($_SERVER['HTTP_HOST'])){
            throw new \Exception(\Lia\Exception::CSRF_HTTPHOST_NOT_SET);
        }

        // to remove the port (mainly bc of localhost testing)
        $server_host = parse_url($_SERVER['HTTP_HOST'], PHP_URL_HOST);
        if ($server_host==null)$server_host = $_SERVER['HTTP_HOST'];

        if ($referer_domain != $server_host){
            error_log(sprintf(\Lia\Exception::CSRF_REFERER_HOST_MISMATCH, $form_id, html_entity_decode($post_path??''), html_entity_decode($referer_domain??''), html_entity_decode($server_host??'')));
            return false;
        }

        static::$valid_requests[$form_id.':'.$token] = true;
        return true;
    }

    /**
     * Generate a CSRF entry for the given form_id and return the CSRF value. There can only be one CSRF token generated for each form id, and if it has already been generated, it will be returned.
     *
     * @param $form_id string identifying the form
     * @param $uri_path string URL path that this CSRF token is valid for. Use empty string to disable path-check
     * @param $expiry_minutes int number of minutes before the CSRF token expires (default 60 minutes) (*keep in mind, users may be slow to fill out forms, or may start filling out, walk away, and come back later to finish.*)
     *
     * @return string randomly generated CSRF token value
     *
     * @throw Exception with message `\Lia\Exception::CSRF_SESSION_NOT_STARTED` if session fails to start.
     */
    static public function get_csrf_value(string $form_id, string $uri_path = '', int $expiry_minutes = 60): ?string {
        if (isset(static::$generated_csrf[$form_id]))return static::$generated_csrf[$form_id]['value'];
        $session_key = 'lia-csrf:'.$form_id;
        $key = static::get_csrf_key($form_id); 


        if (session_status()==PHP_SESSION_NONE)session_start();
        if (session_status()!=PHP_SESSION_ACTIVE)throw new \Exception(\Lia\Exception::CSRF_SESSION_NOT_STARTED);

        $csrf_value = static::make_csrf_code();

        $csrf_meta = [
            'created' => time(),
            'expires_at' => time() + ($expiry_minutes * 60),
            'value' => $csrf_value,
            'uri_path' => $uri_path,
            'form_id' => $form_id,
        ];

        if (!isset($_SESSION[$session_key])
            || !is_array($_SESSION[$session_key])
        ){
            $_SESSION[$session_key] = [];
        }

        // allows the user to have multiple of the same form open and get different CSRF key/values for each
        $_SESSION[$session_key][$csrf_value] = $csrf_meta;
        static::$generated_csrf[$form_id] = $csrf_meta;
        
        return $csrf_value;
    }

    static public function get_csrf_key(string $form_id){
        return 'csrf-'.$form_id;
    }

    /** Generate a random CSRF value (*used both as key and value*)
     *
     * @return a string
     */
    static protected function make_csrf_code(): string {
        // this code from symfony csrf package: https://github.com/symfony/security-csrf/blob/5.4/TokenGenerator/UriSafeTokenGenerator.php
        $bytes = random_bytes(64);

        return rtrim(strtr(base64_encode($bytes), '+/', '-_'), '=');
    }

}