PostGraphile-Authentifizierung mit JWT & Security mit RLS

Wir schreiben ja immer davon, dass wir möglichst Beispiele aus der „echten“ Welt in unserer Serie* abbilden wollen und nicht nur auf 30.000 Metern Flughöhe irgendwelche Themen beäugen. Dann wollen wir doch einfach mal Authentifizierung und ein bisschen Security in unserer PostGraphile-Serie mit aufnehmen.

PostGraphile bringt eine ganze Menge Werkzeuge mit, mit der wir Authentifizierung gegen unser GraphQL-API mit JWT-Tokens umsetzen können. Da wollen wir direkt darauf eingehen – und anschließend bohren wir die ganze Sache noch richtig auf, indem wir mit Hilfe des Postgres-Features „RLS“ (Row-Level-Security) sogar genau auf einzelne Zeilen einer Tabelle Zugriffsregeln konfigurieren können!

Um in unserem Beispiel der Blogging-Platform zu bleiben (siehe unser Repo), bauen wir folgende neue Features direkt mit ein:

  1. Authentifizierung als „Writer“ an unserer Platform: idealerweise loggen wir uns mit E-Mail & Passwort ein und erhalten dann einen passenden JWT-Token.
  2. Autorisierung auf Basis von RLS: Regeln, um Änderungen an Blogposts bspw. nur von deren Autoren zuzulassen.

Hinweis

Kurzer Hinweis: was nun folgt, sind nur ein paar Beispiele, wie man Authentifizierung und Autorisierung mit PostGraphile umsetzen kann. Wir orientieren uns hier sehr stark an den Beispielen, wie sie in der Dokumentation auf graphile.org angegeben werden.

Aber wie so oft bei Software-Projekten gilt: Viele Wege führen zum Ziel. Wir wollen hier auf jeden Fall Beispiele zeigen, wie wir es umsetzen könnten. Jedes Projekt ist ein bisschen anders und hat andere Anforderungen.

Wenn ihr Fragen habt, zögert also nicht uns anzusprechen! Wir haben das Thema schon ein paar mal durch diskutiert und geben gerne unseren Senf zum Besten.

* Mehr zum Thema PostGraphile


JWT-Authentifizierung mit PostGraphile

Wir fangen dort an, wo wir beim letzten Mal aufgehört hatten, als unser Katzenbilder-Plug-In eingebaut wurde. Bevor wir JWT-Tokens ausliefern, möchten wir noch eben die  E-Mail-Adresse unserer Autoren in unser Schema aufnehmen, damit wir dann einen einfachen Login mit E-Mail und Passwort abbilden können:

CREATE TABLE Writer
(
    id       SERIAL PRIMARY KEY,
    name     TEXT NOT NULL,
    surname  TEXT NOT NULL,
    email    TEXT NOT NULL UNIQUE CHECK (email ~* '^.+@.+\..+$'), -- Wir Datenkraken wollen nun auch die Mailadresse unserer Writer, samt Regex zur validation - weil warum auch nicht?
    nickname TEXT,
    password TEXT NOT NULL
)

Dann passen wir noch unsere writer_register-Funktion so an, dass wir anstelle von Vorname und Nachname nun die E-Mail-Adresse prüfen:

-- functions.sql
CREATE FUNCTION writer_register(
    name text,
    lastname text,
    password text,
    email text,
    nickname text = NULL
) RETURNS writer
    STRICT
    SECURITY DEFINER
    LANGUAGE plpgsql AS
$$
DECLARE
    result writer;
BEGIN
    IF (SELECT EXISTS(SELECT 1
                      FROM writer w
                      WHERE w.email = writer_register.email)) THEN
        RAISE EXCEPTION 'Writer with the email address already exists!';
    END IF;
 
    IF (LENGTH(writer_register.password) < 8) THEN
        RAISE EXCEPTION 'Password is not strong enough! Should be at least 8 characters long!';
    END IF;
 
    INSERT INTO writer (name, surname, nickname, email, password)
    VALUES (writer_register.name, writer_register.lastname,
            writer_register.nickname,
            writer_register.email,
            crypt(writer_register.password, gen_salt('bf', 8)))
    RETURNING * INTO result;
 
    RETURN result;
END
$$;

Basics

Fangen wir direkt damit an, einen Typen in unserer Postgres-Datenbank zu definieren, der die Form unseres JWT-Tokens bestimmt. Wir brauchen erst einmal nur eine writer_id und einen Ablaufzeitpunkt für den Token:

CREATE TYPE jwt_token as
(
    writer_id integer,
    exp       bigint
)

Wir erstellen einen Einfachen Komposit-Typen, um unseren JWT-Token zu definieren.

Um PostGraphile unseren jwt_token als passenden Typen zu verwenden, können wir im CLI-Modus einfach --jwt-token-identifier jwt_token als Argument anhängen. Damit PostGraphile die JWT-Tokens auch signieren kann, brauchen wir noch einen Wert für  --jwt-secret.

Wer wie im Beispiel zum Plug-In-System bereits auf den Library-Modus gewechselt hat, kann das selbe mit folgendem Code in unserer Node-Anwendung erreichen:

//server.ts
 
http.createServer(
    postgraphile(
        {
            host: process.env.PGHOST,
            user: process.env.PGUSER,
            password: process.env.PGPASSWORD,
            database: process.env.PGDATABASE,
        },
    "public", {
        watchPg: true,
        graphiql: true,
        enhanceGraphiql: true,
        jwtPgTypeIdentifier: 'public.jwt_token', //Unser in der Datenbank neu-angelegter Typ
        jwtSecret: 'Supersecret', //Das wollen wir im echten Betrieb natürlich ändern und ggF. als Umgebungsvariable hinein reichen.
        appendPlugins:
            [PostGraphileConnectionFilterPlugin, BlogPostTitleImagePlugin]
    })
).listen(5000);

Login Mutation

Um einen Login durchzuführen, brauchen wir nun nur noch eine passende Mutation die E-Mail und Passwort entgegen nimmt und – sollte das Passwort stimmen – einen jwt_token zurück gibt.

CREATE FUNCTION writer_authenticate(email text, password text) RETURNS jwt_token
    LANGUAGE plpgsql AS
$$
    DECLARE _writer writer;
    BEGIN
        SELECT w.* into _writer from writer as w where (w.email) = ($1);
 
        IF _writer.password = crypt($2, _writer.password) then
            RETURN (_writer.id, extract(epoch  from (now() + interval '2 days')))::jwt_token;
        ELSE
            RETURN NULL;
        ENDIF;
    END;
$$

Dann starten wir doch mal mit docker-compose up unser API und schauen auf http://localhost:5000/graphiql. Hier können wir dann in der GraphiQL-Anwendung testweise einen Writer registrieren und uns anschließend mit dessen Credentials einen Token abholen:

Weil wir mit der Option jwtTokenIdentifier unseren Komposit-Typen jwt_token eingestellt haben, erkennt PostGraphile automatisch, dass wir diesen Typen für unseren JWT-Workflow verwenden wollen und kümmert sich direkt darum, das ganze mit unserem JwtSecret zu signieren. Und wir erhalten einen ordentlichen Token anstelle einfacher Werte – ziemlich cool oder?

Die Grundlagen sind geschaffen

Damit haben wir schon einmal die Grundlagen geschaffen, Authentifizierung in unserem API abzubilden. Um das Ganze im Verlauf der Entwicklung etwas bequemer untersuchen zu können, bauen wir noch eine Query „whoami“ ein, mit der wir einfach nur mit unserer writer_id, die wir in unseren Token packen und unsere Writer-Daten erhalten – sofern unser JWT-Token vorhanden und gültig ist:

CREATE FUNCTION whoami() RETURNS WRITER LANGUAGE sql stable AS
$$
SELECT *
FROM WRITER
WHERE id = current_setting('jwt.claims.writer_id', true)::integer
$$

Wenn wir während der Entwicklung ein wenig manuell Debuggen wollen, können wir innerhalb einer Transaktion auch einfach SET LOCAL Verwenden, um uns die entsprechenden Token-Claims zu geben, mit denen wir arbeiten wollen. Das ist das, was PostGraphile sonst für uns unter der Haube macht:

BEGIN;
SET LOCAL jwt.claims.writer_id to 2;
SELECT whoami(); -- (2,Thomas,Blank,foo@bar.com,Blanky,$2a$08$d3LXl.p8PFlKB2RkZRFVLejyRCTw0TzZLrMwybVFn89vo8GwE739G)
COMMIT;

Wir können nun unter http://localhost:5000/graphiql direkt den Login-Workflow simulieren, indem wir zuerst unsere Auth-Mutation verwenden. Dann können wir den Token in unserem GraphiQL einfach in der unteren Leiste unter „Request Headers“ als „Authorization Bearer“ kopieren und verwenden.

Super! Jetzt kann unser API anhand des Tokens erkennen, wer wir „sind“. Dann können wir endlich anfangen Mechanismen einzubauen die verhindern, dass meine Kollegen ständig fremde Posts ändern, sondern jeder Author nur seine eigenen.

Row-Level-Security

Zunächst sollten wir die grundlegende Frage klären: Worum geht’s eigentlich?

Nun, „traditionelle“ Backend-Anwendungen verwenden ihre Datenbanken oft nur als simple Datenhalden. Oft hat man ein bisschen Backend-Code (in PHP, Go, Python, Java, oder was auch immer), der sich um die Security kümmert, Tokens ausliefert oder Sessions anlegt. Datenbanken werden automatisch mit irgendwelchen ORM-Bibliotheken in irgendwelche Datenbeutel-Objekte gepackt, die dann im Backend-Code ausgelesen werden, wo wir eine Art „Guards“ haben, welche die Rechte der Nutzer ausliest und dann entsprechende 403- oder 401-HTTP-Antworten zurück liefern. Beispielsweise kann man mal einen Blick auf Symfony’s Security-Voter werfen.

Früher war das notwendig, weil Datenbanksysteme schlicht nicht so granuläre Sicherheitskonzepte abbilden konnten, wie wir sie für unsere Anwendungen gebraucht haben. Das Doofe daran ist, dass es für unsere Backend-Entwickler eine neue Domäne aufmacht, um die wir uns kümmern müssen.

Das heißt dann aber auch, dass das bei neuen Daten und Features im besten Falle niemand vergisst – und wir zudem noch eine ganze Schicht in unserer Geschäftslogik haben, die sich um Authentification und Authorization kümmert. Oft kommen dann noch andere Anwendungen hinzu, die auch mit der Datenbank interagieren wollen – dann müssen wir entweder einen expliziten Service bauen, der sich dazwischen klemmt, oder die ganze Auth-Logik bei den neuen Anwendungen replizieren. Das gibt dem ganzen ein gewisses Entwicklungs-Risiko und kann ein Bottleneck bei der Entwicklung und ggf. der Perfomance der Anwendungen darstellen.

Auf den Kopf gestellt

Was ist aber, wenn wir diese Idee einfach mal auf den Kopf stellen und Rechtemanagement auf der „untersten“ Ebene unserer Anwendungen abbilden – in der Datenbank? Was also, wenn wir uns in unserem Backend-Code nicht mehr um diesen Sonderfall kümmern müssen, sondern den Datenbank-Server einfach selbst direkt entscheiden lassen können, ob ein gewisser Nutzer Daten lesen, hinzufügen, ändern oder löschen darf?

Klingt utopisch oder? – Aber genau das ist seit 2016 mit Postgres 9.5 möglich! Mit dem Feature Row-Level-Security (RLS) können wir unseren Postgres-Server einfach selbst entscheiden lassen, ob ein spezifischer Nutzer in bestimmten Tabellen Daten manipulieren darf oder nicht! Wenn wir das ganze mit unseren JWT-Tokens aus PostGraphile kombinieren, sollte es keinen Weg vorbei an unserer Authorizations-Logik geben, wenn wir Postgres richtig konfigurieren!

Genug geschwärmt, wir machen das jetzt einfach mal!

Alle Schotten dicht!

PostGraphile verwendet erst einmal den SQL-Nutzer (den wir entsprechend konfigurieren) und agiert mit dessen Rechten. Um Row-Level-Security zu verwenden, müssen wir diese erst einmal explizit für unsere Tabellen aktivieren (andernfalls greift nur das „klassische“ Authorizations-Konzept von SQL).

Wir aktivieren RLS bei unseren Tabellen writer und blogpost, am besten erstellen wir direkt eine „anonyme“ Rolle.

CREATE ROLE graphile LOGIN PASSWORD 'graphile' -- wir erstellen eine spezifische Rolle für unseren Postgraphile-Server
CREATE ROLE anonymous;
GRANT anonymous TO graphile
 
ALTER TABLE writer ENABLE ROW LEVEL SECURITY;
ALTER TABLE blogpost ENABLE ROW LEVEL SECURITY;

Dann ändern wir noch unsere Umgebungsvariablen im docker-compose so, dass unser PostGrahile-Server nun auch die Credentials der neuen graphile-Rolle verwenden soll. In unserer server.ts teilen wir PostGraphile noch mit, dass wir standardmäßig mit der Rolle anonymous agieren wollen:

--- docker-compose.yml
  postgraphile:
    build:
      context: .
      dockerfile: Postgraphile.Dockerfile
    restart: unless-stopped
    network_mode: service:db
    depends_on:
      - db
      - migration
    environment:
      PGPASSWORD: graphile
      PGUSER: graphile
      PGDATABASE: postgres
      PGHOST: db
      CAT_API_KEY: '---'
 
--- server.ts
http.createServer(
    postgraphile(
        {
            host: process.env.PGHOST,
            user: process.env.PGUSER,
            password: process.env.PGPASSWORD,
            database: process.env.PGDATABASE,
             
        },
        "public", {
            watchPg: true,
            graphiql: true,
            enhanceGraphiql: true,
            jwtPgTypeIdentifier: 'public.jwt_token',
            jwtSecret: 'Supersecret',
            pgDefaultRole: 'anonymous', -- Unsere queries sollten immer erstmal mit der Rolle anonymous ausgeführt werden.
            appendPlugins:
                [PostGraphileConnectionFilterPlugin, BlogPostTitleImagePlugin]
    }
    )
).listen(5000);

Wenn wir dann Blogposts oder Writer in unserem GraphiQL anfragen wollen, bekommen wir als Antwort erst einmal ein deutliches „NEIN“:

Das ist doch schon mal etwas Gutes!

RLS hilft uns, unsere Security-Use-Cases abzubilden

Natürlich wollen wir das Ganze so nicht stehen lassen. Wir nutzen allerdings die Gelegenheit, uns einmal klar zu werden, welche Use-Cases wir überhaupt unterstützen wollen, bevor wir uns weiter in den Code stürzen.

Wer darf was lesen?

Lesen von Blogposts, sollte auch nicht-eingeloggten Nutzern ermöglicht werden. Wir wollen also „SELECT“ Operationen auf Blogposts, grundsätzlich mindestens für die Rolle anonymous zulassen. Das selbe sollte auch für das Lesen von Autoren-Profilen gelten.

Eingeloggte Nutzer sind auf unserer Plattform erst einmal nur andere Autoren. Für sie erstellen wir auch gleich eine Rolle und erlauben unserer graphile-Rolle Zugriff darauf.

CREATE ROLE writer_role;
GRANT writer_role TO graphile
 
GRANT USAGE ON SCHEMA public TO anonymous, writer_role; -- Das bedeutet nur, dass die Rollen nur auf Objekte in unserem Schema zugreifen darf, noch nicht auf Daten innerhalb der Tabellen!
 
GRANT SELECT ON TABLE writer TO anonymous, writer_role; -- Sowohl eingeloggte sowie nicht-eingeloggte Nutzer dürfen in den Tabellen Daten lesen.
GRANT SELECT ON TABLE blogpost TO anonymous, writer_role;
 
CREATE POLICY select_writer ON writer FOR SELECT USING (true); -- Hier reichen schon die Grants aus den Zeilen darüber, um SELECTS zuzulassen, daher reicht es wenn unsere Policy einfach "JA" sagt :)
CREATE POLICY select_blogpost ON blogpost FOR SELECT USING (true);

Registrieren als neuer Autor

GRANT EXECUTE ON FUNCTION writer_register(text, text, text, text, text) TO anonymous

Änderungen an den Daten unserer Autoren

Ihre eigenen Daten sollten nur eingeloggte Autoren ändern können. Dafür müssen wir allerdings noch einmal kurz unseren JWT-Token so anpassen, dass dieser auch die Postgres-Rolle als Claim enthält. Dadurch kann PostGraphile automatisch die Rolle aus dem JWT-Token für die Queries verwenden:

CREATE TYPE jwt_token as
(
    role text,
    writer_id integer,
    exp       bigint
);
 
--- Dann noch in unseren JWT- packen...
CREATE FUNCTION writer_authenticate(email text, password text) RETURNS jwt_token
    LANGUAGE plpgsql AS
$$
DECLARE
    _writer writer;
BEGIN
    SELECT w.* into _writer from writer as w where (w.email) = ($1);
 
    if _writer.password = crypt($2, _writer.password) then
        return ('writer_role', _writer.id,
                extract(epoch from (now() + interval '2 days')))::jwt_token;
    else
        return null;
    end if;
END ;
$$

Dann können wir einfach der Rolle writer_role UPDATE und DELETE ermöglichen. Außerdem können wir eine Row-Level-Policy einbauen, die sicherstellt, dass wir auch nur unseren eigenen Nutzer erwischen:

GRANT UPDATE, DELETE ON TABLE writer TO writer_role;
 
CREATE POLICY update_writer ON writer FOR UPDATE TO writer_role
    USING (id = current_setting('jwt.claims.writer_id', true)::integer);
 
CREATE POLICY delete_writer ON writer FOR DELETE TO writer_role
    USING (id = current_setting('jwt.claims.writer_id', true)::integer)

Änderungen an Blogposts und neue Blogposts erstellen

Den eigenen Blogposts sollten nur deren Autoren ändern dürfen! Erstellen von neuen Blogposts sollte wiederum für jeden eingeloggten Autor funktionieren:

GRANT INSERT, UPDATE, DELETE ON TABLE blogpost TO writer_role;
 
GRANT USAGE ON SEQUENCE blogpost_writer_id_seq to writer_role;
GRANT USAGE ON SEQUENCE blogpost_id_seq to writer_role;
 
CREATE POLICY insert_blogpost ON blogpost FOR INSERT TO writer_role
    WITH CHECK (writer_id =
                current_setting('jwt.claims.writer_id', true)::integer);
 
CREATE POLICY update_blogpost ON blogpost FOR UPDATE TO writer_role
    USING (writer_id = current_setting('jwt.claims.writer_id', true)::integer);
 
CREATE POLICY delete_blogpost ON blogpost FOR DELETE TO writer_role
    USING (writer_id = current_setting('jwt.claims.person_id', true)::integer)

WITH CHECK vs USING

Bei der Policy insert_blogpost haben wir unsere Prüfung in eine WITH CHECK-Klausel stehen anstelle von USING – warum?

Grob gesagt liegt der Unterschied in der Reihenfolge, in der die Klauseln evaluiert werden. Wenn wir WITH CHECK verwenden, wird die Prüfung innerhalb der Transaktion nach dem INSERT getätigt – was ja auch einleuchtet, wenn wir bspw. einen Blogpost erst erstellen, dann hat dieser auch erst einmal keine writer_id gegen die wir prüfen müssen!

Postgres erzwingt auch die Regelung, dass Policies für INSERTS mit einer WITH CHECK-Klausel verwendet werden. Parallel dazu sind wir bei Policies die ein FOR DELETE handhaben von Postgres auch gezwungen, hier eine USING-Klausel zu verwenden. Denn in der Transaktion können wir nicht im „Nachhinein“ irgendwelche Daten prüfen, da diese gelöscht sein sollen. Ein wichtiges Detail, das wir während unserer Entwicklung definitiv im Auge behalten wollen.

Zusammenfassung

Und und schon sind wir fertig! Wenn wir einmal auf unser GraphiQL navigieren (http://localhost:5000/graphiql), können wir den gesamten Ablauf durchspielen: Nutzer registrieren, als dieser Nutzer einloggen, unseren JWT-Token als Bearer Token im Authorization-Header hinterlegen, und direkt Blogposts erstellen, ändern und löschen. Cool!

Hier kommt jetzt kein Bild, sondern die Motivation, dass ihr das einfach einmal selbst in der GraphiQL-Anwendung ausprobiert und in den API-Antworten beobachtet, wie PostGraphile in den unterschiedlichen Fällen antwortet.

Wir haben heute einiges an Strecke gemacht. Unsere Nutzer können sich nun Registrieren, Einloggen, Posts erstellen, ihre Posts anpassen und Löschen, sowie ihre eigenen Stammdaten ändern.

Dazu haben wir erst einmal unsere Login und Registration auf E-Mail & Passwort umgestellt und uns JWT-Tokens ausgedacht.

Dann haben wir das Ganze noch mittels RLS in unserer Datenbank festgezurrt, so dass auf der Datenbankebene ganz fest konfiguriert ist, unter welchen Umständen ein Nutzer welche Daten manipulieren oder lesen darf.

Ein sehr ähnliches und noch etwas detailliertes Beispiel gibt es als offizielles Tutorial von PostGraphile, in dem ihr noch mehr Details herauslesen könnt, wie PostGraphile hier unter der Haube agiert.

Code für alle Anpassungen die wir heute gemacht haben, finden wir auch auf GitHub in einem eigenen Branch.

Somit ist unser GraphQL-API in ihrer Funktionalität eigentlich schon so weit, dass wir das API auf Nutzer loslassen oder mit der Entwicklung eines passenden Frontends beginnen können. Bis auf den Library-Modus von PostGraphile (der noch nicht einmal zwingend notwendig ist), haben wir alle unsere Einstellungen und Policies in SQL festhalten können und mussten dafür keine Zeile PHP oder Ähnliches schreiben.

Darüber hinaus: Lesetipps

Ich hoffe, ich konnte wieder einmal meine Begeisterung für PostGraphile ein wenig mit euch teilen! Mit ein bisschen SQL und ein paar Einstellungen an PostGraphile haben wir hier binnen weniger Minuten einen kompletten Login- und Authorizations-Prozess in unserem API implementiert. Zudem sind unsere Regeln mit RLS auf der Datenbankkonfiguration festgelegt, das heißt also auch, dass wir bei neuen Features erst einmal keine Angst haben müssen, ggf. irgendwelche Sicherheitslücken aufzureissen, weil wir vergessen einen passenden Symfony-Security-Voter zu implementieren, sondern unsere Datenbank sich selbst darum kümmert und im Zweifel, keine Rechte erteilt, die nicht explizit eingestellt sind.

Wer noch weiter gehen möchte, und seine Tokens anstelle von JSON-Antworten lieber als Cookies ausgeben möchte, kann hier noch einmal etwas dazu lesen, wie wir PostGraphile mit Express verheiraten können, um genau das zu machen.

Wollen wir unsere Tokens nicht selbst ausstellen sondern von einem externen Service erhalten und validieren (bspw. Auth0 oder OpenID-Connect), dann können wir unseren Blick auch wieder auf die Dokumentation von PostGraphile lenken.

… jetzt aber wirklich!

Also: PostGraphile ist offensichtlich nicht einfach nur ein kleines Tool, um „testweise“ kleine CRUD-APIs zu bauen. Nein, wir können mit Hilfe allerlei Einstellungsoptionen und kleinen Erweiterungen ein voll funktionsfähiges und komplexes GraphQL-API aufbauen, und das mit nach meinem Dafürhalten vergleichsweise geringerem Aufwand als bei herkömmlichen Backend-Frameworks.

Egal, welche Features wir noch dazu packen – wir verlieren nicht die automatische Generation unseres Schemas, unsere GraphQL- und SQL – Typen. Schon alleine das Arbeiten und Debugging mit der GraphQL-Anwendung bietet einen unheimlichen Komfort, gepaart mit dem automatischen Schema-Watching von PostGraphile haben wir hier eine einmalige Developer-Experience, die ich bei anderen Backend-Frameworks noch vermisse (selbst wenn ich dafür PHP schreiben müsste).

Für neue Blogupdates anmelden:


Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.