Synapse JWT-only Login
Status: LIVE seit 2026-05-12 auf allen Tenants
Konzept: Option C aus Prilog-Auth-Konzept-Reihe
Warum?
Vor dem Umbau konnte sich ein Angreifer mit einem regulären Matrix-Client (Element, FluffyChat, Curl) direkt gegen https://<tenant>.prilog.team/_matrix/client/v3/login mit Username + Passwort einloggen. Synapse validierte gegen seine users.password_hash (lokale Postgres-DB). Das ist Standard-Matrix-Verhalten, aber für Prilog ein Problem: Nutzer sollen ausschließlich über die Prilog-Apps gehen, weil dort Berechtigungen, Audit-Logging, Refresh-Tokens und Rate-Limiting laufen.
Die JWT-only-Architektur schließt diesen Pfad: Synapse akzeptiert nur noch HS256-JWTs, die das Backend mintet. Externe Matrix-Clients können keinen gültigen Token erzeugen, weil ihnen das pro-Tenant-Shared-Secret fehlt.
Architektur
+--------+ +----------+ +----------+ +---------+
| Client | ---> | Backend | ---> | Synapse | ---> | Postgres|
+--------+ +----------+ +----------+ +---------+
Klartext-Password | | |
| bcrypt-Verify | Hook prueft JWT |
| (Prilog-DB) | (HS256 + iss + exp)|
| JWT minten | |
| -> Synapse /login | -> access_token |
| <- access_token | |
<- prilog_jwt+ -<--+ | |
matrix_token | |Komponenten
1. Connector-Hook (prilog-matrix-connector/module.py)
In jedem Synapse läuft das Prilog-Matrix-Connector-Modul, das register_password_auth_provider_callbacks registriert. Der Hook akzeptiert m.login.password-Requests, parst den password-Feld als HS256-JWT, verifiziert mit dem tenant-spezifischen Secret und gibt (matrix_user_id, None) zurück. Verifikation nutzt pure-Python HMAC-SHA256 (kein pyjwt-Dependency).
async def _check_jwt_password(self, username, login_type, login_dict):
token = login_dict.get('password', '').strip()
if not token or token.count('.') != 2:
return None # kein JWT-Format → vom Hook ignorieren
# decode + verify HMAC-SHA256 + check iss + check exp
if header.alg != 'HS256': return None
if iss != self._config.jwt_issuer: return None
if exp < time.time(): return None
return (f"@{sub}:{self._config.matrix_domain}", None)2. Backend-Service (services/synapse-jwt-login.service.ts)
Eine zentrale Funktion mintet das JWT und sendet es als m.login.password an Synapse.
synapseLoginViaJwt(tenantId, matrixLocalPart, deviceName)
→ { userId, accessToken, deviceId }Aufrufer (drei Admin-Helper + auth-v1):
loginAdminSynapse(admin/_helpers/synapse.ts)loginSynapseAdmin(customer/_helpers/synapse.ts)loginPlatformSynapseAdmin(platform-v1.ts)auth-v1.ts /login(User-Login durch Web-Client / Mobile)
3. Password-Hash-Store (MatrixUserPassword in Prilog-DB)
User-Passwords leben jetzt in prilog.matrix_user_passwords als bcrypt-Hash:
CREATE TABLE matrix_user_passwords (
id text PRIMARY KEY,
tenant_id text NOT NULL,
matrix_local_part text NOT NULL,
password_hash text NOT NULL,
source text DEFAULT 'migrated',
failed_attempts int DEFAULT 0,
locked_until timestamp,
last_used_at timestamp,
UNIQUE(tenant_id, matrix_local_part)
);source ist eine Telemetrie für die Herkunft: migrated (initial aus Synapse-DB), user_change (Self-Service), admin_reset (durch Admin), initial_provision (bei User-Create).
4. Synapse-Config (homeserver.yaml)
modules:
- module: prilog_matrix_connector.module.PrilogMatrixConnectorModule
config:
...
jwt_secret: "<32-byte hex>" # tenant_settings.synapse_jwt_secret
jwt_issuer: "prilog-backend"
password_config:
localdb_enabled: false # Synapse-Built-in Password-Auth aus5. nginx-Defense-in-Depth (/etc/nginx/sites-enabled/<tenant>.prilog.team.conf)
location ~ ^/_matrix/client/(r0|v[0-9]+)/login {
return 403 '{"errcode":"M_FORBIDDEN","error":"Direct Matrix login disabled. Use Prilog Backend at /api/auth/v1/login."}';
default_type application/json;
}Login-Flow im Detail
Web-Client / Mobile-App ruft /api/auth/v1/login
POST /api/auth/v1/login HTTP/1.1
Host: api.prilog.chat
Content-Type: application/json
{
"tenant": "demo", // oder "demo.prilog.team"
"username": "anna",
"password": "AnnaSpwd-2026",
"deviceName": "Prilog Mobile iOS", // optional
"issueRefreshToken": true // optional, default true bei /login
}Backend-Schritte
- Tenant aus
serverOrder.matrixDomain == "<tenant>.prilog.team"aufgelöst →tenantId verifyPrilogUserPassword(tenantId, "anna", "AnnaSpwd-2026")— bcrypt-Verify gegen Prilog-DB. Fehler: 401 (generisch, kein User-Existenz-Leak). Locked: 429.synapseLoginViaJwt(tenantId, "anna", "Prilog Mobile iOS"):- JWT minten:
{ sub: "anna", iss: "prilog-backend", iat: now, exp: now+60s } - Signieren mit
tenant_settings.synapse_jwt_secret(HS256) - POST an
<synapse-baseUrl>/_matrix/client/v3/login:json{ "type": "m.login.password", "identifier": { "type": "m.id.user", "user": "anna" }, "password": "<jwt-token>", "initial_device_display_name": "Prilog Mobile iOS" } - Synapse-Hook akzeptiert → access_token + user_id + device_id
- JWT minten:
- Prilog-JWT signieren (Tenant + Rolle + Permissions)
- Optional Refresh-Token erstellen
Antwort
{
"token": "eyJhbGciOiJIUzI1NiI...", // Prilog-JWT (24h)
"expiresIn": 86400,
"matrixAccessToken": "syt_YW5uYQ_abcdef...", // Synapse-Token
"matrixUserId": "@anna:demo.prilog.team",
"homeserver": "demo.prilog.team",
"refreshToken": "...", // optional
"refreshTokenExpiresAt": "2026-06-11T20:48:00.000Z"
}Password-Change-Flow
POST /api/auth/v1/change-password HTTP/1.1
Host: api.prilog.chat
Authorization: Bearer <prilog_jwt>
Content-Type: application/json
{
"currentPassword": "AnnaSpwd-2026",
"newPassword": "NeueAnnaSpwd-2026"
}→ 204 No Content (Erfolg), 401 (currentPassword falsch), 429 (Locked).
Wichtig: Es wird kein Aufruf an Synapse gemacht. Synapse hat keinen Password-Store mehr — Prilog-DB ist die einzige Truth-Source.
Admin-Password-Reset (Schul-Admin setzt User-Password)
Schul-Admin → Customer-Portal → POST /api/customer/users/:userId/reset-password:
- Berechtigung prüfen (manageUsers).
setPrilogUserPassword(tenantId, localPart, newPassword, 'admin_reset')→ bcrypt-Hash in Prilog-DB./_synapse/admin/v1/reset_password/...(No-Op nach D2, aber wird mitgepflegt für Re-Migration-Sicherheit).- UserDirectoryHistory-Eintrag.
Lockout
Nach 10 fehlgeschlagenen Login-Versuchen wird ein Konto für 15 Minuten gesperrt (locked_until-Spalte). Erfolgreicher Login setzt den Counter zurück.
Tenant-Setup (Neuer Tenant)
Beim Provisioning eines neuen Tenants müssen folgende Schritte gemacht werden (Reconcile-Service tut das automatisch beim ersten Reconcile-Lauf):
- JWT-Secret generieren: 32-Byte hex (64 chars), z.B.
crypto.randomBytes(32).toString('hex'). - In
tenant_settings.synapse_jwt_secretspeichern für den Tenant. - In
homeserver.yamlpatchen untermodules[].config.jwt_secret(+jwt_issuer: prilog-backend). password_config.localdb_enabled: falsein homeserver.yaml.- Container env
PYTHONUNBUFFERED=1in docker-compose.yml (sonst werden Hook-Logs gebuffert und Debug ist Hölle). - nginx-Block für /_matrix/client/*/login in
/etc/nginx/sites-enabled/<slug>.conf.
Setup-Script: scripts/setup-jwt-all-tenants.ts --apply (idempotent).
Spec v6 & Smoke-Tests
Tenant-Box-Spec v6 (specs/tenant-box-v6.yaml) codifiziert all das. Zwei neue Smoke-Probes prüfen den End-to-End-Zustand täglich:
| Probe | Was wird geprüft? | Required? |
|---|---|---|
synapse_jwt_login_works | Backend mintet JWT → Synapse antwortet 200 mit access_token | ja |
external_login_blocked | POST /login mit Klartext → HTTP 403 (nginx oder Synapse) | ja |
Daten-Migration (One-Off)
Beim Roll-Out wurden alle Synapse-Hashes (users.password_hash) in matrix_user_passwords übernommen:
npx tsx scripts/migrate-synapse-passwords.ts
# bzw. DRY_RUN=1 zum TestSynapse-Hashes sind Standard $2b$12$-bcrypt ohne Pepper — direkt mit Node-bcrypt verifizierbar, kein Re-Hash nötig.
Notfall-Rollback
Wenn alle JWT-Wege brechen und User sich nicht mehr einloggen können:
- homeserver.yaml auf jedem betroffenen Tenant:
password_config.localdb_enabled: true(bzw. Block entfernen). - nginx-Block rauskommentieren in
<tenant>.prilog.team.conf,nginx -s reload. docker compose up -d synapsepro Tenant.- Web-Client bleibt auf neuem Flow — er ruft Backend
/login, das ruft via JWT → Synapse. Wenn JWT bricht: bcrypt-Hash ist noch in Synapse-DB (wurde nicht gelöscht), kann re-aktiviert werden indem auth-v1.ts auf/_matrix/client/v3/loginzurückgestellt wird.
Audit-Log (was der Hook loggt)
2026-05-12 21:03:10,083 - prilog_matrix_connector.module - 150 - DEBUG - JWT-Hook ENTRY: username=demo login_type=m.login.password
2026-05-12 21:03:10,083 - prilog_matrix_connector.module - 220 - INFO - JWT-Login OK for @demo:demo.prilog.teamDEBUG-Logs erscheinen nur wenn log.config entsprechend konfiguriert ist (prilog_matrix_connector: DEBUG). Container braucht PYTHONUNBUFFERED=1 damit die Logs sofort sichtbar werden.
Stolpersteine bei der Implementation
(Für zukünftige Self-Debug-Sessions dokumentiert.)
docker compose restartreicht nicht wenn Connector-Code via Bind-Mount neu mountet wird.docker compose up -d <svc>oderstop + rm + up -d.PYTHONUNBUFFERED=1ist Pflicht, sonst sind Hook-Logs erst Minuten später sichtbar — und man denkt der Hook wird nicht aufgerufen./_synapse/admin/v1/reset_passwordwirft nachlocaldb_enabled: falsekeinen Fehler, hat aber keine Wirkung. Backend-Code darf nicht hart darauf reagieren.- Synapse-Hash-Format ist Standard bcrypt (
$2b$12$...), Node-bcrypt liest es ohne Konvertierung. Keine Pepper-Konfiguration nötig (wir nutzen keine). getSynapseConnectionmischt Settings + ServerOrder-Fallback unabhängig pro Feld —synapse_base_url-Setting undsynapse_admin_token-Setting werden getrennt aufgelöst (Bug-Fix 2026-05-12).