Geolocation, E-Mail-System & 2FA-Erweiterung
Datum: 2026-04-09
Ueberblick
Abschnitt betitelt „Ueberblick“Drei eng verzahnte Features fuer Stufe 1:
- MaxMind IP-Geolocation — IP-Adressen nach Stadt/Region/Land aufloesen, Ergebnisse in DB speichern
- E-Mail-System — DB-gestuetztes Template-System mit 3-Ebenen-Hierarchie, Queue-basierter Versand via AWS SES
- 2FA-Erweiterung — Device-Erkennung, Known-Device-Cookie, Geolocation in 2FA-Mail, Session 30 Tage
1. MaxMind IP-Geolocation
Abschnitt betitelt „1. MaxMind IP-Geolocation“Beim Login von einem unbekannten Device zeigt die 2FA-E-Mail dem User den Standort an: “Login-Versuch von Berlin, Brandenburg, Deutschland”. Falls Geolocation nicht verfuegbar: nur IP anzeigen.
Datenquelle
Abschnitt betitelt „Datenquelle“- Datenbank: GeoLite2-City.mmdb (nur City, kein Country/ASN separat)
- Quelle: GitHub-Mirror
P3TERX/GeoLite.mmdb— kein MaxMind-Account noetig - URL:
https://github.com/P3TERX/GeoLite.mmdb/raw/download/GeoLite2-City.mmdb
Lokaler Pfad
Abschnitt betitelt „Lokaler Pfad“| Umgebung | Pfad |
|---|---|
| Entwicklung | .local/maxmind/GeoLite2-City.mmdb |
| Produktion | /data/maxmind/GeoLite2-City.mmdb |
Lifecycle
Abschnitt betitelt „Lifecycle“- Beim Start: Download von GitHub → Datei speichern → Reader in Memory laden
- Falls Download fehlschlaegt: Fallback auf vorhandene lokale Datei
- Falls keine vorhanden: Warning loggen, Geolocation deaktiviert (kein Crash)
- Taeglich 03:00 UTC: Goroutine laedt DB neu herunter → neuer Reader → alter Reader wird geschlossen
- Atomarer Swap via
atomic.Pointer[maxminddb.Reader]— kein Lock, thread-sicher - Falls Download fehlschlaegt: bestehender Reader bleibt aktiv, Fehler loggen
- Atomarer Swap via
Sprache
Abschnitt betitelt „Sprache“MaxMind liefert City/Region/Country in Deutsch und Englisch:
- Deutsch ist der Standard (
location_de) - Englisch ist der Fallback (
location_en)
Format: "{City}, {Region}, {Country}" — z.B. “Berlin, Brandenburg, Deutschland”
Komponenten
Abschnitt betitelt „Komponenten“internal/feature/geolocation/├── geolocation.go # Geolocation struct, LocationFromIP(), Start(), Shutdown()├── maxmind_reader.go # Download, Laden, atomarer Swap, Cron-Reload└── errors.go # Sentinel Errors (ErrGeolocationUnavailable)Geolocation struct:
LocationFromIP(ip string) (Location, error)— gibt Location zurueckStart(ctx context.Context)— initialer Download + Cron startenShutdown()— Reader schliessen
Location struct:
type Location struct { CityDe string CityEn string RegionDe string RegionEn string CountryDe string CountryEn string CountryCode string // ISO Alpha-2, z.B. "de"}
func (l Location) DisplayDe() string // "Berlin, Brandenburg, Deutschland"func (l Location) DisplayEn() string // "Berlin, Brandenburg, Germany"Datenbank-Tabelle: geolocation
Abschnitt betitelt „Datenbank-Tabelle: geolocation“Folgt dem BIS-Pattern: geolocation.id = ID der Eltern-Entitaet (1:1-Erweiterung).
Jede Aktion bekommt ihren eigenen Geolocation-Eintrag — kein Cache/Dedup pro IP.
CREATE TABLE traffino.geolocation ( id UUID PRIMARY KEY, -- = ID der Eltern-Entitaet created_at TIMESTAMPTZ, created_by TEXT, updated_at TIMESTAMPTZ, updated_by TEXT, row_version INT NOT NULL DEFAULT 0, row_period TSTZRANGE, -- Business account_id UUID REFERENCES traffino.accounts(id), -- nullable ip TEXT NOT NULL, device TEXT, -- User-Agent location_de TEXT, -- "Berlin, Brandenburg, Deutschland" location_en TEXT -- "Berlin, Brandenburg, Germany");
CREATE INDEX idx_geolocation_account_id ON traffino.geolocation (account_id);SELECT create_base_triggers('traffino.geolocation');Referenzierung:
sessions:geolocation.id = session.idlogin_attempts:geolocation.id = login_attempt.idknown_devices:geolocation.id = known_device.id- Spaeter erweiterbar auf Password-Reset, Kontaktanfragen etc.
Integration
Abschnitt betitelt „Integration“Die Geolocation-Erstellung wird als Helper-Funktion im Repository bereitgestellt:
func createGeolocation(ctx context.Context, db *sql.DB, geo *geolocation.Geolocation, relationID uuid.UUID, accountID *uuid.UUID, ip string, userAgent string)Aufgerufen bei: Session-Erstellung, Login-Versuch, Known-Device-Erstellung.
2. E-Mail-System
Abschnitt betitelt „2. E-Mail-System“Architektur
Abschnitt betitelt „Architektur“Dreistufiger, vollstaendig asynchroner Versand (nach BIS-Vorbild):
UseCase erstellt nur das Business-Event │ z.B. LoginAttempt mit twofactor_code, │ Account mit activated_at = NULL, etc. │ ▼Preparation-Worker (Goroutine, alle 10s) │ Registrierte Handler pruefen auf ausstehende Events │ Handler-Query: "Gibt es Events die eine Mail brauchen │ UND fuer die noch keine system_message existiert?" │ (Idempotenz per NOT EXISTS-Subquery) │ │ Fuer jeden Fund: │ ├── Template + Keys aufloesen (3-Ebenen-Lookup) │ ├── HTML rendern │ └── system_message erstellen (Status: pending) ▼system_message Tabelle (Status: pending) │ ▼Dispatch-Worker (Goroutine, alle 10s) │ Liest pending-Mails │ Versendet via AWS SES (Go SDK v2) │ Status: pending → sent / failed ▼AWS SES Frankfurt (eu-central-1)Wichtig: UseCases rufen NIEMALS direkt E-Mail-Versand auf. Sie erstellen nur das Business-Event (z.B. LoginAttempt, Account, ContactMessage). Die E-Mail-Erstellung ist komplett entkoppelt und laeuft asynchron ueber den Preparation-Worker.
Registrierte Handler (Stufe 1)
Abschnitt betitelt „Registrierte Handler (Stufe 1)“Jeder Handler implementiert ein Interface mit einer Check()-Methode, die per DB-Query
nach ausstehenden Events sucht und bei Fund die Mail vorbereitet:
| Handler | Prueft auf | Idempotenz-Check |
|---|---|---|
TwoFactorCodeHandler | LoginAttempts mit twofactor_code IS NOT NULL und twofactor_verified_at IS NULL | Keine system_message mit type=two_factor_code fuer diesen LoginAttempt |
PasswordResetHandler | Password-Reset-Requests (kommt spaeter) | Keine system_message mit type=password_reset fuer diesen Request |
ContactNotificationHandler | Neue Messages ohne zugehoerige Notification | Keine system_message mit type=contact_notification fuer diese Message |
ContactConfirmationHandler | Neue Messages ohne zugehoerige Bestaetigung | Keine system_message mit type=contact_confirmation fuer diese Message |
WelcomeHandler | Neue Accounts ohne Willkommensmail | Keine system_message mit type=welcome fuer diesen Account |
Template-Hierarchie (3 Ebenen)
Abschnitt betitelt „Template-Hierarchie (3 Ebenen)“System-Default → Basis-Templates (von Agency gepflegt) └─ Tenant-Override → Kunde ueberschreibt fuer seinen Mandanten └─ Site-Override → Spezifisch pro Website des KundenLookup-Reihenfolge: Site → Tenant → System. Erstes Ergebnis gewinnt. Gilt sowohl fuer Templates als auch fuer Uebersetzungs-Keys.
Editierbarkeit: Nur Agency (agency_owner, agency_employee) kann Templates und Keys bearbeiten. Kein Tenant-Zugang zum Template-Editor in Stufe 1.
Template-Typen
Abschnitt betitelt „Template-Typen“Drei strukturelle Template-Typen:
| Typ | Beschreibung |
|---|---|
layout | Aeusseres Geruest (Header, Footer, Navigation) — umschliesst jede Mail |
signature | Rechtliche Signatur — wird in jede Mail eingebettet |
| Content-Templates | Konkrete Mail-Inhalte (pro E-Mail-Typ) |
Rendering-Pipeline
Abschnitt betitelt „Rendering-Pipeline“1. Template-Lookup (jeweils Site → Tenant → System) ├── Layout laden ├── Content-Template laden (nach template_type) └── Signature laden
2. Platzhalter aufloesen ├── Translation-Keys laden (Site → Tenant → System, pro Locale) ├── Custom-Variablen vom UseCase (z.B. 2FA-Code, Location) └── Merge in Map: alle {{ key }}-Ersetzungen
3. Zusammensetzen ├── Layout: {{ content.template }} → Content-Template einsetzen ├── Layout: {{ content.signature }} → Signature einsetzen └── Rekursive Ersetzung: {{ key }} in eingesetzten Inhalten ersetzen
4. Ergebnis → system_message.contentPlatzhalter-Syntax: {{ schluessel }} (Leerzeichen um den Key werden ignoriert).
Fehlender Schluessel → harter Fehler. Mail wird nicht erstellt, Fehler wird geloggt.
So werden fehlende Keys frueh erkannt und nicht stillschweigend verschluckt.
Datenbank-Tabellen
Abschnitt betitelt „Datenbank-Tabellen“E-Mail-Adressen (nach BIS-Vorbild, vereinfacht)
Abschnitt betitelt „E-Mail-Adressen (nach BIS-Vorbild, vereinfacht)“Separate Speicherung von E-Mail-Adressen mit Relations-Modell. Ermoeglicht: mehrere Adressen pro Account, Kontaktanfrage-Adressen, spaeter E-Mail-Reset-Flow, DOI-Consent-Tracking.
emails — Deduplizierte E-Mail-Adressen:
CREATE TABLE traffino.email ( id UUID PRIMARY KEY, created_at TIMESTAMPTZ, created_by TEXT, updated_at TIMESTAMPTZ, updated_by TEXT, row_version INT NOT NULL DEFAULT 0, row_period TSTZRANGE, -- Business email TEXT NOT NULL, hash BYTEA NOT NULL -- SHA-256 fuer Unique-Index);
CREATE UNIQUE INDEX idx_email_hash ON traffino.email (hash);SELECT create_base_triggers('traffino.email');email_gdpr_compliance — DSGVO-Consent-Tracking pro E-Mail-Adresse:
CREATE TABLE traffino.email_gdpr_compliance ( id UUID PRIMARY KEY, created_at TIMESTAMPTZ, created_by TEXT, updated_at TIMESTAMPTZ, updated_by TEXT, row_version INT NOT NULL DEFAULT 0, row_period TSTZRANGE, -- Business doi_confirmed TIMESTAMPTZ, -- Wann DOI bestaetigt doi_cancelled TIMESTAMPTZ, -- Wann DOI abgebrochen doi_date_expiration TIMESTAMPTZ -- Wann DOI ablaeuft);
SELECT create_base_triggers('traffino.email_gdpr_compliance');email_relation — Verknuepft E-Mails mit Entitaeten:
CREATE TYPE traffino.email_relation_type AS ENUM ( 'account_primary', -- Primaere Account-E-Mail 'account_primary_change', -- E-Mail-Aenderung (spaeter) 'contact_request', -- Besucher-Kontaktanfrage 'contact_confirmation' -- Bestaetigung an Besucher);
CREATE TABLE traffino.email_relation ( id UUID PRIMARY KEY, created_at TIMESTAMPTZ, created_by TEXT, updated_at TIMESTAMPTZ, updated_by TEXT, row_version INT NOT NULL DEFAULT 0, row_period TSTZRANGE, -- Business email_id UUID NOT NULL REFERENCES traffino.email(id), relation_id UUID NOT NULL, -- Polymorphe ID (Account, Message, etc.) relation_type traffino.email_relation_type NOT NULL, email_gdpr_compliance_id UUID REFERENCES traffino.email_gdpr_compliance(id));
CREATE UNIQUE INDEX idx_email_relation_unique ON traffino.email_relation (email_id, relation_id, relation_type);CREATE INDEX idx_email_relation_relation_id ON traffino.email_relation (relation_id);SELECT create_base_triggers('traffino.email_relation');Migration: Die bestehende account.email-Spalte wird entfernt. Der Login-Lookup
laeuft ueber email_relation (type: account_primary) → email. Bei Account-Erstellung
wird ein email-Eintrag + email_relation-Eintrag angelegt. Die bestehende Migration 002
wird direkt angepasst (Projekt ist noch nicht live).
Login-Query:
SELECT a.* FROM traffino.account aJOIN traffino.email_relation er ON er.relation_id = a.id AND er.relation_type = 'account_primary'JOIN traffino.email e ON e.id = er.email_idWHERE e.hash = sha256($1)email_template — HTML-Templates in der DB
Abschnitt betitelt „email_template — HTML-Templates in der DB“CREATE TABLE traffino.email_template ( id UUID PRIMARY KEY, created_at TIMESTAMPTZ, created_by TEXT, updated_at TIMESTAMPTZ, updated_by TEXT, row_version INT NOT NULL DEFAULT 0, row_period TSTZRANGE, -- Business template_type TEXT NOT NULL, -- 'layout', 'signature', 'two_factor_code', etc. tenant_id UUID REFERENCES traffino.tenants(id), -- NULL = System-Default site_id UUID REFERENCES traffino.sites(id), -- NULL = Tenant/System subject_key TEXT, -- Uebersetzungs-Key fuer Betreff (nur Content-Templates) html_body TEXT NOT NULL, -- Kompiliertes HTML (aus MJML) mjml_source TEXT -- MJML-Quelle als Referenz (optional));
CREATE UNIQUE INDEX idx_email_template_unique ON traffino.email_template (template_type, COALESCE(tenant_id, '00000000-0000-0000-0000-000000000000'), COALESCE(site_id, '00000000-0000-0000-0000-000000000000'));SELECT create_base_triggers('traffino.email_template');email_translation_key — Uebersetzbare Texte
Abschnitt betitelt „email_translation_key — Uebersetzbare Texte“CREATE TABLE traffino.email_translation_key ( id UUID PRIMARY KEY, created_at TIMESTAMPTZ, created_by TEXT, updated_at TIMESTAMPTZ, updated_by TEXT, row_version INT NOT NULL DEFAULT 0, row_period TSTZRANGE, -- Business key TEXT NOT NULL, -- z.B. 'email.common.salutation' locale VARCHAR(10) NOT NULL, -- 'de', 'en' value TEXT NOT NULL, tenant_id UUID REFERENCES traffino.tenants(id), -- NULL = System-Default site_id UUID REFERENCES traffino.sites(id) -- NULL = Tenant/System);
CREATE UNIQUE INDEX idx_email_translation_key_unique ON traffino.email_translation_key (key, locale, COALESCE(tenant_id, '00000000-0000-0000-0000-000000000000'), COALESCE(site_id, '00000000-0000-0000-0000-000000000000'));SELECT create_base_triggers('traffino.email_translation_key');system_message — E-Mail-Queue
Abschnitt betitelt „system_message — E-Mail-Queue“CREATE TYPE traffino.system_message_type AS ENUM ( 'two_factor_code', 'password_reset', 'contact_notification', 'contact_confirmation', 'welcome');
CREATE TABLE traffino.system_message ( id UUID PRIMARY KEY, created_at TIMESTAMPTZ, created_by TEXT, updated_at TIMESTAMPTZ, updated_by TEXT, row_version INT NOT NULL DEFAULT 0, row_period TSTZRANGE, -- Typ message_type traffino.system_message_type NOT NULL, -- Empfaenger (ueber email_relation, nicht als Text) email_relation_id UUID NOT NULL REFERENCES traffino.email_relation(id), -- E-Mail-Inhalt subject TEXT NOT NULL, -- Fertig gerenderter Betreff content TEXT NOT NULL, -- Fertig gerenderter HTML-Body -- Status-Tracking scheduled_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), sent_at TIMESTAMPTZ, failed_at TIMESTAMPTZ, sent_failed INT NOT NULL DEFAULT 0, -- Anzahl fehlgeschlagener Versuche sent_error TEXT, -- Kontext tenant_id UUID REFERENCES traffino.tenants(id), site_id UUID REFERENCES traffino.sites(id), account_id UUID REFERENCES traffino.accounts(id));
CREATE INDEX idx_system_message_pending ON traffino.system_message (scheduled_at) WHERE sent_at IS NULL AND (failed_at IS NULL OR sent_failed < 3);CREATE INDEX idx_system_message_email_relation_id ON traffino.system_message (email_relation_id);CREATE INDEX idx_system_message_account_id ON traffino.system_message (account_id);SELECT create_base_triggers('traffino.system_message');Kein recipient_email Text-Feld — die Empfaenger-Adresse wird ueber
email_relation_id → email_relation → email aufgeloest. So ist jede Mail
sauber mit dem E-Mail-Relations-Modell verknuepft.
Kein opened_at / Tracking-Pixel — nicht DSGVO-konform, bewusst weggelassen.
Retry-Logik: Dispatch-Worker versucht Mails bis zu 3 Mal (sent_failed < 3).
Nach 3 Fehlversuchen wird die Mail nicht mehr versucht.
system_message_relation — Idempotenz-Verknuepfung (nach BIS-Vorbild)
Abschnitt betitelt „system_message_relation — Idempotenz-Verknuepfung (nach BIS-Vorbild)“Verknuepft eine System-Message mit den betroffenen Entitaeten (Account-ID, LoginAttempt-ID etc.).
Wird fuer die NOT EXISTS-Idempotenz-Pruefung der Handler verwendet.
CREATE TABLE traffino.system_message_relation ( id UUID PRIMARY KEY, created_at TIMESTAMPTZ, created_by TEXT, updated_at TIMESTAMPTZ, updated_by TEXT, row_version INT NOT NULL DEFAULT 0, row_period TSTZRANGE, -- Business system_message_id UUID NOT NULL REFERENCES traffino.system_message(id), relation_id UUID NOT NULL -- ID der verknuepften Entitaet (Account, LoginAttempt, etc.));
CREATE UNIQUE INDEX idx_system_message_relation_unique ON traffino.system_message_relation (system_message_id, relation_id);CREATE INDEX idx_system_message_relation_relation_id ON traffino.system_message_relation (relation_id);SELECT create_base_triggers('traffino.system_message_relation');Idempotenz-Pattern: Der Handler prueft per NOT EXISTS ob fuer eine relation_id
(z.B. LoginAttempt-ID) bereits eine system_message mit dem passenden message_type existiert.
Pro Mail koennen mehrere Relations gespeichert werden (z.B. Account-ID + LoginAttempt-ID).
E-Mail-Typen Stufe 1
Abschnitt betitelt „E-Mail-Typen Stufe 1“| Typ | message_type | template_type | Empfaenger | Relations (Idempotenz) | Custom-Variablen |
|---|---|---|---|---|---|
| 2FA-Login-Code | two_factor_code | two_factor_code | Account (account_primary) | LoginAttempt-ID | custom.code, custom.location, custom.ip, custom.device |
| Password-Reset | password_reset | password_reset | Account (account_primary) | PasswordReset-ID | custom.reset_url, custom.expires_at |
| Kontakt-Notification | contact_notification | contact_notification | Tenant-Kontaktadresse (account_primary des Tenant-Admins) | Message-ID | custom.sender_name, custom.sender_email, custom.message, custom.site_name |
| Kontakt-Bestaetigung | contact_confirmation | contact_confirmation | Besucher (contact_confirmation) | Message-ID | custom.sender_name, custom.site_name |
| Willkommen | welcome | welcome | Account (account_primary) | Account-ID | custom.login_url, custom.display_name |
Gemeinsame Translation-Keys (System-Defaults)
Abschnitt betitelt „Gemeinsame Translation-Keys (System-Defaults)“| Key | DE | EN |
|---|---|---|
email.common.salutation | Hallo {{ custom.display_name }} | Hello {{ custom.display_name }} |
email.common.regards | Mit freundlichen Gruessen | Best regards |
email.common.footer.company | traffino Web-Agentur | traffino Web Agency |
email.common.footer.legal | Diese E-Mail wurde automatisch versendet. | This email was sent automatically. |
email.2fa.subject | Dein Login-Code | Your login code |
email.2fa.intro | Es wurde ein Login-Versuch von einem unbekannten Geraet erkannt. | A login attempt from an unknown device was detected. |
email.2fa.code_label | Dein Sicherheitscode | Your security code |
email.2fa.location_label | Standort | Location |
email.2fa.ip_label | IP-Adresse | IP address |
email.2fa.device_label | Geraet | Device |
email.2fa.expiry | Der Code ist 5 Minuten gueltig. | The code is valid for 5 minutes. |
email.2fa.not_you | Falls du diesen Login nicht ausgeloest hast, aendere bitte sofort dein Passwort. | If you did not initiate this login, please change your password immediately. |
email.password_reset.subject | Passwort zuruecksetzen | Reset your password |
email.password_reset.intro | Du hast angefordert, dein Passwort zurueckzusetzen. | You requested to reset your password. |
email.password_reset.button | Passwort zuruecksetzen | Reset password |
email.password_reset.expiry | Dieser Link ist 1 Stunde gueltig. | This link is valid for 1 hour. |
email.password_reset.not_you | Falls du dies nicht angefordert hast, ignoriere diese E-Mail. | If you did not request this, please ignore this email. |
email.contact_notification.subject | Neue Kontaktanfrage | New contact request |
email.contact_notification.intro | Eine neue Kontaktanfrage ist eingegangen. | A new contact request has been received. |
email.contact_notification.from_label | Von | From |
email.contact_notification.message_label | Nachricht | Message |
email.contact_confirmation.subject | Deine Anfrage wurde empfangen | Your request has been received |
email.contact_confirmation.intro | Vielen Dank fuer deine Nachricht. Wir melden uns so schnell wie moeglich. | Thank you for your message. We will get back to you as soon as possible. |
email.welcome.subject | Willkommen | Welcome |
email.welcome.intro | Dein Account wurde erfolgreich angelegt. | Your account has been successfully created. |
email.welcome.button | Jetzt einloggen | Log in now |
MJML-Workflow
Abschnitt betitelt „MJML-Workflow“.mjml-Datei im MJML Online-Editor bearbeiten- Generierten HTML-Output kopieren
- HTML in
email_template.html_bodyspeichern (per SQL-Migration fuer System-Defaults) - MJML-Quelle in
email_template.mjml_sourcespeichern (als Referenz)
Initial werden einfache, funktionale HTML-Templates per Migration eingefuegt. MJML-basiertes Design kommt spaeter, wenn das visuelle Design steht.
Komponenten
Abschnitt betitelt „Komponenten“internal/feature/email/├── types.go # MessageType enum, TemplateType, MailContext├── renderer.go # Platzhalter-Ersetzung, Layout-Assembly├── template_service.go # Template-Lookup (3-Ebenen), Translation-Keys laden├── repository.go # Repository Interfaces├── jet_repository.go # Jet Implementierungen└── errors.go # Sentinel Errors
internal/feature/email/handler/├── handler.go # Handler Interface (Check() method)├── two_factor_code.go # Prueft LoginAttempts mit 2FA-Code├── password_reset.go # Prueft Password-Reset-Requests├── contact_notification.go # Prueft neue Kontaktanfragen → Notification an Kunden├── contact_confirmation.go # Prueft neue Kontaktanfragen → Bestaetigung an Absender└── welcome.go # Prueft neue Accounts
internal/feature/email/worker/├── preparation_worker.go # Goroutine: alle 10s alle Handler durchlaufen├── dispatch_worker.go # Goroutine: alle 10s pending → SES → sent/failed└── ses_client.go # AWS SES v2 SDK WrapperIdempotenz
Abschnitt betitelt „Idempotenz“Jeder Handler prueft per NOT EXISTS-Subquery, ob fuer das jeweilige Event bereits
eine system_message des passenden Typs existiert. So wird auch bei mehrfachem
Durchlauf des Preparation-Workers keine doppelte Mail erstellt.
3. 2FA-Erweiterung
Abschnitt betitelt „3. 2FA-Erweiterung“Aktueller Stand
Abschnitt betitelt „Aktueller Stand“generateTwoFactorCode()existiert (6-stelliger Zahlencode)- Login prueft
account.TwofactorEmail, erstellt LoginAttempt mit Code Verify2FA()validiert Code, prueft 5-Minuten-Ablauf, erstellt Session- E-Mail-Versand:
// TODO— wird durch das E-Mail-System ersetzt
Erweiterungen
Abschnitt betitelt „Erweiterungen“A) Device-Erkennung
Abschnitt betitelt „A) Device-Erkennung“Neues Cookie device_token (zufaelliges Token, 30 Tage Laufzeit).
Nach erfolgreichem 2FA wird das Device als “bekannt” gespeichert.
Bei bekanntem Device: kein 2FA noetig.
known_device-Tabelle:
CREATE TABLE traffino.known_device ( id UUID PRIMARY KEY, created_at TIMESTAMPTZ, created_by TEXT, updated_at TIMESTAMPTZ, updated_by TEXT, row_version INT NOT NULL DEFAULT 0, row_period TSTZRANGE, -- Business account_id UUID NOT NULL REFERENCES traffino.accounts(id), device_token TEXT NOT NULL, -- Zufaelliges Token (im Cookie) last_used_at TIMESTAMPTZ NOT NULL);
CREATE INDEX idx_known_device_account_id ON traffino.known_device (account_id);CREATE UNIQUE INDEX idx_known_device_token ON traffino.known_device (device_token);SELECT create_base_triggers('traffino.known_device');Geolocation-Referenz: geolocation.id = known_device.id (1:1 wie bei Sessions).
B) Login-Flow mit Device-Check
Abschnitt betitelt „B) Login-Flow mit Device-Check“Login (Email + Passwort korrekt) │ ├─ account.twofactor_email == false? │ → Session erstellen, fertig │ ├─ device_token Cookie vorhanden? │ → DB-Lookup: known_device.device_token == cookie? │ │ │ ├─ Ja (bekanntes Device) │ │ → last_used_at aktualisieren │ │ → Session erstellen, kein 2FA │ │ │ └─ Nein (unbekanntes Token) │ → Weiter zu 2FA │ └─ Kein Cookie → Weiter zu 2FA
2FA-Flow: │ ├─ Geolocation-Lookup (MaxMind) ├─ 6-stelligen Code generieren ├─ LoginAttempt erstellen (Code + Geolocation) │ (E-Mail wird NICHT hier gesendet — der Preparation-Worker │ erkennt den neuen LoginAttempt und erstellt die Mail async) └─ Response: { attemptId, twoFARequired: true }
2FA-Verifizierung: │ ├─ Code korrekt + nicht abgelaufen (5 Min)? ├─ Session erstellen (30 Tage) ├─ Known-Device erstellen + Geolocation ├─ device_token Cookie setzen (30 Tage) └─ Response: { sessionId }C) Security: Device-Validierung in der Middleware
Abschnitt betitelt „C) Security: Device-Validierung in der Middleware“Bei jedem authentifizierten Request:
Request mit Session-Cookie + Device-Cookie │ ├─ Session gueltig? │ └─ Nein → 401 │ ├─ Device-Cookie vorhanden? │ ├─ Ja → Existiert device_token in known_device fuer diesen Account? │ │ ├─ Ja → OK, weiter │ │ └─ Nein → Session terminieren, 401 │ │ (Device wurde geloescht → Remote-Logout) │ └─ Nein → OK, weiter │ (Account hat ggf. kein 2FA aktiviert)Wichtig: Der Device-Check greift nur wenn ein Device-Cookie vorhanden ist. Wenn kein Cookie da ist (z.B. 2FA deaktiviert), wird nicht geloescht.
D) Session-Laufzeit
Abschnitt betitelt „D) Session-Laufzeit“Session-Cookie Laufzeit von 24 Stunden auf 30 Tage erhoehen.
Betrifft: sessions.Create() und Cookie Max-Age.
E) Device-Management (spaeterer Schritt)
Abschnitt betitelt „E) Device-Management (spaeterer Schritt)“Nicht Teil dieser Implementierung, aber vorbereitet durch die Tabellenstruktur:
- User sieht Liste seiner bekannten Geraete (Device, Standort, letzter Zugriff)
- User kann einzelne Geraete loeschen → naechster Request mit diesem Token → Logout
- Endpoint:
GET /api/protected/devices+DELETE /api/protected/devices/{id}
4. Aenderungen an bestehenden Migrationen
Abschnitt betitelt „4. Aenderungen an bestehenden Migrationen“Da das Projekt noch nicht live ist, werden bestehende Migrationen direkt angepasst:
Migration 002 (000002_accounts_and_sessions.up.sql)
Abschnitt betitelt „Migration 002 (000002_accounts_and_sessions.up.sql)“- Alle Tabellennamen auf Singular (
tenants→tenant,accounts→account, etc.) account.emailSpalte entfernen — Login laeuft ueberemail_relationlogin_attempts.emailSpalte entfernen — E-Mail wird ueber Account aufgeloest- Unique Index auf
account.emailentfernen - Initialer Admin-User:
twofactor_email = TRUE, keinemail-Feld mehr im INSERT
Neue Migration 003 (000003_geolocation_email_devices.up.sql)
Abschnitt betitelt „Neue Migration 003 (000003_geolocation_email_devices.up.sql)“- E-Mail-Tabellen:
email,email_gdpr_compliance,email_relation - E-Mail-Template-Tabellen:
email_template,email_translation_key - System-Message-Tabellen:
system_message,system_message_relation - Geolocation:
geolocation - Device-Erkennung:
known_device - Admin-E-Mail-Relation:
email-Eintrag +email_relation-Eintrag (type:account_primary) fuer den Admin-Account - System-Default-Templates: Layout, Signature, und alle 5 Content-Templates (einfaches HTML)
- System-Default-Translation-Keys: Alle Keys aus der Tabelle oben, jeweils DE + EN
5. Neue Abhaengigkeiten (Go)
Abschnitt betitelt „5. Neue Abhaengigkeiten (Go)“| Package | Zweck |
|---|---|
oschwald/maxminddb-golang | GeoLite2 .mmdb Reader |
aws/aws-sdk-go-v2/service/sesv2 | AWS SES E-Mail-Versand |
6. Konfiguration (envconfig)
Abschnitt betitelt „6. Konfiguration (envconfig)“| Variable | Default | Beschreibung |
|---|---|---|
MAXMIND_PATH | .local/maxmind | Lokaler Pfad fuer .mmdb-Datei |
AWS_REGION | eu-central-1 | AWS Region fuer SES |
SES_FROM_EMAIL | (required) | Absender-Adresse |
SES_FROM_NAME | traffino | Absender-Name |
SMTP_HOST | (leer) | Falls gesetzt: SMTP statt SES (lokale Entwicklung mit MailHog) |
SMTP_PORT | 1025 | SMTP Port (MailHog) |