Zum Inhalt springen

Geolocation, E-Mail-System & 2FA-Erweiterung

Datum: 2026-04-09

Drei eng verzahnte Features fuer Stufe 1:

  1. MaxMind IP-Geolocation — IP-Adressen nach Stadt/Region/Land aufloesen, Ergebnisse in DB speichern
  2. E-Mail-System — DB-gestuetztes Template-System mit 3-Ebenen-Hierarchie, Queue-basierter Versand via AWS SES
  3. 2FA-Erweiterung — Device-Erkennung, Known-Device-Cookie, Geolocation in 2FA-Mail, Session 30 Tage

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.

  • 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
UmgebungPfad
Entwicklung.local/maxmind/GeoLite2-City.mmdb
Produktion/data/maxmind/GeoLite2-City.mmdb
  1. 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)
  2. 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

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”

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 zurueck
  • Start(ctx context.Context) — initialer Download + Cron starten
  • Shutdown() — 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"

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.id
  • login_attempts: geolocation.id = login_attempt.id
  • known_devices: geolocation.id = known_device.id
  • Spaeter erweiterbar auf Password-Reset, Kontaktanfragen etc.

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.

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.

Jeder Handler implementiert ein Interface mit einer Check()-Methode, die per DB-Query nach ausstehenden Events sucht und bei Fund die Mail vorbereitet:

HandlerPrueft aufIdempotenz-Check
TwoFactorCodeHandlerLoginAttempts mit twofactor_code IS NOT NULL und twofactor_verified_at IS NULLKeine system_message mit type=two_factor_code fuer diesen LoginAttempt
PasswordResetHandlerPassword-Reset-Requests (kommt spaeter)Keine system_message mit type=password_reset fuer diesen Request
ContactNotificationHandlerNeue Messages ohne zugehoerige NotificationKeine system_message mit type=contact_notification fuer diese Message
ContactConfirmationHandlerNeue Messages ohne zugehoerige BestaetigungKeine system_message mit type=contact_confirmation fuer diese Message
WelcomeHandlerNeue Accounts ohne WillkommensmailKeine system_message mit type=welcome fuer diesen Account
System-Default → Basis-Templates (von Agency gepflegt)
└─ Tenant-Override → Kunde ueberschreibt fuer seinen Mandanten
└─ Site-Override → Spezifisch pro Website des Kunden

Lookup-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.

Drei strukturelle Template-Typen:

TypBeschreibung
layoutAeusseres Geruest (Header, Footer, Navigation) — umschliesst jede Mail
signatureRechtliche Signatur — wird in jede Mail eingebettet
Content-TemplatesKonkrete Mail-Inhalte (pro E-Mail-Typ)
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.content

Platzhalter-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.

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 a
JOIN 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_id
WHERE e.hash = sha256($1)
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');
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');
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).

Typmessage_typetemplate_typeEmpfaengerRelations (Idempotenz)Custom-Variablen
2FA-Login-Codetwo_factor_codetwo_factor_codeAccount (account_primary)LoginAttempt-IDcustom.code, custom.location, custom.ip, custom.device
Password-Resetpassword_resetpassword_resetAccount (account_primary)PasswordReset-IDcustom.reset_url, custom.expires_at
Kontakt-Notificationcontact_notificationcontact_notificationTenant-Kontaktadresse (account_primary des Tenant-Admins)Message-IDcustom.sender_name, custom.sender_email, custom.message, custom.site_name
Kontakt-Bestaetigungcontact_confirmationcontact_confirmationBesucher (contact_confirmation)Message-IDcustom.sender_name, custom.site_name
WillkommenwelcomewelcomeAccount (account_primary)Account-IDcustom.login_url, custom.display_name
KeyDEEN
email.common.salutationHallo {{ custom.display_name }}Hello {{ custom.display_name }}
email.common.regardsMit freundlichen GruessenBest regards
email.common.footer.companytraffino Web-Agenturtraffino Web Agency
email.common.footer.legalDiese E-Mail wurde automatisch versendet.This email was sent automatically.
email.2fa.subjectDein Login-CodeYour login code
email.2fa.introEs wurde ein Login-Versuch von einem unbekannten Geraet erkannt.A login attempt from an unknown device was detected.
email.2fa.code_labelDein SicherheitscodeYour security code
email.2fa.location_labelStandortLocation
email.2fa.ip_labelIP-AdresseIP address
email.2fa.device_labelGeraetDevice
email.2fa.expiryDer Code ist 5 Minuten gueltig.The code is valid for 5 minutes.
email.2fa.not_youFalls 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.subjectPasswort zuruecksetzenReset your password
email.password_reset.introDu hast angefordert, dein Passwort zurueckzusetzen.You requested to reset your password.
email.password_reset.buttonPasswort zuruecksetzenReset password
email.password_reset.expiryDieser Link ist 1 Stunde gueltig.This link is valid for 1 hour.
email.password_reset.not_youFalls du dies nicht angefordert hast, ignoriere diese E-Mail.If you did not request this, please ignore this email.
email.contact_notification.subjectNeue KontaktanfrageNew contact request
email.contact_notification.introEine neue Kontaktanfrage ist eingegangen.A new contact request has been received.
email.contact_notification.from_labelVonFrom
email.contact_notification.message_labelNachrichtMessage
email.contact_confirmation.subjectDeine Anfrage wurde empfangenYour request has been received
email.contact_confirmation.introVielen 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.subjectWillkommenWelcome
email.welcome.introDein Account wurde erfolgreich angelegt.Your account has been successfully created.
email.welcome.buttonJetzt einloggenLog in now
  1. .mjml-Datei im MJML Online-Editor bearbeiten
  2. Generierten HTML-Output kopieren
  3. HTML in email_template.html_body speichern (per SQL-Migration fuer System-Defaults)
  4. MJML-Quelle in email_template.mjml_source speichern (als Referenz)

Initial werden einfache, funktionale HTML-Templates per Migration eingefuegt. MJML-basiertes Design kommt spaeter, wenn das visuelle Design steht.

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 Wrapper

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.

  • 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

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).

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 }

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.

Session-Cookie Laufzeit von 24 Stunden auf 30 Tage erhoehen. Betrifft: sessions.Create() und Cookie Max-Age.

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}

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 (tenantstenant, accountsaccount, etc.)
  • account.email Spalte entfernen — Login laeuft ueber email_relation
  • login_attempts.email Spalte entfernen — E-Mail wird ueber Account aufgeloest
  • Unique Index auf account.email entfernen
  • Initialer Admin-User: twofactor_email = TRUE, kein email-Feld mehr im INSERT

Neue Migration 003 (000003_geolocation_email_devices.up.sql)

Abschnitt betitelt „Neue Migration 003 (000003_geolocation_email_devices.up.sql)“
  1. E-Mail-Tabellen: email, email_gdpr_compliance, email_relation
  2. E-Mail-Template-Tabellen: email_template, email_translation_key
  3. System-Message-Tabellen: system_message, system_message_relation
  4. Geolocation: geolocation
  5. Device-Erkennung: known_device
  6. Admin-E-Mail-Relation: email-Eintrag + email_relation-Eintrag (type: account_primary) fuer den Admin-Account
  7. System-Default-Templates: Layout, Signature, und alle 5 Content-Templates (einfaches HTML)
  8. System-Default-Translation-Keys: Alle Keys aus der Tabelle oben, jeweils DE + EN
PackageZweck
oschwald/maxminddb-golangGeoLite2 .mmdb Reader
aws/aws-sdk-go-v2/service/sesv2AWS SES E-Mail-Versand
VariableDefaultBeschreibung
MAXMIND_PATH.local/maxmindLokaler Pfad fuer .mmdb-Datei
AWS_REGIONeu-central-1AWS Region fuer SES
SES_FROM_EMAIL(required)Absender-Adresse
SES_FROM_NAMEtraffinoAbsender-Name
SMTP_HOST(leer)Falls gesetzt: SMTP statt SES (lokale Entwicklung mit MailHog)
SMTP_PORT1025SMTP Port (MailHog)