QVINTVS · SCRIBET

Single-Sign-On mit Moinmoin und Mailman 3

Der Artikel erklärt anhand eines konkreten Beispiels (TSC-Projekt), wie man eine einheitliche Anmeldung (Single-Sign-On) mit Moinmoin und Mailman 3 ohne Zuhilfenahme einer Drittsoftware wie etwa eines LDAP-Servers umsetzt.

I. Problemstellung

Bekanntlich leite ich die Entwicklung des freien und quelloffenen Computerspiels „The Secret Chronicles of Dr. M.“ (TSC). In der Frühzeit dieses Projekts gab es ein Wiki, welches aber nach einiger Zeit wegen zweier Probleme eingestellt werden mußte: zum einen gab es ein immenses Problem mit Spam-Bots, zum anderen war jeder Nutzer gezwungen, für das TSC-Wiki einen eigenen Account anzulegen, weil eine einheitliche Anmeldung für alle TSC-Dienste (sog. „Single sign on“) bis zum Schluß nicht umgesetzt wurde. Nachdem zuletzt wieder frischer Wind in die TSC-Entwicklung gekommen und eine Veröffentlichung einer neuen Version 2.1.0 absehbar geworden ist, hielt ich es für angebracht, das Thema Wiki und einheitliche Anmeldung wieder aufzuwärmen. Bereits das erste Wiki lief mit Moinmoin weitestgehend klaglos, sodaß es nahe lag, auch diesmal wieder auf die Software zu setzen. Auf der anderen Seite steht Hyperkitty: das Web-Interface von Mailman 3, das für TSC Forenfunktionen bereitstellt und hoffentlich einmal allen an TSC Interessierten ein Austauschmedium jenseits des IRC-Kanals bieten wird. Beide Dienste sollten mit denselben Anmeldeinformatiouen genutzt werden können. Da Hyperkitty bereits voll in Gebrauch war, lag es nahe, Moinmoin an Hyperkitty anzubinden und nicht umgekehrt.

Dieser Artikel zeigt die gewählte Vorgehensweise auf, die dieses Problem für die Anforderungen des TSC-Projekts hinreichend gut gelöst hat. Sie ist bei weitem nicht perfekt, jedoch ist dies wohl nicht zuletzt der Tatsache geschuldet, daß ich eigentlich kein Python programmiere. Erfahrene Python-Programmierer werden über die gewählte Vorgehensweise wahrscheinlich die Hände über dem Kopf zusammenschlagen. Das ist berechtigt; Verbesserungsvorschläge nehme ich gern entgegen. Schon jetzt möchte ich aber darauf hinweisen, daß die Server-Konfiguration im TSC-Projekt bewußt konservativ gewählt ist. Wir sind ein sehr kleines Entwicklungs-Team und sind darauf angewiesen, daß unser Server weitestgehend wartungsarm ist. Wichtigste Konsequenz hieraus ist, daß auf dem Server keine Software an den offiziellen Repositorien vorbei installiert wird. Weder für Mailman 3 noch für Moinmoin sind deshalb die für Python sonst typischen virtuellen Umgebungen (virtualenv) im Einsatz. Dies sollte man bei der Lektüre im Hinterkopf behalten. Eine andere Folge aus dieser Zielsetzung ist die angestrebte Problemlösung überhaupt: denn schließlich gibt es mit LDAP, PAM oder gar Kerberos speziell auf einheitliche Anmeldung über mehrere Dienste hinweg zugeschnittene Software, welche sonst auch oft empfehlenswert ist. Für das TSC-Projekt bedeutete ihr Einsatz aber wieder eine Komponente mehr, die Wartung und Aufsicht benötigt, für welche einfach die Kapazitäten fehlen. Mit der hier beschriebenen Lösung treten zu Mailman 3, Moinmoin und den zur ihrem Einsatz ohnehin erforderlichen Dienstprogrammen dagegen keine größeren Komponenten mehr hinzu.

Abb. 1 skizziert die in die Lösung involvierten Programme und ihre Kommunikation untereinander. An der Spitze befindet sich zunächst der HTTP-Server, Apache httpd‚ der die eingehenden HTTP-Anfragen entgegen nimmt und diejenigen für dynamische Inhalte u.a. auf Moinmoin und Hyperkitty verteilt. Dies geschieht über WSGI, wobei die beiden WSGI-Endpunkte von uwsgi bereitgestellt werden. Bezüglich Hyperkitty als Web-Interface ergibt sich nichts weiter besonderes (rechter Teil der Abbildung). Insoweit handelt es sich nach meiner Wahrnehmung um eine recht übliche Art, Python-Webanwendungen bereitzustellen1‚ die weiterer Erläuterung nicht bedarf. Auf der linken Seite der Abbildung ist der Teil abgebildet, der vorliegend von Interesse ist. Moinmoin kommuniziert über ein Plugin mit Hyperkitty. Hyperkitty liest dann die eigentlichen Zugangsdaten aus der Datenbank und vergleicht die Paßwort-Hashes (wofür Hyperkitty auf für das Webframework Django übliche Techniken zurückgreift, die hier nicht weiter interessieren sollen). Nachfolgend wird die Konfiguration dieser Programme näher erläutert.

Abb. 1: Schemenhafte Darstellung der involvierten Programme

Abb. 1: Schemenhafte Darstellung der involvierten Programme.

II. Bereitstellung der Daten aus Hyperkitty

Sowohl Hyperkitty als auch Moinmoin sind Python-Programme. Was läge da näher, als einfach Hyperkittys Code in Moinmoin zu laden, um die Authentifizierung durchzuführen? In der Tat mag das eine gangbare Variante sein, allerdings kann ich als jemand, der kaum je mit Python arbeitet, die möglichen Folgen eines gleichzeitigen Ladens zweier ganz verschiedener Programme überhaupt nicht abschätzen. Um subtile, später schwer verständliche Probleme zu vermeiden, habe ich mich deshalb dafür entschieden, die Ausführung beider Programme streng voneinander zu trennen. Die deshalb erforderliche Kommunikation über ein externes Medium erfolgt mithilfe der Kommandozeile. Moinmoin erhält ein (in Python geschriebenes) Plugin, welches durch Kommando-Ausführung Hyperkitty die Authentisierung durchführen läßt. Das Skript bekommt dabei zwei Argumente: den Nutzernamen und das übermittelte Paßwort. Via Exitcode gibt es dann zu erkennen, ob die Authentisierung erfolgreich war, d.h. die beiden Paßwort-Hashes übereinstimmten. Zusätzlich wird im Erfolgsfalle auf der Standardausgabe die E-Mail-Adresse ausgegeben, damit Moinmoin diese erfährt. Dies führt zu dem – erwünschten – Seiteneffekt, daß die Nutzer ihre E-Mail-Adresse nur über Hyperkitty wirksam ändern können (siehe dazu noch unten III.).

Das von Hyperkitty genutzte Django-Framework bietet eine einfache Möglichkeit an, beliebigen Code in Form von Python-Skripten im Kontext der Webanwendung auszuführen. Jedes Django-Projekt enthält ein zentrales Verwaltungsskript, manage.py‚ welches eine Reihe administrativer Befehle bereitstellt. Dazu gehört auch ein Befehl runscript, welcher wie der Name nahelegt ein Python-Skript ausführt. Allerdings muß man dabei beachten, daß diese Skripte in einem dem Python-Paketformat entsprechenden Verzeichnis abgelegt werden müssen, widrigenfalls sie nicht gefunden werden. Die Django-Dokumentation selbst schlägt vor, dazu im Projektverzeichnis einen Unterordner „scripts“ anzulegen. Das ist insofern mißlich, als daß auf dem TSC-Server Hyperkitty ja aus dem Repositorium installiert wurde und man unterhalb von /usr gemeinhin keine Änderungen vornehmen sollte. An sich sollte man das Problem durch Ergänzung des Python-Suchpfades um ein Verzeichnis außerhalb von /usr mithilfe von uwsgi beheben können, doch genügt das manage.py aus welchen Gründen auch immer nicht (derselbe Trick funktioniert dagegen bei Moinmoin tadellos, wie unten unter III. beschrieben werden wird). Um die Auswirkungen so gering wie möglich zu halten, legt man deshalb einen Symlink von /usr/share/mailman3-web/scripts nach z.B. /etc/mailman3/customscripts an.

In diesem Verzeichnis kann man dann den Anweisungen der Django-Dokumentation zu runscript folgend eine leere Datei __init__.py anlegen, um das Python-Paketformat zu wahren. Das eigentliche Authentisierungsskript legt man dann mit dem folgenden Inhalt unter /etc/mailman3/customscripts/authtest.py (oder einem beliebigen anderen Namen) ab.

from django.contrib.auth import authenticate

def run(*args):
    user = authenticate(username=args[0], password=args[1])
    if user is not None:
        # A backend authenticated the credentials
        print(user.email)
    else:
        print("Invalid credentials")
        exit(1)
        # No backend authenticated the credentials

Das Skript ist so einfach, daß man es auch ohne vertiefte Python-Kenntnisse gut verstehen kann. Die übergebenen Argumente werden zur Authentisierung genutzt und bei Erfolg wird die E-Mail-Adresse des Nutzers ausgegeben. Die Methode authenticate() wird von Django bereitgestellt. Ausführen kann man das Skript dann ganz einfach von der Kommandozeile:

$ python manage.py runscript authtest --script-args johndoe s33cretp4ssw0rd

Gibt es Probleme, kann man mithilfe der zusätzlichen Option --traceback die vollständige Fehlermeldung erhalten.

III. Abruf in Moinmoin

Die eigentliche Herausforderung besteht nun darin, Moinmoin um eine neue Authentisierungsmöglichkeit zu erweitern, welche das soeben erstellte Skript aufruft. Glücklicherweise ist Moinmoins Authentisierungsmechanismus modular aufgebaut, d.h. jeder Mechanismus zur Authentisierung stellt ein eigenes Modul dar. Wie das Moinmoin-Wiki darlegt, kann man sich das zu Nutze machen, um ein eigenes Authentisierungs-Modul zu schreiben. Was sich zunächst schwierig anhört, ist aber tatsächlich gar nicht so kompliziert, wenngleich es natürlich schöner wäre, wenn Moinmoin bereits von sich aus ein Shellskript-Modul anböte. Schließlich wäre das universell einsetzbar und nicht auf TSC beschränkt. Jedenfalls kann und sollte man sich von den bestehenden Authentisierungs-Modulen in Moinmoins Quelltext inspirieren lassen. Besonders hilfreich für mein persönliches Verständnis waren die LDAP- und Interwiki-Authentisierungs-Module. Nach deren Vorbild und mit der für einen Nicht-Python-Programmierer gehörigen Frickelei ist dabei das folgende Modul entstanden:

"""
MoinMoin - Custom HyperKitty authentication
"""

import subprocess
from MoinMoin import log
logging = log.getLogger(__name__)

from MoinMoin import user
from MoinMoin.auth import BaseAuth, ContinueLogin, CancelLogin

class HyperkittyAuth(BaseAuth):
    """ Authentication against HyperKitty's database """
    name = 'hyperkittyauth'
    logout_possible = True
    login_inputs = ['username', 'password']

    def __init__(self):
        BaseAuth.__init__(self)

    def login(self, request, user_obj, **kw):
        username = kw.get('username')
        password = kw.get('password')
        _ = request.getText

        if not username or not password:
            return ContinueLogin(user_obj, _('Missing user name or password.'))

        logging.debug("Trying to authenticate %r" % username)
        email = self.check_hyperkitty(username, password)
        if not email is None:
            logging.debug("Authentication successful. E-Mail is %r" % email)
            u = user.User(request, name=username, auth_method=self.name, auth_attribs=('name', 'password', 'email'))
            u.name = username
            u.email = email
            u.create_or_update(True)
            logging.debug("Completed authentication for %r" % username)
            return ContinueLogin(u)
        else:
            logging.debug("Authentication failed.")
            return ContinueLogin(user_obj, _('Incorrect user name or password.'))

    def check_hyperkitty(self, username, password):
        try:
            return subprocess.check_output(["python", "/usr/share/mailman3-web/manage.py", "runscript", "--traceback", "authtest", "--script-args", username, password]).rstrip()
        except:
            logging.debug("Hyperkitty says no")
            return None

Die login()-Methode wird von Moinmoin zwecks Authentisierung aufgerufen. Diese extrahiert die übergebenen Zugangsdaten und prüft durch Aufruf von check_hyperkitty() diese auf ihre Gültigkeit. Sind sie gültig, wird ein neues Nutzer-Objekt angelegt oder ein bestehendes aktualisiert. Letzteres führt dazu, daß in Hyperkitty vorgenommene Änderungen der E-Mail-Adresse eines Nutzers bei der Anmeldung automatisch nach Moinmoin (welches jene etwa für Benachrichtungen benötigt) übernommen werden. So muß sich der Nutzer nur um die Verwaltung seines Hyperkitty-Kontos kümmern. Übergibt man ContinueLogin() eine User-Instanz, so wertet Moinmoin die Authentisierung offenbar als gelungen, im Übrigen nicht. Wichtig: wird im letzteren Fall ein String als zweites Argument übergeben, dann wird dieser dem Nutzer als Fehlermeldung angezeigt. Die aufgerufene Methode HyperkittyAuth.check_hyperkitty() nun führt den oben unter II. in Aussicht gestellten Shellout durch und ruft das dort beschriebene Skript über manage.py auf. Die Auswertung ist weitgehend selbsterklärend. rstrip() wird benötigt, um das abschließende Zeilenumbruchzeichen, welches von print() stammt, zu verwerfen. Schließlich gehört es nicht zur E-Mail-Adresse.

Das Skript kann erfreulicherweise außerhalb von /usr abgespeichert werden, solange man dabei die Regeln für Python-Pakete einhält. Im Falle von TSC habe ich mich für /etc/moin/hyperkittyauth/hyperkittyauth/__init__.py entschieden. Das Verzeichnis /etc/moin/hyperkittyauth ist dann dem Python-Suchpfad für Moinmoin hinzuzufügen. Dies geschieht in Falle von TSC mithilfe von uwsgi (siehe unten IV.). Es ist darauf zu achten, daß Moinmoin für dieses Skript Leserechte erhält.

Moinmoin muß nun noch so konfiguriert werden, daß es nur auf das neu erstellte Modul hyperkittyauth zurückgreift, wenn es Nutzer authentisieren muß. Dazu ist die Wiki-Konfiguration anzupassen, welche sich bei der hier Verwendung findenden Debian/Ubuntu-Paketierung von Moinmoin unter /etc/moin/mywiki.py befindet. In die Konfiguration ist zunächst das neue Modul zu importieren:

import hyperkittyauth

Anschließend ist die Klasse Config um folgende Zeile zu ergänzen:

    auth = [hyperkittyauth.HyperkittyAuth()]

Durch vollständiges Überschreiben von auth wird gewährleistet, daß wirklich nur auf dieses Modul zur Authentisierung zurückgegriffen wird.

IV. Konfiguration von httpd und uwsgi

Was bleibt, ist die Konfiguration von Apache httpd und uwsgi. Zu ersterem gibt es nur wenig zu sagen. Moinmoin wird wie Hyperkitty auch per Reverse Proxy über das WSGI-Protokoll angebunden. Das sieht im Falle von TSC so aus (Einsatz eines UNIX-Domain-Sockets):

Alias /static /usr/share/moin/htdocs
ProxyPass /static !
ProxyPass / unix:/run/uwsgi/app/moinmoin/socket|uwsgi://localhost/

Bemerkenswert an der folgend wiedergegebenen uwsgi-Konfiguration ist vor allem die Direktive pythonpath. Sie ergänzt den Python-Suchpfad um das Verzeichnis /etc/moin/hyperkittyauth, in welchem sich das unter oben III. vorgestellte neue Authentisierungsmodul befindet. So kann Moinmoin dieses auffinden. Im Übrigen bestehen keine Besonderheiten, sieht man davon ab, daß Moinmoin seine WSGI-Datei unter /usr/share/moin/server/moin.wsgi bereitstellt (was der Paketierung geschuldet ist).

[uwsgi]
uwsgi-socket = /run/uwsgi/app/moinmoin/socket
enable-threads = true
chdir = /usr/share/moin/server
wsgi-file = moin.wsgi

master = true
process = 2
threads = 2
uid = www-data
gid = www-data

plugins = python
pythonpath = /etc/moin/hyperkittyauth

V. Schluß

Der Artikel zeigt, daß die Verbindung von Mailman 3 mit Moinmoin mit etwas Aufwand gut zu bewerkstelligen ist. Erfreulich ist, daß durch Verzicht auf eine zusätzliche komplexe Komponente wie einen LDAP-Server der Wartungsaufwand gering gehalten wird. Lediglich zwei kleine Skripte treten hinzu, die man schnell nachvollziehen kann. Trotzdem wird eine vollwertige Single-Sign-On-Lösung umgesetzt, die es den Nutzern fortan ermöglichen wird, ihren TSC-Foren-Account auch für das korrespondierende Wiki zu verwenden.

Genutzte Software-Versionen:

  1. Diese „Reverse Proxy“ genannte Konfiguration mit Frontend-HTTP- und Backend-Anwendungs-Server ist so auch für Ruby-Webanwendungen üblich, wenngleich dort statt WSGI meist ebenfalls einfach auf HTTP zur Kommunikation zwischen Frontend- und Backend-Server zurückgegriffen wird.