toscho.design

Authentifizierung mit PHP und UTF-8

Beim einfachen Paßwortschutz mit den Bordmitteln des Webservers Apache – den Dateien .htpasswd und .htaccess – stößt man bald auf ein sehr ekelhaftes Problem: Umlaute kodiert jeder Browser anders.
Und man kann per HTTP keine Kodierung vorschlagen, denn die Authentifzierungsheader werden früher versandt als die Angabe des Content-Typs, der ja erst im Charset-Parameter die Zeichenkodierung angibt.

Opera benutzt UTF-8, Firefox und Internet Explorer aber verwenden die Zeichenkodierung, die als lokaler Rückfallwert eingestellt wurde, auf einem deutschsprachigen System also meistens ISO-8859-1.

Der Apache kann – soweit ich weiß – nicht automatisch erkennen, ob eine Zeichenkette in UTF-8 kodiert wurde. Und diese Kette entsprechend umzuwandeln geht dann vollends über seine Fähigkeit und Kompetenz hinaus.

Mit PHP hingegen ist das sehr leicht möglich, wenn es als Modul arbeitet und nicht per CGI.
Hier will ich mal eine einfache Methode vorstellen, Umlaute in Benutzernamen und Paßwörtern zu erlauben. Ich habe diesen Code 2005 geschrieben; etwas später habe ich ihn auf PHP 5 umgestellt, und seither verwende ich ihn immer wieder in kleinen Projekten.
Die Funktionen is_utf8_compatible() und force_utf8() sollten in einer separaten Klasse liegen; ich habe sie hier nur der Übersichtlichkeit halber direkt in die Authentifizierungsklasse gesetzt.
Im ZIP-Archiv liegt auch eine Datei, die den praktischen Einsatz demonstriert.

Um die Diskussion zu erleichtern, sei hier der Quellcode im Original wiedergegeben:

class HTTP_Auth

<?php
/**
 * Sendet Authentifizierungsheader
 *
 * @category HTTP
 * @author   Thomas Scholz <http://toscho.de>
 * @version  1.0.1 (10.06.2009)
 * @access   public
 */
class HTTP_Auth {
    /**
     * Texte
     */
    private $messages = array(
        'realm'          => 'Passwortschutz!',
        'wrong_name'     => 'Der Nutzername stimmt nicht.',
        'no_password'    => 'Bitte geben Sie Ihr Passwort ein.',
        'wrong_password' => 'Das Passwort stimmt nicht.',
        'login_failed'   => 'Tja, Pech!<br>Zur <a href="/">Startseite</a>.</p>'
        );
    /* Constructor */
    public function __construct()
    {
        /* Nichts zu tun. */
    }
    public function set_message($name, $text)
    {
        $this->messages[$name] = $text;
    }
    /**
     * Prüft Nutzernamen und -paßwort
     * @param array $array_users Array aus name => password je in md5()
     * @return boolean
     */
    public function is_authuser($array_users)
    {
        if ( !isset ( $_SERVER['PHP_AUTH_USER'] ) ) 
        {
            $this->require_auth($this->messages['realm']);
        } 
        else 
        {
            $name = md5( $this->force_utf8($_SERVER['PHP_AUTH_USER']) );
            if ( !array_key_exists($name, $array_users) ) 
            {
                $this->require_auth($this->messages['wrong_name']);
            }
            if ( !isset ($_SERVER['PHP_AUTH_PW']) ) 
            {
                $this->require_auth($this->messages['no_password']);
            } 
            elseif ( $array_users[$name] != md5(
                    $this->force_utf8($_SERVER['PHP_AUTH_PW']) ) 
                )
            {
                $this->require_auth($this->messages['wrong_password']);
            } 
            else 
            {
                return TRUE;
            }
        }
    }
    /**
     * Sendet Authentifizierungsheader.
     * @param string $realm Nachrichtentext
     * @return string
     */
    public function require_auth($realm)
    {
        header('WWW-Authenticate: Basic realm="' . $realm . '"');
        header('HTTP/1.0 401 Unauthorized');
        echo $this->messages['login_failed'];
        exit;
    }
    /**
     * Prüft einen String auf UTF-8-Kompatibilität.
     * RegEx von Martin Dürst
     * @source http://www.w3.org/International/questions/qa-forms-utf-8.html
     * @see http://toscho.de/2009/utf-8-erzwingen/
     * @param string $str String to check
     * @return boolean
     */
    public function is_utf8_compatible($str)
    {
        return preg_match("/^(
              [\x09\x0A\x0D\x20-\x7E]            # ASCII
            | [\xC2-\xDF][\x80-\xBF]             # non-overlong 2-byte
            |  \xE0[\xA0-\xBF][\x80-\xBF]        # excluding overlongs
            | [\xE1-\xEC\xEE\xEF][\x80-\xBF]{2}  # straight 3-byte
            |  \xED[\x80-\x9F][\x80-\xBF]        # excluding surrogates
            |  \xF0[\x90-\xBF][\x80-\xBF]{2}     # planes 1-3
            | [\xF1-\xF3][\x80-\xBF]{3}          # planes 4-15
            |  \xF4[\x80-\x8F][\x80-\xBF]{2}     # plane 16
            )*$/x",
            $str);
    }
    /**
     * Versucht, einen String nach UTF-8 zu konvertieren.
     *
     * @param string $str Zu kodierender String
     * @param string $inputEnc Vermutete Kodierung des Strings
     * @return string
     */
    function force_utf8($str, $inputEnc='WINDOWS-1252')
    {
        if ( $this->is_utf8_compatible($str) )
        {
            // Nichts zu tun.
            return $str;
        }
        if ( strtoupper($inputEnc) == 'ISO-8859-1')
        {
            return utf8_encode($str);
        }
        if ( function_exists('mb_convert_encoding') )
        {
            return mb_convert_encoding($str, 'UTF-8', $inputEnc);
        }
        if ( function_exists('iconv') )
        {
            return iconv($inputEnc, 'UTF-8', $str);
        }
        else
        { /* GAU! */
            return $str;
        }
    }
}

Eine Nutzerverwaltung gehört natürlich in separaten Code, denn sie sollte vom Authentifizierungsverfahren unabhängig sein.
Ich habe hier MD5-Hashwerte verwendet – diese gelten nicht mehr als sicher. Je nach Sicherheitsanforderung sollte man also einen anderen Algorithmus benutzen oder besser eine ganz andere Authentifizierungsmethode. Denn bei der hier vorgestellten Variante werden Nutzername und Paßwort im Klartext an den Webserver gesandt. Wenn das ein Problem ist, dann dürfte die Erzeugung des Hashwertes die kleinste Sorge sein …

Anwendungsbeispiel example.php

Hier wird hoffentlich klar, daß es ausreicht, nur die Hashwerte der Namen und Paßworte auf dem Server zu speichern – die Klasse arbeitet allein damit, muß die Originale also nicht kennen. Generell gilt: Speichere niemals Paßwörter im Klartext! Nicht in der Datenbank, nicht im PHP-Skript und auch nicht in Textdateien.

<?php
require_once 'class.HTTP_Auth.php';
header ("Content-Type: text/html;charset=utf-8");
$auth = new HTTP_Auth;
$users = array(
        // 'ö'   => 'ßüß'
        'a172480f4e21d0a124bac19c89569c59'      => '994510e17c01e8680c3147df0c8ea605',
        // So lieber nicht!
        md5('Gröfaz') => md5('Laß mich rein!')
    );
if ( $auth->is_authuser($users) ) 
{
    echo '<p style="padding: 20%; font:2em/1.5 serif">Hallo <b>'
        . $auth->force_utf8($_SERVER['PHP_AUTH_USER'])
        . '</b>, dein Passwort ist <b>'
        . $auth->force_utf8($_SERVER['PHP_AUTH_PW'])
        . '</b>!</p>';
}

Einschränkungen

In Deutschland leben etwa 7 Millionen Ausländer oder Menschen mit einer anderen Muttersprache als Deutsch.
Wer Türkisch als Muttersprache hat, wird die Daten vielleicht in ISO-8859-9 kodieren, wer vorrangig Russisch spricht, ISO-8859-2 – und so weiter. Da die übertragenen Daten zu kurz für eine automatische Erkennung sind, scheitert der hier vorgestellte Weg notwendig an solchen Problemen.

Der Zugriff auf Nicht-PHP-Dateien ist immer noch ohne Kontrolle möglich. Hierbei hat der Schutz per Server klar die Nase vorne. Man könnte natürlich sämtliche Aufrufe im zu schützenden Verzeichnis über das PHP-Skript schicken. Das finde ich aber nicht sehr … praktisch.

4 Kommentare

  1. GwenDragon am 15.06.2009 · 18:19

    Die länderspezifischen Zeichen sind ein arges Problem, ja. Ich denke da gerade auch an nicht-lateinische Zeichen wie Kyrillisch, Laotisch, Hangul, Urdu oder ähnliche Seltsamkeiten.

    Die Kodierung der übersandten Zeichen als UTF8 ist die einzige sinnvolle Lösung, Opera macht das schon seit Jahren so. Mir ist es unverständlich, warum Internet Explorer und Firefox noch den eingestellten Fallback nehmen. Wahrscheinlich wurde es einfach übersehen, dass Kodierungen auch außerhalb von HTML ein Rolle spielen. Oder missverstehe ich da was?

    Zum Themas Verschlüsselung von Hashwerten: MD5 als Hash ist seit langem unsicher und SHA verliert auch den Ruf des unknackbaren Hashes. MD5 ist in Sekundenschnelle geknackt, dafür existieren schon sogar Webseiten, die das machen. Bei SHA, auch längere SHA-Schlüssel, mit ein paar GPUs (Grafikprozessoren von normalen Grafikkarten, die sehr schnell rechnen können) auch in einem angemessenen Zeitraum von Minuten oder Stunden.

    Übrigens, das PHP-Beispiel läuft nicht, wenn PHP als CGI arbeitet, es geht nur wenn PHP als Modul in Apache geladen wird.
    Das CGI kann keine Authentifikationsheader senden.
    Da aber mittlerweile PHP fast ausschließlich als Apache-Modul läuft, es sei denn PHP 4 und 5 existieren nebeneinander auf dem Server, sollte das nur ein eher akademisches Problem sein.

  2. Thomas Scholz am 15.06.2009 · 19:48

    Ich denke, das Kodierungsverhalten des Internet Explorers und Firefox’ soll »Rückwärtskompatibilität« gewährleisten. Ein festgeschraubter Quirksmodus sozusagen.

    Die Unsicherheit bei MD5-Hashes wird nur dann ein Problem, wenn der Angreifer den Ziel-Hash kennt. Den bekommt aber der normale Besucher nie zu Gesicht, selbst wenn er sich einloggt – insofern halte ich den Einsatz hier für akzeptabel.

    Das CGI kann keine Authentifikationsheader senden.

    Ein wichtiger Hinweis; das hatte ich ganz vergessen. Habe ich gleich nachgetragen. Ganz so selten ist PHP per CGI dann doch nicht.

  3. warp am 08.09.2010 · 05:23

    Hmm, geht das nicht viel einfacher...?

    $pw = mb_convert_encoding(
    $_SERVER['PHP_AUTH_PW'],
    mb_internal_encoding(),
    'UTF-8,ISO-8859-1');

  4. Thomas Scholz am 08.09.2010 · 13:25

    @warp: Leider kann man sich nicht darauf verlassen, daß mb_convert_encoding() verfügbar ist, deshalb prüfe ich das erst so umständlich. Nicht sehr schön, ja, aber doch meistens erfolgreich.