Endliche Geschichte (Linux-Magazin, Dezember 2002)

Registrieren Websurfer ihre Email-Adressen, müssen Web-Applikationen oft deren Richtigkeit überprüfen. Ein CGI-Skript lädt heute zum Registrieren ein, schickt Emails an die eingetragene Adresse und stellt so sicher, dass das Konto auch tatsächlich dem Web-Nutzer gehört.

Email-Adressen folgen gewissen syntaktischen Anforderungen. Zum Beispiel ist immer ein @-Zeichen drin. Das zu Überprüfen, genügt aber bei weitem nicht, um sicher zu stellen, dass es sich um ein real existierendes Email-Konto handelt, geschweige denn dass die angegebene Adresse auch tatsächlich dem gerade auf der Seite herumbrowsenden Web-Nutzer gehört.

Um sicherzustellen, dass der Websurfer auch tatsächlich seine Email richtig in das zur Verfügung gestellte Formular eingetragen hat, und nicht etwa inkognito reist oder gar einen üblen Spass mit der Email-Adresse seines Lieblingsfeindes treibt, hilft nur eines: Die Web-Applikation muss einen schwer zu erratenden Code an die angegebene Email-Adresse schicken, und deren Besitzer dazu bewegen, dieses Geheimnis wieder zurück zur Web-Applikation zu schicken -- zum Beispiel per Formularfeld auf einer weiteren Webseite.

Das heute vorgestellte CGI-Skript emailreg zeigt zunächst ein Formular an, in das der Benutzer seine Email einträgt (Abbildung 1). Durch einen Mausklick auf den Submit-Button erhält der Webserver die Eingabe und führt rudimentäre Tests durch. Er prüft, ob tatsächlich irgendetwas eingetragen wurde und ob die Eingabe ein '@' enthält. Im Fehlerfall verzweigt er wieder zurück zum Eingabeformular, das einen entsprechenden Fehlertext anzeigt (Abbildung 2).

Abbildung 1: Email-Adresse im Formular registrieren

Abbildung 2: Ungültig!

Genügt die eingetragene Email den etwas schludrig gestalteten Anforderungen, generiert der Server einen alphanumerischen Zufallscode und schickt ihn an die angegebene Email-Adresse. Der Browser stellt indes eine weitere Seite dar, die ein Formular mit vorausgefüllter Email-Addresse enthält und außerdem in einem weiteren Feld den geheimen Code erwartet (Abbildung 3). Den weiss freilich nur der rechtmäßige Besitzer des Email-Kontos, auf dem die Post mit der geheimen Nachricht ankommt (Abbildung 4).

Abbildung 3: Warten auf Bestätigung

Abbildung 4: You've got mail!

Bei falsch eingetragenem Bestätigungs-Code verzweigt die Registrierungs-Seite wieder zurück zur Bestätigungsseite, die dann eine passende Fehlermeldung anzeigt. Stimmt die Eingabe aber mit dem übermittelten Code überein, übernimmt der Webserver die Email als gültig in seine Datenbank und zeigt eine ``Danke!''-Seite an (Abbildung 5).

Abbildung 5: Registrierung erfolgreich. Email-Adresse bestätigt.


Qual der Wahl

Derartig simple Web-Transaktionen lassen sich schnell zusammenschustern. Traditionell gibt es hierzu zwei Möglichkeiten:

[a]
Ein CGI Skript spuckt je nach Status der Transaktion verschiedene HTML-Seiten aus. Leider führt die Vermengung von HTML und Perl-Code dazu, dass die Skripts kein HTML-Designer mehr grafisch aufpolieren kann.

[b]
Ein Template-System wie HTML::Mason oder Embperl versteht mit Code angereichertes HTML. HTML-Designer ignorieren einfach den Code und arbeiten am Layout, während Perl-Programmierer den dynamischen Seitenfluss regeln.

[a] ist nur für 08/15-Projekte geeignet -- für alle anderen stellt sich heraus, dass Perl-Hacker besser mit Code umgehen als mit anspruchsvollem Seiten-Layout. [b] umgeht dieses Problem geschickt dadurch, dass HTML-Editoren den eingebetteten Perl-Code einfach unterdrücken, bzw. große Teile einfach in Bibliotheksdateien liegen. Im rauhen Projektalltag und mit konstant eintrudelnden Verbesserungs- und Änderungswünschen seitens des Kunden landet jedoch, wenn man nicht genau aufpasst, manchmal mehr Perl-Code in den HTML-Seiten als für ein sauber entworfenes und leicht wartbares System zuträglich wäre.


Finite Automaten

Behandeln wir das Problem mal systematisch: Wenn man sich's genau überlegt, ist eigentlich eine Webapplikation nichts anderes als ein finiter Automat aus der Informatikvorlesung: Es gibt eine endliche Anzahl von Zuständen (Email eingeben, Bestätigungs-Code eingeben, Danke-Seite anzeigen) und eine Reihe von Übergangsbedingungen (z.B. zurück zum Formular mit Fehlermeldung, falls die Email falsch ist), um zwischen den Zuständen hin- und herzuwandern. Abbildung 6 zeigt das Ablaufdiagramm der Email-Überprüfung.

Abbildung 6: Ablaufdiagramm

Diesen Automaten implementiert das vom CPAN erhältliche Modul CGI::Application von Jesse Erlbaum. Der CGI-Programmierer teilt nur mit, welche Zustände seine Applikation anspringt und welche Parameter die Übergänge einleiten. Im Zusammenspiel mit dem Modul HTML::Template von Sam Tregar entsteht daraus eine flexible Applikationsplattform.

Listing emailreg zeigt das ganze CGI-Skript: Zeile 9 lässt den Browser detaillierte Fehlermeldungen anzeigen, falls das Skript auf irgendwelche Probleme läuft -- immer eine gute Idee bei der CGI-Entwicklung. Unter Produktionsbedingungen sollte die Zeile freilich verschwinden.

Listing 1: emailreg

    01 #!/usr/bin/perl
    02 ###########################################
    03 # emailreg - CGI to register/confirm emails
    04 # Mike Schilli, 2002 (m@perlmeister.com)
    05 ###########################################
    06 use warnings;
    07 use strict;
    08 
    09 use CGI::Carp qw(fatalsToBrowser);
    10 use EmailReg;
    11 my $emailreg = EmailReg->new(
    12     TMPL_PATH => "/data/templates/reg");
    13 $emailreg->run();

Zeile 10 zieht das nachfolgend besprochene Modul EmailReg herein, das die ganze Ablauflogik enthält. Zeile 11 definiert eine Instanz des finiten Automaten und teilt ihm mit, in welchem Verzeichnis die HTML-Templates liegen, die das graphische Layout der Zustände definieren. Und Zeile 13 wirft schließlich den Automaten an, der seinen aktuellen Zustand über CGI-Variablen steuert. Das war's schon!

Templates

Die Templates enthalten ganz normales HTML, angereichert durch eine Handvoll simpler Macros, die der Template-Motor durch entsprechenden Text ersetzt. Die Betonung liegt auf simpel: Außer trivialer Variableninterpolation im Format

    <tmpl_var variable_name>

bietet HTML::Template noch if-else-Logik und Schleifen, aber keines der gezeigten HTML-Snippets nutzt diese ``fortgeschrittenen'' Funktionen -- lediglich einfache Variablen wie die Email-Addresse des Benutzers oder der Text einer Fehlermeldung werden ersetzt.

Die Beschränkung auf triviale Textersetzung ist bewußt gewählt: Die Intelligenz des Skripts liegt im finiten Automaten, nicht im HTML-Code der Templates. So ist gewährleistet, dass niemand komplizierte Logik in die Seiten einbaut, die losgelöst von der zentralen Steuerung im Automaten nur schwer verständlich ist, sobald sie eine gewisse Komplexitätsgrenze überschreitet.

signup.tmpl enthält das HTML-Formular für die Eingabe der Emailadresse samt Submit-Knopf wie in Abbildung 1 dargestellt. Liegt in der Variablen err_text vom Automaten eine Fehlermeldung vor, wird <tmpl_var err_text> gegen diese ausgetauscht und wegen des <FONT>-Tags fett in Rot dargestellt (Abbildung 2). Auch wird das Email-Eingabefeld vorbesetzt, falls die Variable email gesetzt war.

Listing 2: signup.tmpl

    01 <HTML><HEAD><TITLE>Sign In</TITLE></HEAD>
    02 <BODY><P>
    03 <FONT color=red><B>
    04 <tmpl_var err_text></B></FONT>
    05 
    06 <FORM method=POST>
    07 <INPUT TYPE=text NAME=email 
    08                  VALUE="<tmpl_var email>">
    09 <INPUT TYPE=hidden NAME=mode VALUE=verify>
    10 <INPUT TYPE=submit VALUE="Sign Up">
    11 <FORM>
    12 
    13 </BODY>

confirm.tmpl zeigt ein Eingabeformular für den Bestätigungs-Code. Auch hier stehen die Platzhalter <tmpl_var email> und <tmpl_var err_text> für die vorbelegte Email-Adresse und eine eventuell gezeigte Fehlermeldung. Als letztes HTML-Snippet zeigt schließlich confirm.tmpl nur eine Bestätigung, falls der Code richtig eingegeben wurde und die Registrierung klappte.

Listing 3: confirm.tmpl

    01 <HTML><HEAD><TITLE>Confirm</TITLE></HEAD>
    02 <BODY>
    03 <P><FONT color=red><B><tmpl_var err_text>
    04 </B></FONT>
    05 
    06 <FORM method=POST><TABLE><TR><TD>Email:
    07 </TD><TD>
    08 <INPUT TYPE=text NAME=email 
    09                  VALUE="<tmpl_var email>">
    10 </TD></TR><TR><TD>Confirmation Code:
    11 </TD><TD>
    12 <INPUT TYPE=text NAME=code>
    13 </TD></TR></TABLE>
    14 <INPUT TYPE=hidden NAME=mode 
    15                    VALUE=chk_confirm>
    16 <INPUT TYPE=submit VALUE="Confirm">
    17 <FORM>
    18 </BODY>

Listing 4: thanks.tmpl

    1 <HTML><HEAD><TITLE>Welcome</TITLE></HEAD>
    2 <BODY>
    3 Welcome <tmpl_var email>!
    4 <P>
    5 <tmpl_var email>, you are now subscribed.
    6 </BODY>

Im Maschinenraum des Automaten

Ans Eingemachte geht's in Listing EmailReg.pm, einem Modul, das, wie in der CGI::Application-Welt üblich, eine von CGI::Application abgeleitete Klasse EmailReg enthält. Die im vorher gezeigten Skript emailreg aufgerufene run()-Methode startet den in EmailReg.pm definierten Automaten, der, falls er nicht weiss, in welchem Zustand er steht, einfach die setup()-Methode aufruft.

setup() definiert zunächst mittels der mode_param()-Methode den Namen des CGI-Parameters, der den Zustand des Automaten zwischen Browser und Server hin- und herschleift: mode. Der Methodenaufruf start_mode("signup") in Zeile 33 bestimmt, dass der Automat mit der ``signup''-Methode zu starten ist. Diese ist weiter unten definiert und wird später das Template zur Eingabe der Email-Addresse in den Browser zaubern.

Die run_modes()-Methode in Zeile 34 bestimmt die Namen der Zustände, die der Automat annehmen kann (siehe auch Abbildung 6) und deren zugehörige Methoden in EmailReg:

signup
Zeigt das Template zur Eingabe der Emailadresse an. Klickt der Benutzer den Submit-Knopf, geht's weiter mit verify.

verify
Führt einige simple Tests mit der eingegebenen Email-Adresse durch. Im Fehlerfall geht's zurück zu signup. Bei Erfolg wird die Email abgeschickt und es geht mit confirm weiter.

confirm
Stellt das HTML-Formular zur Eingabe des Bestätigungs-Codes dar.

chk_confirm
Prüft den eingegebene Code gegen die Datenbank. Stimmt er, geht's mit thanks weiter. Ist er falsch, geht's mit einer Fehlermeldung zurück zu confirm.

thanks
Gibt eine kurze Bestätigungsmeldung aus.

Anschließend bindet der tie-Befehl in Zeile 42 den globalen Hash %EMAILS an eine DB_File-Datei, die wegen der O_RDWR|O_CREAT-Kombination aus dem Fcntl-Modul zum Lesen und Schreiben geöffnet und neu angelegt wird, falls sie noch nicht existiert. Die Zugriffsrechte der Datei werden mit 0644 (rw-r--r--) festgelegt, die aus DB_File exportierte Variable $DB_HASH bestimmt, dass die DBM-Datei im DB_File-Format einfach den angegebenen Hash %EMAILS persistent macht.

DB_File::Lock ist eine von DB_File abgeleitete Klasse, die außer den regulären tie-Funktionen auch noch die Zugriffe über Locks synchronisiert, denn schließlich können auf einem Webserver unter hoher Last leicht mal zwei Instanzen des Skripts gleichzeitig auf die Datenbank einhämmern. Der 'write'-Parameter weist DB_File::Lock an, einen Schreib-Lock zu holen, also die Datenbank exklusiv zu sperren, während der Hash gebunden ist.

Zeile 16 legt den Namen der DBM-Datei fest, in denen der Hash %EMAILS über DB_File seine Daten ablegt.

Der Reigen beginnt

Nach setup kommt wegen Zeile 33 der Startzustand signup dran, falls der Browser keinen mode-Parameter sandte, um einen anderen Zustand anzufordern, was beim ersten Aufruf des Skripts emailreg der Fall ist.

signup (ab Zeile 56) holt lediglich mittels

    $self->query()->param('error');

den eventuell auf eine Nummer gesetzten CGI-Parameter error ab und ruft die ab Zeile 66 definierte Methode _signup (Unterstrich, da kein Zustand) mit dem Wertepaar error => Fehlernummer auf und gibt ihr Ergebnis zurück.

_signup erwartet als Parameter (außer der sowieso mitgelieferten Referenz auf das EmailReg-Objekt) optionale Attributwerte unter den Schlüsseln error und email.

Mit load_tmpl("signup.tmpl") wird dann das HTML-Template geladen und die param()-Methoden legen die Werte für die Macro-Ersetzung fest. Für error muss (falls ein Wert ungleich 0 vorliegt) erst die entsprechende Fehlermeldung aus dem ab Zeile 18 definierten Hash %ERRORS extrahiert werden -- schließlich soll der CGI-Parameter error nur Nummern hin- und herschleifen und nicht vollständige Fehlertexte, die irgendwelche Schlingel dann auch noch für ihre Zwecke modifizieren könnten.

Die output-Methode (Zeile 79) des Template-Objekts gibt den HTML-Text des Templates einschließlich der mittels param() ersetzten Variablen zurück. Es ist wichtig, darauf zu achten, dass der Automat niemals Text über printf auf STDOUT ausgibt -- die Ausgabe erfolgt dadurch, dass Zustandsmethoden Textwerte zurückgeben, die der Automat dann unter Hinzufügung der notwendigen HTTP-Header an den Browser schickt.

Der ab Zeile 83 definierte verify-Zustand führt elementare Syntaxprüfungen mit der eingegebenen Email-Adresse durch und verzweigt im Fehlerfall zurück zu _signup und setzt die Fehlernummern entsprechend auf 1 (keine Email da) oder 2 (kein @ drin). Im zweiten Fall kommt auch noch der email-Parameter mit, der _signup() veranlasst, die Email gleich wieder mit dem falschen Wert vorzubesetzen, damit der Benutzer sie gleich verbessern kann, ohne alles wieder von vorne einzutippen.

Genügt die Email den minimalen Anforderungen, holt Zeile 98 das MD5-Modul herein, dessen hexhash-Methode in Zeile 99 einen String aus einer Zufallszahl und der gerade laufenden Prozessnummer in einen MD5-Hash umwandelt und diesen auf 5 Zeichen kürzt -- ein einigermaßen schwer zu erratender alphanumerischer Zufallsstring.

Zeile 101 setzt ein ``U'' (für Unconfirmed) davor und legt das ganze im persistenten Hash %EMAILS unter der eingegebenen Email-Adresse ab. Zwischen 103 und 109 sendet das Mail::Mailer-Modul eine Email mit dem geheimen Code an die Email-Adresse. Zeile 111 verzweigt daraufhin zur Methode _confirm (wieder Unterstrich, da Zwischenzustand) und damit zur Anzeige des Bestätigungsformulars (Abbildung 3).

Dessen HTML (confirm.html) setzt mit

    <INPUT TYPE=hidden NAME=mode VALUE=chk_confirm>

den Zustand des Automaten auf chk_confirm, sodass der Server chk_confirm() anspringt, falls der Benutzer den Submit-Knopf drückt. Die Logik ab Zeile 137 prüft dort, ob der Benutzer in der Datenbank steht, sein Code mit 'U' beginnt (Unconfirmed) und der Rest mit dem im HTML-Formular eingegebenen Code (verfügbar unter $self->query()->param('code')) übereinstimmt.

Falls ja, setzt Zeile 141 den Hasheintrag unter der Email-Adresse auf 'C' (für Confirmed) und Zeile 142 springt in den thanks-Zustand, der die Dankesmeldung im Browser anzeigt. Falls nein, geht's mit einer Fehlermeldung zurück zu _confirm.

Am Ende einer Runde, bevor die Daten zurück an den Browser gehen, springt CGI::Application jedes Mal zuverlässig die teardown()-Methode an. Ab Zeile 48 wird dort der Hash %EMAILS wieder ordnungsgemäß von der Datenbankdatei abgekoppelt.

Listing 5: EmailReg.pm

    001 ###########################################
    002 package EmailReg;
    003 ###########################################
    004 # Register and confirm Emails on the Web
    005 # Mike Schilli, 2002 (m@perlmeister.com)
    006 ###########################################
    007 
    008 use strict;
    009 use warnings;
    010 
    011 use CGI::Application;
    012 use DB_File::Lock;
    013 use Fcntl qw(:flock O_RDWR O_CREAT);
    014 use Mail::Mailer;
    015 
    016 our $DB_FILE = "/tmp/emails.dat";
    017 
    018 our %ERRORS = ( 
    019     1 => 'No email address given',
    020     2 => 'Not a valid email address',
    021     3 => 'Confirmation failed',
    022 );
    023 
    024 our @ISA  = qw(CGI::Application);
    025 our %EMAILS = ();
    026 
    027 ###########################################
    028 sub setup {
    029 ###########################################
    030   my($self) = @_;
    031 
    032   $self->mode_param("mode");
    033   $self->start_mode("signup");
    034   $self->run_modes(
    035       signup      => "signup",
    036       verify      => "verify",
    037       confirm     => "confirm",
    038       chk_confirm => "chk_confirm",
    039       thanks      => "thanks",
    040   );
    041 
    042   tie %EMAILS, 'DB_File::Lock', $DB_FILE, 
    043       O_RDWR|O_CREAT, 0644, $DB_HASH, 
    044       'write' or die $@;
    045 }
    046 
    047 ###########################################
    048 sub teardown {
    049 ###########################################
    050   my($self) = @_;
    051     
    052   untie %EMAILS;
    053 }
    054 
    055 ###########################################
    056 sub signup {
    057 ###########################################
    058   my($self) = @_;
    059 
    060   my $e = $self->query()->param('error');
    061 
    062   return $self->_signup(error => $e || 0);
    063 }
    064 
    065 ###########################################
    066 sub _signup {
    067 ###########################################
    068   my($self, %opt) = @_;
    069 
    070   my $tmpl = 
    071            $self->load_tmpl("signup.tmpl");
    072 
    073   $tmpl->param(err_text => 
    074       $ERRORS{$opt{error}}) if $opt{error};
    075 
    076   $tmpl->param(email => $opt{email}) if 
    077                         exists $opt{email};
    078 
    079   return $tmpl->output();
    080 }
    081 
    082 ###########################################
    083 sub verify {
    084 ###########################################
    085   my($self) = @_;
    086 
    087   my $email = 
    088             $self->query()->param('email');
    089 
    090   return $self->_signup(error => 1) 
    091                              unless $email;
    092 
    093   if($email !~ /@/) {
    094     return $self->_signup(email => $email, 
    095                           error => 2);
    096   }
    097 
    098   require MD5;
    099   my $code = substr(MD5->hexhash(
    100                         rand().$$), 0, 5);
    101   $EMAILS{$email} = "U$code";
    102 
    103   my $mail = Mail::Mailer->new("sendmail");
    104   $mail->open(
    105       {From    => 'email@service.org',
    106        To      => $email,
    107        Subject => 'Confirm'});
    108   print $mail "Confirmation code: $code\n";
    109   $mail->close;
    110 
    111   return $self->_confirm(email => $email);
    112 }
    113 
    114 ###########################################
    115 sub _confirm {
    116 ###########################################
    117   my($self, %opt) = @_;
    118 
    119   my $tmpl = 
    120         $self->load_tmpl("confirm.tmpl");
    121   $tmpl->param(err_text => 
    122       $ERRORS{$opt{error}}) if $opt{error};
    123   $tmpl->param(email => $opt{email}) 
    124                      if exists $opt{email};
    125 
    126   return $tmpl->output();
    127 }
    128 
    129 ###########################################
    130 sub chk_confirm {
    131 ###########################################
    132   my($self) = shift;
    133 
    134   my $email=$self->query()->param('email');
    135   my $code = $self->query()->param('code');
    136 
    137   if(exists $EMAILS{$email} and
    138        $EMAILS{$email} =~ /(.)(.*)/ and
    139        $1 eq "U" and
    140        $2 eq $code) {
    141     $EMAILS{$email} = "C";
    142     return $self->thanks(email => $email);
    143   } else {
    144     return $self->_confirm(error => 3, 
    145                           email => $email);    
    146   }
    147 }
    148 
    149 ###########################################
    150 sub thanks {
    151 ###########################################
    152     my($self, %opt) = @_;
    153 
    154     my $template = 
    155            $self->load_tmpl("thanks.tmpl");
    156     $template->param(email => $opt{email});
    157     return $template->output();
    158 }
    159 
    160 1;

Emails ernten

Um die bestätigten Emails aus der Datenbank zu ernten, genügt ein einfaches Skript wie dumphash, das den Hash bindet (auch wieder mit Lock, aber diesmal nur lesend mit 'read', durch die Einträge iteriert und nach Emails sucht, deren Code aus 'C' besteht. Fertig!

Listing 6: dumphash

    01 #!/usr/bin/perl
    02 ###########################################
    03 # dumphash -- Print confirmed emails
    04 # Mike Schilli, 2002 (m@perlmeister.com)
    05 ###########################################
    06 use warnings;
    07 use strict;
    08 
    09 use DB_File::Lock;
    10 use Fcntl qw(:flock O_RDONLY);
    11 use EmailReg;
    12 
    13 tie my %DATA, 'DB_File::Lock',
    14     $EmailReg::DB_FILE,
    15     O_RDONLY, 0644, $DB_HASH, 
    16     'read' or die "Cannot tie";
    17 
    18 for (keys %DATA) {
    19     print "$_\n" if $DATA{$_} eq "C";
    20 }
    21 
    22 untie %DATA;

Weitere Informationen zu CGI::Application finden sich in den Manualseiten (perldoc CGI::Application) sowie in [2], das nicht nur schön erklärt, wie man Module für's CPAN schreibt, sondern beschreibt, wie man das beste aus CGI::Application mit und ohne Templatesystem herausholt.

Installation

Neben CGI::Application benötigt das Registrierungssystem die folgenden CPAN-Module: Mail::Mailer, MD5, DB_File, DB_File::Lock. Alle installieren sich wie üblich mit der CPAN-Shell.

Das Skript emailreg muss ins cgi-bin-Verzeichnis des Webservers und das Modul EmailReg.pm irgendwohin, wo emailreg es findet -- am einfachsten ins selbe Verzeichnis. Die HTML-Templates kommen das Verzeichnis, das für sie in Zeile 16 in EmailReg.pm gesetzt wurde. Wer einen Webdesigner an der Hand hat, kann die Templates ohne weiteres verschönern lassen.

Die vom Automaten angelegte DB_File-Datei legt Zeile 16 in EmailReg.pm mit /tmp/emails.dat fest. Ein Skript nach Listing dumphash liest sie aus und gibt alle bestätigten Emailadressen aus.

Infos

[1]
Listings zu diesem Artikel: ftp://www.linux-magazin.de/pub/listings/magazin/2002/12/Perl

[2]
Sam Tregar, ``Writing Perl Modules for CPAN'', Apress, 2002, http://perlmeister.com/cgi/amz/159059018X

Michael Schilli

arbeitet als Software-Engineer bei Yahoo! in Sunnyvale, Kalifornien. Er hat "Goto Perl 5" (deutsch) und "Perl Power" (englisch) für Addison-Wesley geschrieben und ist unter mschilli@perlmeister.com zu erreichen. Seine Homepage: http://perlmeister.com.