From 5813e6209c2a1508ec06350e130cd6db85e5396b Mon Sep 17 00:00:00 2001 From: noroot Date: Fri, 28 Aug 2026 09:32:43 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20technisches=20Grundger=C3=BCst=20f?= =?UTF-8?q?=C3=BCr=20Instagram-/TikTok-OAuth=20(Plattform-Verbindung)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vorbereitung für automatische Beweissicherung statt manuellem Screenshot-Upload: ein Kunde kann künftig seinen eigenen Instagram- oder TikTok-Account per Standard-OAuth-Consent verbinden. Bewusst nur das Grundgerüst — Meta/TikTok verlangen vor öffentlicher Nutzung eine einmalige Business-Verification/App-Review (Wochen Vorlauf, siehe CLAUDE.md-Abschnitt "Plattform-Verbindung (OAuth)"), die separat von dieser Codeänderung läuft. - internal/socialconnect: Connector-Interface + InstagramConnector/ TikTokConnector (reiner Authorization-Code-Flow, kein DB-Zugriff). Instagram tauscht den Code zweistufig (kurzlebiges → 60-Tage-Token), TikTok liefert Access-/Refresh-Token direkt. Endpunkte/Scopes wurden gegen aktuelle Entwicklerdokumentation gebaut, nie gegen die echte API verifiziert (keine Zugangsdaten vorhanden) — Hinweis dazu im Paket- und CLAUDE.md-Kommentar. - Migration 0006: platform_connection (NICHT append-only, anders als finding/extraction/asset — ein Token wird ersetzt, keine Korrektur- Zeile), höchstens eine Verbindung pro Account+Plattform. - internal/web: GET /verbindungen (Übersicht je Plattform: verbunden/ nicht verbunden/nicht konfiguriert), GET /oauth/{platform}/start (State-Cookie gegen CSRF, Redirect zum Consent-Screen), GET /oauth/{platform}/callback (State prüfen, Code tauschen, Verbindung speichern), POST /verbindungen/{platform}/trennen. - Ohne gesetzte Client-Credentials + PUBLIC_BASE_URL bleibt die Funktion inaktiv (kein Connector konfiguriert, /verbindungen zeigt "nicht konfiguriert", kein Absturz) — main.go loggt das beim Start. Volle Testsuite inkl. echter Postgres-Tests grün; OAuth-Flow gegen Fake-Connector/httptest-Server verifiziert (State-Mismatch, Ablehnung durch Nutzer, Token-Speicherung, Mandantentrennung). Kein Live-Test gegen echte Meta-/TikTok-Endpunkte möglich, da noch keine echten Client-Credentials existieren. --- CLAUDE.md | 44 +++- cmd/deklarix/main.go | 17 +- internal/socialconnect/connector.go | 82 +++++++ internal/socialconnect/instagram.go | 103 ++++++++ internal/socialconnect/instagram_test.go | 82 +++++++ internal/socialconnect/tiktok.go | 95 ++++++++ internal/socialconnect/tiktok_test.go | 70 ++++++ .../0006_platform_connection.down.sql | 1 + .../0006_platform_connection.up.sql | 21 ++ internal/store/platformconnection.go | 108 +++++++++ internal/store/platformconnection_test.go | 114 +++++++++ internal/web/oauth_handlers.go | 142 +++++++++++ internal/web/oauth_handlers_test.go | 225 ++++++++++++++++++ internal/web/server.go | 19 +- internal/web/server_test.go | 86 ++++++- internal/web/templates/layout.html | 1 + internal/web/templates/verbindungen.html | 41 ++++ packaging/etc/deklarix/deklarix.env.example | 10 + 18 files changed, 1246 insertions(+), 15 deletions(-) create mode 100644 internal/socialconnect/connector.go create mode 100644 internal/socialconnect/instagram.go create mode 100644 internal/socialconnect/instagram_test.go create mode 100644 internal/socialconnect/tiktok.go create mode 100644 internal/socialconnect/tiktok_test.go create mode 100644 internal/store/migrations/0006_platform_connection.down.sql create mode 100644 internal/store/migrations/0006_platform_connection.up.sql create mode 100644 internal/store/platformconnection.go create mode 100644 internal/store/platformconnection_test.go create mode 100644 internal/web/oauth_handlers.go create mode 100644 internal/web/oauth_handlers_test.go create mode 100644 internal/web/templates/verbindungen.html diff --git a/CLAUDE.md b/CLAUDE.md index ea3c3c8..c334592 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -112,6 +112,7 @@ Managed Postgres in der EU (DSGVO). │ ├── rules/ # YAML-Loader, Auswertung, Versionierung │ ├── evidence/ # Hashing, Zeitstempel, Append-only-Log │ ├── dossier/ # PDF-Erzeugung +│ ├── socialconnect/ # OAuth-Flow Instagram/TikTok (Plattform-Verbindung) │ ├── store/ # Postgres, Migrationen │ └── web/ # Handler, Templates ├── rules/ # YAML-Regeln, versioniert im Git @@ -180,6 +181,12 @@ Plus drei Tabellen für Auth/Mandantentrennung (`account`, `app_user`, - `participant` — Beteiligter an einer Submission mit Rolle (`creator`, `agentur`, `marke`, `kanzlei`) und Beitrag zur Verantwortungsmatrix (wer hat vorgegeben, wer freigegeben) +- `platform_connection` — die per OAuth hergestellte Verbindung eines + Accounts zu seinem eigenen Instagram- oder TikTok-Account (siehe + Abschnitt „Plattform-Verbindung (OAuth)" unten). NICHT append-only — + Tokens laufen ab und werden erneuert, eine Verbindung kann getrennt + und neu hergestellt werden; höchstens eine Verbindung pro + Account+Plattform (`UNIQUE(account_id, platform)`) **Append-only.** Kein UPDATE auf `finding`, `extraction`, `asset`, `evidence_package` oder `audit_log`. Korrekturen sind neue Zeilen mit @@ -203,6 +210,39 @@ gültige Sitzung (sonst Redirect zu `/login`); `POST /pruefen`, existierender behandelt (404), nie mit einer expliziten 403 bestätigt — sonst würde die Antwort selbst verraten, dass die ID existiert. +**Plattform-Verbindung (OAuth):** Jeder Kunde kann optional seinen +eigenen Instagram- oder TikTok-Account verbinden (`GET /verbindungen`), +damit die Beweissicherung einen veröffentlichten Beitrag künftig direkt +per API abrufen kann, statt ihn manuell hochzuladen — reiner +Authorization-Code-Flow, jeder Kunde autorisiert nur seinen eigenen +Account (`internal/socialconnect`, Persistenz in `platform_connection`). +Der manuelle Standbild-Upload bleibt der primäre Weg und funktioniert +unabhängig davon weiter; OAuth reduziert nur Reibung, ist kein +Ersatz für die Pre-Publish-Prüfung (die läuft zwingend vor +Veröffentlichung, wenn auf der Plattform noch nichts existiert — dafür +kann OAuth nichts abrufen). + +Technisch ist der Flow fertig (Connector-Interface, CSRF-Schutz per +State-Cookie, Token-Speicherung), aber **ohne aktive Meta-/TikTok- +Freigabe nutzlos**: Instagram (`instagram_business_basic`) und TikTok +(Login Kit + Content Posting API) verlangen jeweils eine einmalige, +plattformseitige Prüfung des Deklarix-Betreiberkontos (Meta Business +Verification + App Review: ca. 2–4 Wochen; TikTok-Audit: ca. 1–2 +Wochen), bevor sich beliebige Kunden selbst verbinden können. Bis dahin +lässt sich mit bis zu 25 (Meta) bzw. 10 (TikTok) manuell eingetragenen +Testern trotzdem schon mit einem echten Piloten testen. Ohne gesetzte +Konfiguration (`INSTAGRAM_CLIENT_ID`/`_SECRET`, +`TIKTOK_CLIENT_KEY`/`_SECRET`, `PUBLIC_BASE_URL`) zeigt +`GET /verbindungen` beide Plattformen als „noch nicht konfiguriert" +ohne Verbinden-Button — kein Absturz, kein stiller Fallback. + +**Vorsicht bei künftigen Änderungen:** Instagram-/TikTok-Endpunkte, +Scopes und Token-Formate in `internal/socialconnect` wurden ohne echte +Zugangsdaten gegen die Entwicklerdokumentation gebaut, nie gegen die +echte API verifiziert — vor dem ersten echten Verbindungsversuch mit +realen Credentials die Konstanten in `internal/socialconnect/*.go` noch +einmal gegen die dann aktuelle Meta-/TikTok-Dokumentation prüfen. + --- ## Go Commands @@ -396,7 +436,9 @@ sudo systemctl start deklarix sudo systemctl status deklarix # Config: /etc/deklarix/deklarix.env (DATABASE_URL, PORT, RULES_DIR, -# DOSSIER_DIR, ASSET_DIR, TSA_URL) +# DOSSIER_DIR, ASSET_DIR, TSA_URL, PUBLIC_BASE_URL, +# INSTAGRAM_CLIENT_ID/_SECRET, TIKTOK_CLIENT_KEY/_SECRET — letztere vier +# optional, ohne sie zeigt /verbindungen nur "nicht konfiguriert") # Logs prüfen journalctl -u deklarix -f diff --git a/cmd/deklarix/main.go b/cmd/deklarix/main.go index 80f0126..24674cc 100644 --- a/cmd/deklarix/main.go +++ b/cmd/deklarix/main.go @@ -9,6 +9,7 @@ import ( "github.com/netcell-it/deklarix/internal/evidence" "github.com/netcell-it/deklarix/internal/extract" "github.com/netcell-it/deklarix/internal/rules" + "github.com/netcell-it/deklarix/internal/socialconnect" "github.com/netcell-it/deklarix/internal/store" "github.com/netcell-it/deklarix/internal/web" ) @@ -53,7 +54,21 @@ func main() { assetDir = "assets" } - server, err := web.NewServer(extractor, ruleSet, db, timestamper, dossierDir, assetDir) + connectors := map[string]socialconnect.Connector{} + publicBaseURL := os.Getenv("PUBLIC_BASE_URL") + if publicBaseURL != "" { + if id, secret := os.Getenv("INSTAGRAM_CLIENT_ID"), os.Getenv("INSTAGRAM_CLIENT_SECRET"); id != "" && secret != "" { + connectors["instagram"] = socialconnect.NewInstagramConnector(id, secret, publicBaseURL+"/oauth/instagram/callback") + } + if key, secret := os.Getenv("TIKTOK_CLIENT_KEY"), os.Getenv("TIKTOK_CLIENT_SECRET"); key != "" && secret != "" { + connectors["tiktok"] = socialconnect.NewTikTokConnector(key, secret, publicBaseURL+"/oauth/tiktok/callback") + } + } + if len(connectors) == 0 { + log.Print("keine Plattform-Verbindungen konfiguriert (INSTAGRAM_CLIENT_ID/TIKTOK_CLIENT_KEY/PUBLIC_BASE_URL fehlen) — /verbindungen zeigt nur manuelle Beweissicherung an") + } + + server, err := web.NewServer(extractor, ruleSet, db, timestamper, dossierDir, assetDir, connectors) if err != nil { log.Fatalf("web server: %v", err) } diff --git a/internal/socialconnect/connector.go b/internal/socialconnect/connector.go new file mode 100644 index 0000000..b0b5965 --- /dev/null +++ b/internal/socialconnect/connector.go @@ -0,0 +1,82 @@ +// Package socialconnect implementiert den OAuth-Authorization-Code-Flow, +// mit dem ein Kunde seinen eigenen Instagram- oder TikTok-Account mit +// Deklarix verbindet — Ziel ist, dass die Beweissicherung einen +// veröffentlichten Beitrag später direkt abrufen kann, statt ihn manuell +// hochladen zu müssen. Reine HTTP-Logik gegen die jeweilige Plattform- +// API, keine Datenbankzugriffe — Persistenz der Verbindung liegt in +// internal/store (platform_connection). +// +// WICHTIG: Instagram- und TikTok-Endpunkte, Scopes und Token-Formate +// ändern sich häufiger als andere APIs. Vor dem ersten echten +// Verbindungsversuch mit realen Client-Credentials die Konstanten hier +// gegen die aktuelle Meta-/TikTok-Entwicklerdokumentation prüfen — +// dieser Code wurde ohne echte Zugangsdaten gebaut und gegen die +// Dokumentation, nicht gegen die echte API, verifiziert. +package socialconnect + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +// Token ist das Ergebnis eines erfolgreichen Code-Tauschs. +type Token struct { + AccessToken string + RefreshToken string + ExpiresAt time.Time // Nullwert, wenn die Plattform keine Ablaufzeit liefert + PlatformUserID string +} + +// Connector kapselt den OAuth-Flow einer einzelnen Plattform. *InstagramConnector +// und *TikTokConnector erfüllen dieses Interface; internal/web hält eine +// Menge konfigurierter Connectors (nur die, für die echte Client- +// Credentials gesetzt sind — siehe cmd/deklarix/main.go). +type Connector interface { + // Platform ist der interne Bezeichner ("instagram" | "tiktok"), wie + // er auch in platform_connection.platform gespeichert wird. + Platform() string + AuthorizationURL(state string) string + Exchange(ctx context.Context, code string) (Token, error) +} + +func postForm(ctx context.Context, client *http.Client, endpoint string, form url.Values, out any) error { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode())) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + return doJSON(client, req, out) +} + +func getJSON(ctx context.Context, client *http.Client, endpoint string, query url.Values, out any) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint+"?"+query.Encode(), nil) + if err != nil { + return err + } + return doJSON(client, req, out) +} + +func doJSON(client *http.Client, req *http.Request, out any) error { + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("antwort lesen: %w", err) + } + if resp.StatusCode >= 300 { + return fmt.Errorf("unerwarteter Status %d: %s", resp.StatusCode, string(body)) + } + if err := json.Unmarshal(body, out); err != nil { + return fmt.Errorf("antwort parsen: %w", err) + } + return nil +} diff --git a/internal/socialconnect/instagram.go b/internal/socialconnect/instagram.go new file mode 100644 index 0000000..0622eec --- /dev/null +++ b/internal/socialconnect/instagram.go @@ -0,0 +1,103 @@ +package socialconnect + +import ( + "context" + "fmt" + "net/http" + "net/url" + "time" +) + +const ( + instagramDefaultAuthorizeURL = "https://api.instagram.com/oauth/authorize" + instagramDefaultTokenURL = "https://api.instagram.com/oauth/access_token" + instagramDefaultLongLivedTokenURL = "https://graph.instagram.com/access_token" + + // instagram_business_basic ist die einzige Berechtigung, die wir + // brauchen (Profil + Medien lesen) — mehr zu verlangen verzögert nur + // den App-Review, siehe Paket-Kommentar. + instagramScope = "instagram_business_basic" +) + +// InstagramConnector implementiert Connector für Instagram (Instagram +// API with Instagram Login). Der Flow läuft zweistufig: der +// Autorisierungscode wird zuerst gegen ein 1 Stunde gültiges Token +// getauscht, das anschließend gegen ein 60 Tage gültiges langlebiges +// Token getauscht wird — ein einzelner API-Aufruf reicht dafür nicht. +type InstagramConnector struct { + ClientID string + ClientSecret string + RedirectURL string + + // Überschreibbar für Tests (Default: die echten Instagram-Endpunkte). + AuthorizeURL string + TokenURL string + LongLivedTokenURL string + HTTPClient *http.Client +} + +// NewInstagramConnector erstellt einen InstagramConnector mit den +// echten Instagram-Endpunkten. +func NewInstagramConnector(clientID, clientSecret, redirectURL string) *InstagramConnector { + return &InstagramConnector{ + ClientID: clientID, + ClientSecret: clientSecret, + RedirectURL: redirectURL, + AuthorizeURL: instagramDefaultAuthorizeURL, + TokenURL: instagramDefaultTokenURL, + LongLivedTokenURL: instagramDefaultLongLivedTokenURL, + HTTPClient: http.DefaultClient, + } +} + +func (c *InstagramConnector) Platform() string { return "instagram" } + +func (c *InstagramConnector) AuthorizationURL(state string) string { + v := url.Values{ + "client_id": {c.ClientID}, + "redirect_uri": {c.RedirectURL}, + "scope": {instagramScope}, + "response_type": {"code"}, + "state": {state}, + } + return c.AuthorizeURL + "?" + v.Encode() +} + +func (c *InstagramConnector) Exchange(ctx context.Context, code string) (Token, error) { + form := url.Values{ + "client_id": {c.ClientID}, + "client_secret": {c.ClientSecret}, + "grant_type": {"authorization_code"}, + "redirect_uri": {c.RedirectURL}, + "code": {code}, + } + var short struct { + AccessToken string `json:"access_token"` + UserID any `json:"user_id"` // liefert Instagram mal als Zahl, mal als String + } + if err := postForm(ctx, c.HTTPClient, c.TokenURL, form, &short); err != nil { + return Token{}, fmt.Errorf("socialconnect: instagram code exchange: %w", err) + } + if short.AccessToken == "" { + return Token{}, fmt.Errorf("socialconnect: instagram code exchange: kein access_token in der Antwort") + } + + long := url.Values{ + "grant_type": {"ig_exchange_token"}, + "client_secret": {c.ClientSecret}, + "access_token": {short.AccessToken}, + } + var longResp struct { + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` + } + if err := getJSON(ctx, c.HTTPClient, c.LongLivedTokenURL, long, &longResp); err != nil { + return Token{}, fmt.Errorf("socialconnect: instagram long-lived token exchange: %w", err) + } + + return Token{ + AccessToken: longResp.AccessToken, + ExpiresAt: time.Now().Add(time.Duration(longResp.ExpiresIn) * time.Second), + PlatformUserID: fmt.Sprint(short.UserID), + }, nil +} diff --git a/internal/socialconnect/instagram_test.go b/internal/socialconnect/instagram_test.go new file mode 100644 index 0000000..2b1276e --- /dev/null +++ b/internal/socialconnect/instagram_test.go @@ -0,0 +1,82 @@ +package socialconnect + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestInstagramAuthorizationURL(t *testing.T) { + c := NewInstagramConnector("client-123", "secret", "https://app.deklarix.de/oauth/instagram/callback") + u := c.AuthorizationURL("state-abc") + + for _, want := range []string{ + "https://api.instagram.com/oauth/authorize?", + "client_id=client-123", + "state=state-abc", + "scope=instagram_business_basic", + "response_type=code", + } { + if !strings.Contains(u, want) { + t.Errorf("AuthorizationURL = %q, want it to contain %q", u, want) + } + } +} + +func TestInstagramExchangeSuccess(t *testing.T) { + tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + t.Fatalf("ParseForm: %v", err) + } + if r.FormValue("code") != "der-code" { + t.Errorf("code = %q, want der-code", r.FormValue("code")) + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"access_token":"short-lived-token","user_id":"17841400000000000"}`)) + })) + defer tokenServer.Close() + + longLivedServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("access_token") != "short-lived-token" { + t.Errorf("access_token query = %q, want short-lived-token", r.URL.Query().Get("access_token")) + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"access_token":"long-lived-token","expires_in":5184000}`)) + })) + defer longLivedServer.Close() + + c := NewInstagramConnector("client-123", "secret", "https://app.deklarix.de/oauth/instagram/callback") + c.TokenURL = tokenServer.URL + c.LongLivedTokenURL = longLivedServer.URL + + tok, err := c.Exchange(context.Background(), "der-code") + if err != nil { + t.Fatalf("Exchange: %v", err) + } + if tok.AccessToken != "long-lived-token" { + t.Errorf("AccessToken = %q, want long-lived-token", tok.AccessToken) + } + if tok.PlatformUserID != "17841400000000000" { + t.Errorf("PlatformUserID = %q, want 17841400000000000", tok.PlatformUserID) + } + if tok.ExpiresAt.IsZero() { + t.Error("expected a non-zero ExpiresAt") + } +} + +func TestInstagramExchangePropagatesTokenEndpointError(t *testing.T) { + tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"error_message":"ungueltiger code"}`)) + })) + defer tokenServer.Close() + + c := NewInstagramConnector("client-123", "secret", "https://app.deklarix.de/oauth/instagram/callback") + c.TokenURL = tokenServer.URL + + if _, err := c.Exchange(context.Background(), "falscher-code"); err == nil { + t.Fatal("expected an error when the token endpoint returns 400") + } +} diff --git a/internal/socialconnect/tiktok.go b/internal/socialconnect/tiktok.go new file mode 100644 index 0000000..0f466da --- /dev/null +++ b/internal/socialconnect/tiktok.go @@ -0,0 +1,95 @@ +package socialconnect + +import ( + "context" + "fmt" + "net/http" + "net/url" + "time" +) + +const ( + tiktokDefaultAuthorizeURL = "https://www.tiktok.com/v2/auth/authorize/" + tiktokDefaultTokenURL = "https://open.tiktokapis.com/v2/oauth/token/" + + // user.info.basic reicht für Profil-Grunddaten; video.list für den + // späteren Abruf veröffentlichter Videos (Beweissicherung). Mehr + // Scopes verlangen als nötig verzögert nur den Audit, siehe + // Paket-Kommentar. + tiktokScope = "user.info.basic,video.list" +) + +// TikTokConnector implementiert Connector für TikTok Login Kit v2 (Web- +// Flow — PKCE ist bei TikTok nur für Desktop/Mobile-Apps Pflicht, beim +// Web-Flow schützt allein der state-Parameter gegen CSRF, siehe +// TikTok-Dokumentation "Web"). +type TikTokConnector struct { + ClientKey string + ClientSecret string + RedirectURL string + + // Überschreibbar für Tests (Default: die echten TikTok-Endpunkte). + AuthorizeURL string + TokenURL string + HTTPClient *http.Client +} + +// NewTikTokConnector erstellt einen TikTokConnector mit den echten +// TikTok-Endpunkten. +func NewTikTokConnector(clientKey, clientSecret, redirectURL string) *TikTokConnector { + return &TikTokConnector{ + ClientKey: clientKey, + ClientSecret: clientSecret, + RedirectURL: redirectURL, + AuthorizeURL: tiktokDefaultAuthorizeURL, + TokenURL: tiktokDefaultTokenURL, + HTTPClient: http.DefaultClient, + } +} + +func (c *TikTokConnector) Platform() string { return "tiktok" } + +func (c *TikTokConnector) AuthorizationURL(state string) string { + v := url.Values{ + "client_key": {c.ClientKey}, + "redirect_uri": {c.RedirectURL}, + "scope": {tiktokScope}, + "response_type": {"code"}, + "state": {state}, + } + return c.AuthorizeURL + "?" + v.Encode() +} + +func (c *TikTokConnector) Exchange(ctx context.Context, code string) (Token, error) { + form := url.Values{ + "client_key": {c.ClientKey}, + "client_secret": {c.ClientSecret}, + "code": {code}, + "grant_type": {"authorization_code"}, + "redirect_uri": {c.RedirectURL}, + } + var resp struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int `json:"expires_in"` + OpenID string `json:"open_id"` + Error string `json:"error"` + ErrorDescription string `json:"error_description"` + } + if err := postForm(ctx, c.HTTPClient, c.TokenURL, form, &resp); err != nil { + return Token{}, fmt.Errorf("socialconnect: tiktok code exchange: %w", err) + } + if resp.Error != "" { + return Token{}, fmt.Errorf("socialconnect: tiktok code exchange: %s: %s", resp.Error, resp.ErrorDescription) + } + if resp.AccessToken == "" { + return Token{}, fmt.Errorf("socialconnect: tiktok code exchange: kein access_token in der Antwort") + } + + return Token{ + AccessToken: resp.AccessToken, + RefreshToken: resp.RefreshToken, + ExpiresAt: time.Now().Add(time.Duration(resp.ExpiresIn) * time.Second), + PlatformUserID: resp.OpenID, + }, nil +} diff --git a/internal/socialconnect/tiktok_test.go b/internal/socialconnect/tiktok_test.go new file mode 100644 index 0000000..d8604d7 --- /dev/null +++ b/internal/socialconnect/tiktok_test.go @@ -0,0 +1,70 @@ +package socialconnect + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestTikTokAuthorizationURL(t *testing.T) { + c := NewTikTokConnector("client-key-123", "secret", "https://app.deklarix.de/oauth/tiktok/callback") + u := c.AuthorizationURL("state-xyz") + + for _, want := range []string{ + "https://www.tiktok.com/v2/auth/authorize/?", + "client_key=client-key-123", + "state=state-xyz", + "response_type=code", + } { + if !strings.Contains(u, want) { + t.Errorf("AuthorizationURL = %q, want it to contain %q", u, want) + } + } +} + +func TestTikTokExchangeSuccess(t *testing.T) { + tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + t.Fatalf("ParseForm: %v", err) + } + if r.FormValue("client_key") != "client-key-123" { + t.Errorf("client_key = %q, want client-key-123", r.FormValue("client_key")) + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"access_token":"tt-access","refresh_token":"tt-refresh","expires_in":86400,"open_id":"tt-open-id-1"}`)) + })) + defer tokenServer.Close() + + c := NewTikTokConnector("client-key-123", "secret", "https://app.deklarix.de/oauth/tiktok/callback") + c.TokenURL = tokenServer.URL + + tok, err := c.Exchange(context.Background(), "der-code") + if err != nil { + t.Fatalf("Exchange: %v", err) + } + if tok.AccessToken != "tt-access" || tok.RefreshToken != "tt-refresh" || tok.PlatformUserID != "tt-open-id-1" { + t.Errorf("unexpected token: %+v", tok) + } +} + +func TestTikTokExchangePropagatesPlatformError(t *testing.T) { + tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // TikTok liefert Fehler oft mit Status 200, Fehlerfeldern im Body. + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"error":"invalid_grant","error_description":"code abgelaufen"}`)) + })) + defer tokenServer.Close() + + c := NewTikTokConnector("client-key-123", "secret", "https://app.deklarix.de/oauth/tiktok/callback") + c.TokenURL = tokenServer.URL + + _, err := c.Exchange(context.Background(), "abgelaufener-code") + if err == nil { + t.Fatal("expected an error when the platform response contains an error field") + } + if !strings.Contains(err.Error(), "invalid_grant") { + t.Errorf("error = %v, want it to mention invalid_grant", err) + } +} diff --git a/internal/store/migrations/0006_platform_connection.down.sql b/internal/store/migrations/0006_platform_connection.down.sql new file mode 100644 index 0000000..9577bb8 --- /dev/null +++ b/internal/store/migrations/0006_platform_connection.down.sql @@ -0,0 +1 @@ +DROP TABLE platform_connection; diff --git a/internal/store/migrations/0006_platform_connection.up.sql b/internal/store/migrations/0006_platform_connection.up.sql new file mode 100644 index 0000000..75ec10a --- /dev/null +++ b/internal/store/migrations/0006_platform_connection.up.sql @@ -0,0 +1,21 @@ +-- Ein Kunde kann seinen eigenen Instagram- oder TikTok-Account per +-- OAuth verbinden (siehe internal/socialconnect), damit die +-- Beweissicherung einen veröffentlichten Beitrag künftig direkt +-- abrufen kann statt ihn manuell hochzuladen. Anders als +-- extraction/finding/asset ist das NICHT append-only: Tokens laufen ab +-- und werden erneuert, eine Verbindung kann getrennt und neu +-- hergestellt werden — das ist ein normaler Konfigurationszustand, kein +-- Beweis-Eintrag. Ein Account hat höchstens eine Verbindung pro +-- Plattform (erneutes Verbinden ersetzt die alte, siehe +-- ON CONFLICT in UpsertPlatformConnection). +CREATE TABLE platform_connection ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + account_id UUID NOT NULL REFERENCES account (id), + platform TEXT NOT NULL CHECK (platform IN ('instagram', 'tiktok')), + platform_user_id TEXT NOT NULL, + access_token TEXT NOT NULL, + refresh_token TEXT NOT NULL DEFAULT '', + expires_at TIMESTAMPTZ, + connected_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (account_id, platform) +); diff --git a/internal/store/platformconnection.go b/internal/store/platformconnection.go new file mode 100644 index 0000000..56e3713 --- /dev/null +++ b/internal/store/platformconnection.go @@ -0,0 +1,108 @@ +package store + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" +) + +// PlatformConnection ist die per OAuth hergestellte Verbindung eines +// Accounts zu seinem eigenen Instagram- oder TikTok-Account (siehe +// internal/socialconnect). Anders als asset/finding/extraction NICHT +// append-only — ein abgelaufenes oder erneuertes Token ersetzt das alte, +// eine getrennte Verbindung wird wirklich gelöscht. +type PlatformConnection struct { + ID string + AccountID string + Platform string + PlatformUserID string + AccessToken string + RefreshToken string + ExpiresAt *time.Time + ConnectedAt time.Time +} + +// UpsertPlatformConnection legt eine Verbindung an oder ersetzt die +// bestehende für dasselbe Account+Plattform-Paar (z. B. bei erneutem +// Verbinden nach Trennen, oder wenn refresh_token erneuert wurde). +func (s *Store) UpsertPlatformConnection(ctx context.Context, accountID, platform, platformUserID, accessToken, refreshToken string, expiresAt *time.Time) (PlatformConnection, error) { + var c PlatformConnection + err := s.Pool.QueryRow(ctx, ` + INSERT INTO platform_connection (account_id, platform, platform_user_id, access_token, refresh_token, expires_at) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (account_id, platform) DO UPDATE SET + platform_user_id = EXCLUDED.platform_user_id, + access_token = EXCLUDED.access_token, + refresh_token = EXCLUDED.refresh_token, + expires_at = EXCLUDED.expires_at, + connected_at = now() + RETURNING id, account_id, platform, platform_user_id, access_token, refresh_token, expires_at, connected_at + `, accountID, platform, platformUserID, accessToken, refreshToken, expiresAt).Scan( + &c.ID, &c.AccountID, &c.Platform, &c.PlatformUserID, &c.AccessToken, &c.RefreshToken, &c.ExpiresAt, &c.ConnectedAt, + ) + if err != nil { + return PlatformConnection{}, fmt.Errorf("store: upsert platform connection: %w", err) + } + return c, nil +} + +// ListPlatformConnectionsForAccount liefert alle Plattform-Verbindungen +// eines Accounts. +func (s *Store) ListPlatformConnectionsForAccount(ctx context.Context, accountID string) ([]PlatformConnection, error) { + rows, err := s.Pool.Query(ctx, ` + SELECT id, account_id, platform, platform_user_id, access_token, refresh_token, expires_at, connected_at + FROM platform_connection WHERE account_id = $1 ORDER BY platform + `, accountID) + if err != nil { + return nil, fmt.Errorf("store: list platform connections: %w", err) + } + defer rows.Close() + + var out []PlatformConnection + for rows.Next() { + var c PlatformConnection + if err := rows.Scan(&c.ID, &c.AccountID, &c.Platform, &c.PlatformUserID, &c.AccessToken, &c.RefreshToken, &c.ExpiresAt, &c.ConnectedAt); err != nil { + return nil, fmt.Errorf("store: scan platform connection: %w", err) + } + out = append(out, c) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("store: list platform connections: %w", err) + } + return out, nil +} + +// GetPlatformConnection liest die Verbindung eines Accounts zu einer +// bestimmten Plattform. Liefert ErrNotFound, wenn keine Verbindung +// besteht — der Normalfall, solange der Kunde nichts verbunden hat. +func (s *Store) GetPlatformConnection(ctx context.Context, accountID, platform string) (PlatformConnection, error) { + var c PlatformConnection + err := s.Pool.QueryRow(ctx, ` + SELECT id, account_id, platform, platform_user_id, access_token, refresh_token, expires_at, connected_at + FROM platform_connection WHERE account_id = $1 AND platform = $2 + `, accountID, platform).Scan( + &c.ID, &c.AccountID, &c.Platform, &c.PlatformUserID, &c.AccessToken, &c.RefreshToken, &c.ExpiresAt, &c.ConnectedAt, + ) + if errors.Is(err, pgx.ErrNoRows) { + return PlatformConnection{}, ErrNotFound + } + if err != nil { + return PlatformConnection{}, fmt.Errorf("store: get platform connection: %w", err) + } + return c, nil +} + +// DeletePlatformConnection trennt eine Plattform-Verbindung. +func (s *Store) DeletePlatformConnection(ctx context.Context, accountID, platform string) error { + tag, err := s.Pool.Exec(ctx, `DELETE FROM platform_connection WHERE account_id = $1 AND platform = $2`, accountID, platform) + if err != nil { + return fmt.Errorf("store: delete platform connection: %w", err) + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} diff --git a/internal/store/platformconnection_test.go b/internal/store/platformconnection_test.go new file mode 100644 index 0000000..196514d --- /dev/null +++ b/internal/store/platformconnection_test.go @@ -0,0 +1,114 @@ +package store_test + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/netcell-it/deklarix/internal/store" +) + +func TestPlatformConnectionUpsertGetDelete(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + accID := testAccountID(t, s) + + expires := time.Now().Add(60 * 24 * time.Hour).Truncate(time.Millisecond) + c, err := s.UpsertPlatformConnection(ctx, accID, "instagram", "ig-user-1", "access-1", "", &expires) + if err != nil { + t.Fatalf("UpsertPlatformConnection: %v", err) + } + if c.Platform != "instagram" || c.PlatformUserID != "ig-user-1" { + t.Fatalf("UpsertPlatformConnection = %+v, unerwartete Werte", c) + } + + got, err := s.GetPlatformConnection(ctx, accID, "instagram") + if err != nil { + t.Fatalf("GetPlatformConnection: %v", err) + } + if got.AccessToken != "access-1" { + t.Fatalf("AccessToken = %q, want access-1", got.AccessToken) + } + + list, err := s.ListPlatformConnectionsForAccount(ctx, accID) + if err != nil { + t.Fatalf("ListPlatformConnectionsForAccount: %v", err) + } + if len(list) != 1 { + t.Fatalf("expected exactly 1 connection, got %d", len(list)) + } + + if err := s.DeletePlatformConnection(ctx, accID, "instagram"); err != nil { + t.Fatalf("DeletePlatformConnection: %v", err) + } + if _, err := s.GetPlatformConnection(ctx, accID, "instagram"); !errors.Is(err, store.ErrNotFound) { + t.Fatalf("err nach Delete = %v, want store.ErrNotFound", err) + } +} + +func TestPlatformConnectionUpsertReplacesExisting(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + accID := testAccountID(t, s) + + first, err := s.UpsertPlatformConnection(ctx, accID, "tiktok", "tt-user-1", "access-alt", "refresh-alt", nil) + if err != nil { + t.Fatalf("UpsertPlatformConnection (1): %v", err) + } + second, err := s.UpsertPlatformConnection(ctx, accID, "tiktok", "tt-user-1", "access-neu", "refresh-neu", nil) + if err != nil { + t.Fatalf("UpsertPlatformConnection (2): %v", err) + } + if second.ID != first.ID { + t.Fatalf("expected the same row to be updated (same account+platform), got a new ID") + } + if second.AccessToken != "access-neu" || second.RefreshToken != "refresh-neu" { + t.Fatalf("UpsertPlatformConnection (2) = %+v, tokens wurden nicht ersetzt", second) + } + + list, err := s.ListPlatformConnectionsForAccount(ctx, accID) + if err != nil { + t.Fatalf("ListPlatformConnectionsForAccount: %v", err) + } + if len(list) != 1 { + t.Fatalf("expected exactly 1 connection after upsert-replace, got %d", len(list)) + } +} + +func TestGetPlatformConnectionNotFound(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + accID := testAccountID(t, s) + + _, err := s.GetPlatformConnection(ctx, accID, "instagram") + if !errors.Is(err, store.ErrNotFound) { + t.Fatalf("err = %v, want store.ErrNotFound", err) + } +} + +func TestDeletePlatformConnectionNotFound(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + accID := testAccountID(t, s) + + err := s.DeletePlatformConnection(ctx, accID, "tiktok") + if !errors.Is(err, store.ErrNotFound) { + t.Fatalf("err = %v, want store.ErrNotFound", err) + } +} + +func TestPlatformConnectionIsolatedPerAccount(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + accA := testAccountID(t, s) + accB := testAccountID(t, s) + + if _, err := s.UpsertPlatformConnection(ctx, accA, "instagram", "ig-a", "token-a", "", nil); err != nil { + t.Fatalf("UpsertPlatformConnection (A): %v", err) + } + + if _, err := s.GetPlatformConnection(ctx, accB, "instagram"); !errors.Is(err, store.ErrNotFound) { + t.Fatalf("Mandant B sollte keine Verbindung von Mandant A sehen, err = %v", err) + } +} diff --git a/internal/web/oauth_handlers.go b/internal/web/oauth_handlers.go new file mode 100644 index 0000000..84feb5f --- /dev/null +++ b/internal/web/oauth_handlers.go @@ -0,0 +1,142 @@ +package web + +import ( + "errors" + "net/http" + "time" + + "github.com/netcell-it/deklarix/internal/auth" + "github.com/netcell-it/deklarix/internal/store" +) + +const oauthStateCookieName = "deklarix_oauth_state" + +// knownPlatforms sind alle Plattformen, die die Verbindungs-Übersicht +// anzeigt — unabhängig davon, ob dafür schon ein Connector konfiguriert +// ist (siehe cmd/deklarix/main.go). Eine unkonfigurierte Plattform zeigt +// "nicht konfiguriert" statt eines Verbinden-Buttons. +var knownPlatforms = []string{"instagram", "tiktok"} + +type connectionView struct { + Platform string + Configured bool + Connected bool + ConnectedAt string +} + +type connectionsData struct { + Title string + Nav navData + Connections []connectionView +} + +// handleConnectionsList zeigt, welche Plattformen der Account verbunden +// hat — Grundlage für die spätere automatische Beweissicherung +// (Post per API statt manuellem Screenshot-Upload abrufen). +func (s *Server) handleConnectionsList(w http.ResponseWriter, r *http.Request) { + accountID := currentUser(r).AccountID + existing, err := s.store.ListPlatformConnectionsForAccount(r.Context(), accountID) + if err != nil { + http.Error(w, "Verbindungen konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError) + return + } + connectedAt := map[string]string{} + for _, c := range existing { + connectedAt[c.Platform] = c.ConnectedAt.Format("02.01.2006 15:04") + } + + data := connectionsData{Title: "Verbindungen", Nav: navFor(r)} + for _, platform := range knownPlatforms { + _, configured := s.connectors[platform] + at, connected := connectedAt[platform] + data.Connections = append(data.Connections, connectionView{ + Platform: platform, Configured: configured, Connected: connected, ConnectedAt: at, + }) + } + if err := s.templates.ExecuteTemplate(w, "verbindungen", data); err != nil { + http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError) + } +} + +// handleOAuthStart leitet zum Consent-Screen der Plattform weiter. Der +// state-Wert wird in einem kurzlebigen Cookie gehalten und beim +// Callback gegengeprüft — Schutz gegen CSRF (ein Angreifer könnte sonst +// einen fremden Autorisierungscode gegen das Konto des Opfers +// einschleusen). +func (s *Server) handleOAuthStart(w http.ResponseWriter, r *http.Request) { + connector, ok := s.connectors[r.PathValue("platform")] + if !ok { + http.Error(w, "Plattform nicht konfiguriert", http.StatusNotFound) + return + } + + state, err := auth.NewSessionToken() + if err != nil { + http.Error(w, "Anfrage konnte nicht vorbereitet werden: "+err.Error(), http.StatusInternalServerError) + return + } + http.SetCookie(w, &http.Cookie{ + Name: oauthStateCookieName, Value: state, Path: "/oauth", + HttpOnly: true, Secure: r.TLS != nil, SameSite: http.SameSiteLaxMode, + Expires: time.Now().Add(10 * time.Minute), + }) + http.Redirect(w, r, connector.AuthorizationURL(state), http.StatusSeeOther) +} + +// handleOAuthCallback verarbeitet die Rückleitung von der Plattform: +// state prüfen, Code gegen ein Token tauschen, Verbindung speichern. +func (s *Server) handleOAuthCallback(w http.ResponseWriter, r *http.Request) { + connector, ok := s.connectors[r.PathValue("platform")] + if !ok { + http.Error(w, "Plattform nicht konfiguriert", http.StatusNotFound) + return + } + + // Der Nutzer hat die Autorisierung abgelehnt — kein Fehler unsererseits. + if errParam := r.URL.Query().Get("error"); errParam != "" { + http.Redirect(w, r, "/verbindungen", http.StatusSeeOther) + return + } + + stateCookie, err := r.Cookie(oauthStateCookieName) + if err != nil || stateCookie.Value == "" || stateCookie.Value != r.URL.Query().Get("state") { + http.Error(w, "ungültiger oder abgelaufener State-Parameter", http.StatusBadRequest) + return + } + http.SetCookie(w, &http.Cookie{Name: oauthStateCookieName, Value: "", Path: "/oauth", MaxAge: -1}) + + code := r.URL.Query().Get("code") + if code == "" { + http.Error(w, "kein Autorisierungscode erhalten", http.StatusBadRequest) + return + } + + token, err := connector.Exchange(r.Context(), code) + if err != nil { + http.Error(w, "Verbindung fehlgeschlagen: "+err.Error(), http.StatusBadGateway) + return + } + + var expiresAt *time.Time + if !token.ExpiresAt.IsZero() { + expiresAt = &token.ExpiresAt + } + accountID := currentUser(r).AccountID + if _, err := s.store.UpsertPlatformConnection(r.Context(), accountID, connector.Platform(), token.PlatformUserID, token.AccessToken, token.RefreshToken, expiresAt); err != nil { + http.Error(w, "Verbindung konnte nicht gespeichert werden: "+err.Error(), http.StatusInternalServerError) + return + } + + http.Redirect(w, r, "/verbindungen", http.StatusSeeOther) +} + +// handleDisconnect trennt eine Plattform-Verbindung. +func (s *Server) handleDisconnect(w http.ResponseWriter, r *http.Request) { + accountID := currentUser(r).AccountID + platform := r.PathValue("platform") + if err := s.store.DeletePlatformConnection(r.Context(), accountID, platform); err != nil && !errors.Is(err, store.ErrNotFound) { + http.Error(w, "Verbindung konnte nicht getrennt werden: "+err.Error(), http.StatusInternalServerError) + return + } + http.Redirect(w, r, "/verbindungen", http.StatusSeeOther) +} diff --git a/internal/web/oauth_handlers_test.go b/internal/web/oauth_handlers_test.go new file mode 100644 index 0000000..3c39a9f --- /dev/null +++ b/internal/web/oauth_handlers_test.go @@ -0,0 +1,225 @@ +package web_test + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "github.com/netcell-it/deklarix/internal/socialconnect" +) + +// fakeConnector ist ein socialconnect.Connector-Fake für Tests — kein +// echter HTTP-Aufruf gegen Instagram/TikTok nötig. +type fakeConnector struct { + platform string + token socialconnect.Token + err error +} + +func (f fakeConnector) Platform() string { return f.platform } +func (f fakeConnector) AuthorizationURL(state string) string { + return "https://provider.example/authorize?state=" + state + "&platform=" + f.platform +} +func (f fakeConnector) Exchange(ctx context.Context, code string) (socialconnect.Token, error) { + if f.err != nil { + return socialconnect.Token{}, f.err + } + return f.token, nil +} + +func TestConnectionsListShowsUnconfiguredPlatformsWithoutConnectButton(t *testing.T) { + s, _, cookie := newAuthedTestServer(t, fakeExtractor{}) + + resp := getWithCookie(t, s, cookie, "/verbindungen") + if resp.Code != http.StatusOK { + t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String()) + } + body := resp.Body.String() + if strings.Contains(body, "/oauth/instagram/start") || strings.Contains(body, "/oauth/tiktok/start") { + t.Errorf("expected no connect links when no connector is configured, got: %s", body) + } + if !strings.Contains(body, "instagram") || !strings.Contains(body, "tiktok") { + t.Errorf("expected both platforms to be listed regardless of configuration, got: %s", body) + } +} + +func TestConnectionsListShowsConnectButtonWhenConfigured(t *testing.T) { + fs := newFakeStore() + connectors := map[string]socialconnect.Connector{"instagram": fakeConnector{platform: "instagram"}} + s := newServerWithConnectors(t, fakeExtractor{}, fs, connectors) + cookie := seedAccount(t, fs, "Mandant", "mandant@example.com") + + resp := getWithCookie(t, s, cookie, "/verbindungen") + body := resp.Body.String() + if !strings.Contains(body, "/oauth/instagram/start") { + t.Errorf("expected an Instagram connect link, got: %s", body) + } + if strings.Contains(body, "/oauth/tiktok/start") { + t.Errorf("expected no TikTok connect link (not configured), got: %s", body) + } +} + +func TestOAuthStartRedirectsToProviderAndSetsStateCookie(t *testing.T) { + fs := newFakeStore() + connectors := map[string]socialconnect.Connector{"instagram": fakeConnector{platform: "instagram"}} + s := newServerWithConnectors(t, fakeExtractor{}, fs, connectors) + cookie := seedAccount(t, fs, "Mandant", "mandant@example.com") + + req := httptest.NewRequest(http.MethodGet, "/oauth/instagram/start", nil) + req.AddCookie(cookie) + w := httptest.NewRecorder() + s.ServeHTTP(w, req) + + if w.Code != http.StatusSeeOther { + t.Fatalf("status = %d, want 303", w.Code) + } + loc := w.Header().Get("Location") + if !strings.HasPrefix(loc, "https://provider.example/authorize?") { + t.Fatalf("Location = %q, want a redirect to the provider", loc) + } + var stateCookie *http.Cookie + for _, c := range w.Result().Cookies() { + if c.Name == "deklarix_oauth_state" { + stateCookie = c + } + } + if stateCookie == nil || stateCookie.Value == "" { + t.Fatal("expected a non-empty deklarix_oauth_state cookie to be set") + } +} + +func TestOAuthStartRejectsUnconfiguredPlatform(t *testing.T) { + s, _, cookie := newAuthedTestServer(t, fakeExtractor{}) + + resp := getWithCookie(t, s, cookie, "/oauth/instagram/start") + if resp.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404 for an unconfigured platform", resp.Code) + } +} + +func TestOAuthCallbackStoresConnectionOnValidState(t *testing.T) { + fs := newFakeStore() + expires := time.Now().Add(60 * 24 * time.Hour) + connectors := map[string]socialconnect.Connector{ + "instagram": fakeConnector{platform: "instagram", token: socialconnect.Token{ + AccessToken: "ig-token", PlatformUserID: "ig-user-1", ExpiresAt: expires, + }}, + } + s := newServerWithConnectors(t, fakeExtractor{}, fs, connectors) + cookie := seedAccount(t, fs, "Mandant", "mandant@example.com") + + req := httptest.NewRequest(http.MethodGet, "/oauth/instagram/callback?code=der-code&state=gueltiger-state", nil) + req.AddCookie(cookie) + req.AddCookie(&http.Cookie{Name: "deklarix_oauth_state", Value: "gueltiger-state"}) + w := httptest.NewRecorder() + s.ServeHTTP(w, req) + + if w.Code != http.StatusSeeOther { + t.Fatalf("status = %d, want 303, body: %s", w.Code, w.Body.String()) + } + if loc := w.Header().Get("Location"); loc != "/verbindungen" { + t.Fatalf("Location = %q, want /verbindungen", loc) + } + + var accID string + for id := range fs.accounts { + accID = id + } + conn, err := fs.GetPlatformConnection(context.Background(), accID, "instagram") + if err != nil { + t.Fatalf("expected a stored connection, got err: %v", err) + } + if conn.AccessToken != "ig-token" || conn.PlatformUserID != "ig-user-1" { + t.Errorf("unexpected connection: %+v", conn) + } +} + +func TestOAuthCallbackRejectsMismatchedState(t *testing.T) { + fs := newFakeStore() + connectors := map[string]socialconnect.Connector{ + "instagram": fakeConnector{platform: "instagram", token: socialconnect.Token{AccessToken: "x", PlatformUserID: "y"}}, + } + s := newServerWithConnectors(t, fakeExtractor{}, fs, connectors) + cookie := seedAccount(t, fs, "Mandant", "mandant@example.com") + + req := httptest.NewRequest(http.MethodGet, "/oauth/instagram/callback?code=der-code&state=falscher-state", nil) + req.AddCookie(cookie) + req.AddCookie(&http.Cookie{Name: "deklarix_oauth_state", Value: "anderer-state"}) + w := httptest.NewRecorder() + s.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400 for a state mismatch", w.Code) + } +} + +func TestOAuthCallbackHandlesUserDenial(t *testing.T) { + fs := newFakeStore() + connectors := map[string]socialconnect.Connector{ + "instagram": fakeConnector{platform: "instagram"}, + } + s := newServerWithConnectors(t, fakeExtractor{}, fs, connectors) + cookie := seedAccount(t, fs, "Mandant", "mandant@example.com") + + req := httptest.NewRequest(http.MethodGet, "/oauth/instagram/callback?error=access_denied", nil) + req.AddCookie(cookie) + w := httptest.NewRecorder() + s.ServeHTTP(w, req) + + if w.Code != http.StatusSeeOther { + t.Fatalf("status = %d, want 303 (redirect back, no error page)", w.Code) + } +} + +func TestDisconnectRemovesConnection(t *testing.T) { + fs := newFakeStore() + connectors := map[string]socialconnect.Connector{"instagram": fakeConnector{platform: "instagram"}} + s := newServerWithConnectors(t, fakeExtractor{}, fs, connectors) + cookie := seedAccount(t, fs, "Mandant", "mandant@example.com") + var accID string + for id := range fs.accounts { + accID = id + } + if _, err := fs.UpsertPlatformConnection(context.Background(), accID, "instagram", "u1", "tok", "", nil); err != nil { + t.Fatalf("UpsertPlatformConnection: %v", err) + } + + resp := postForm(t, s, cookie, "/verbindungen/instagram/trennen", url.Values{}) + if resp.Code != http.StatusSeeOther { + t.Fatalf("status = %d, want 303, body: %s", resp.Code, resp.Body.String()) + } + if _, err := fs.GetPlatformConnection(context.Background(), accID, "instagram"); err == nil { + t.Fatal("expected the connection to be gone after disconnect") + } +} + +func TestConnectionsIsolatedPerTenant(t *testing.T) { + fs := newFakeStore() + connectors := map[string]socialconnect.Connector{"instagram": fakeConnector{platform: "instagram"}} + s := newServerWithConnectors(t, fakeExtractor{}, fs, connectors) + cookieA := seedAccount(t, fs, "Mandant A", "a@example.com") + cookieB := seedAccount(t, fs, "Mandant B", "b@example.com") + + var accA string + for id, acc := range fs.accounts { + if acc.Name == "Mandant A" { + accA = id + } + } + if _, err := fs.UpsertPlatformConnection(context.Background(), accA, "instagram", "u1", "tok", "", nil); err != nil { + t.Fatalf("UpsertPlatformConnection: %v", err) + } + + respA := getWithCookie(t, s, cookieA, "/verbindungen") + if !strings.Contains(respA.Body.String(), "verbunden seit") { + t.Errorf("expected Mandant A to see their own connection, got: %s", respA.Body.String()) + } + respB := getWithCookie(t, s, cookieB, "/verbindungen") + if strings.Contains(respB.Body.String(), "verbunden seit") { + t.Errorf("expected Mandant B to see no connection, got: %s", respB.Body.String()) + } +} diff --git a/internal/web/server.go b/internal/web/server.go index 6bd9cdc..3db80ad 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -16,6 +16,7 @@ import ( "github.com/netcell-it/deklarix/internal/evidence" "github.com/netcell-it/deklarix/internal/extract" "github.com/netcell-it/deklarix/internal/rules" + "github.com/netcell-it/deklarix/internal/socialconnect" "github.com/netcell-it/deklarix/internal/store" ) @@ -72,6 +73,10 @@ type Store interface { ListAuditLog(ctx context.Context, limit int) ([]store.AuditEntry, error) CreateAsset(ctx context.Context, submissionID, kind, path, sha256Hex string) (store.Asset, error) GetLatestAssetForSubmission(ctx context.Context, submissionID string) (store.Asset, error) + + UpsertPlatformConnection(ctx context.Context, accountID, platform, platformUserID, accessToken, refreshToken string, expiresAt *time.Time) (store.PlatformConnection, error) + ListPlatformConnectionsForAccount(ctx context.Context, accountID string) ([]store.PlatformConnection, error) + DeletePlatformConnection(ctx context.Context, accountID, platform string) error } // Server bündelt Routing und Abhängigkeiten der Web-Schicht. @@ -83,14 +88,19 @@ type Server struct { timestamper evidence.Timestamper dossierDir string assetDir string + connectors map[string]socialconnect.Connector templates *template.Template } // NewServer erstellt den Server. ruleSet kommt von rules.Load und wird // einmal beim Start geladen, nicht pro Request. dossierDir ist das // Verzeichnis, in das erzeugte Nachweis-Dossiers geschrieben werden; -// assetDir das Verzeichnis für hochgeladene Standbilder. -func NewServer(extractor Extractor, ruleSet []rules.Rule, st Store, timestamper evidence.Timestamper, dossierDir, assetDir string) (*Server, error) { +// assetDir das Verzeichnis für hochgeladene Standbilder. connectors +// enthält nur die Plattformen, für die echte Client-Credentials +// konfiguriert sind (siehe cmd/deklarix/main.go) — eine leere oder nil +// Map ist gültig, dann zeigt /verbindungen "nicht konfiguriert" statt +// eines Verbinden-Buttons. +func NewServer(extractor Extractor, ruleSet []rules.Rule, st Store, timestamper evidence.Timestamper, dossierDir, assetDir string, connectors map[string]socialconnect.Connector) (*Server, error) { tmpl, err := template.ParseFS(templatesFS, "templates/*.html") if err != nil { return nil, fmt.Errorf("web: templates parsen: %w", err) @@ -103,6 +113,7 @@ func NewServer(extractor Extractor, ruleSet []rules.Rule, st Store, timestamper timestamper: timestamper, dossierDir: dossierDir, assetDir: assetDir, + connectors: connectors, templates: tmpl, } @@ -123,6 +134,10 @@ func NewServer(extractor Extractor, ruleSet []rules.Rule, st Store, timestamper mux.HandleFunc("POST /beitraege/{id}/beteiligte/{pid}/aktualisieren", s.requireAPI(s.handleUpdateParticipant)) mux.HandleFunc("POST /beitraege/{id}/beteiligte/{pid}/loeschen", s.requireAPI(s.handleDeleteParticipant)) mux.HandleFunc("GET /kanzleien", s.handlePublicKanzleiList) + mux.HandleFunc("GET /verbindungen", s.requirePage(s.handleConnectionsList)) + mux.HandleFunc("GET /oauth/{platform}/start", s.requirePage(s.handleOAuthStart)) + mux.HandleFunc("GET /oauth/{platform}/callback", s.requirePage(s.handleOAuthCallback)) + mux.HandleFunc("POST /verbindungen/{platform}/trennen", s.requireAPI(s.handleDisconnect)) mux.HandleFunc("GET /admin", s.requireAdmin(s.handleAdminDashboard)) mux.HandleFunc("GET /admin/accounts", s.requireAdmin(s.handleAdminAccountList)) mux.HandleFunc("GET /admin/accounts/{id}", s.requireAdmin(s.handleAdminAccountDetail)) diff --git a/internal/web/server_test.go b/internal/web/server_test.go index dad17ac..64129ea 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -26,6 +26,7 @@ import ( "github.com/netcell-it/deklarix/internal/auth" "github.com/netcell-it/deklarix/internal/extract" "github.com/netcell-it/deklarix/internal/rules" + "github.com/netcell-it/deklarix/internal/socialconnect" "github.com/netcell-it/deklarix/internal/store" "github.com/netcell-it/deklarix/internal/web" ) @@ -69,20 +70,25 @@ type fakeStore struct { participants map[string]store.Participant auditLog []store.AuditEntry assets map[string]store.Asset // submissionID -> zuletzt hochgeladenes Asset + + // platformConnections ist verschachtelt nach accountID -> platform, + // wie die UNIQUE(account_id, platform)-Beschränkung der echten Tabelle. + platformConnections map[string]map[string]store.PlatformConnection } func newFakeStore() *fakeStore { return &fakeStore{ - accounts: map[string]store.Account{}, - users: map[string]store.User{}, - usersByEmail: map[string]string{}, - sessions: map[string]store.Session{}, - submissions: map[string]store.Submission{}, - extractions: map[string]store.Extraction{}, - findings: map[string][]store.Finding{}, - evidencePkgs: map[string]store.EvidencePackage{}, - participants: map[string]store.Participant{}, - assets: map[string]store.Asset{}, + accounts: map[string]store.Account{}, + users: map[string]store.User{}, + usersByEmail: map[string]string{}, + sessions: map[string]store.Session{}, + submissions: map[string]store.Submission{}, + extractions: map[string]store.Extraction{}, + findings: map[string][]store.Finding{}, + evidencePkgs: map[string]store.EvidencePackage{}, + participants: map[string]store.Participant{}, + assets: map[string]store.Asset{}, + platformConnections: map[string]map[string]store.PlatformConnection{}, } } @@ -364,6 +370,59 @@ func (f *fakeStore) GetLatestAssetForSubmission(ctx context.Context, submissionI return a, nil } +func (f *fakeStore) UpsertPlatformConnection(ctx context.Context, accountID, platform, platformUserID, accessToken, refreshToken string, expiresAt *time.Time) (store.PlatformConnection, error) { + f.mu.Lock() + defer f.mu.Unlock() + byPlatform, ok := f.platformConnections[accountID] + if !ok { + byPlatform = map[string]store.PlatformConnection{} + f.platformConnections[accountID] = byPlatform + } + c := store.PlatformConnection{ + ID: f.newID(), AccountID: accountID, Platform: platform, PlatformUserID: platformUserID, + AccessToken: accessToken, RefreshToken: refreshToken, ExpiresAt: expiresAt, ConnectedAt: time.Now(), + } + if existing, ok := byPlatform[platform]; ok { + c.ID = existing.ID + } + byPlatform[platform] = c + return c, nil +} + +func (f *fakeStore) ListPlatformConnectionsForAccount(ctx context.Context, accountID string) ([]store.PlatformConnection, error) { + f.mu.Lock() + defer f.mu.Unlock() + var out []store.PlatformConnection + for _, c := range f.platformConnections[accountID] { + out = append(out, c) + } + return out, nil +} + +func (f *fakeStore) GetPlatformConnection(ctx context.Context, accountID, platform string) (store.PlatformConnection, error) { + f.mu.Lock() + defer f.mu.Unlock() + c, ok := f.platformConnections[accountID][platform] + if !ok { + return store.PlatformConnection{}, store.ErrNotFound + } + return c, nil +} + +func (f *fakeStore) DeletePlatformConnection(ctx context.Context, accountID, platform string) error { + f.mu.Lock() + defer f.mu.Unlock() + byPlatform, ok := f.platformConnections[accountID] + if !ok { + return store.ErrNotFound + } + if _, ok := byPlatform[platform]; !ok { + return store.ErrNotFound + } + delete(byPlatform, platform) + return nil +} + func (f *fakeStore) ListSubmissionsForAccount(ctx context.Context, accountID string) ([]store.SubmissionSummary, error) { f.mu.Lock() defer f.mu.Unlock() @@ -522,7 +581,12 @@ func loadRealRules(t *testing.T) []rules.Rule { func newServer(t *testing.T, ex web.Extractor, fs *fakeStore) *web.Server { t.Helper() - s, err := web.NewServer(ex, loadRealRules(t), fs, fakeTimestamper{}, t.TempDir(), t.TempDir()) + return newServerWithConnectors(t, ex, fs, nil) +} + +func newServerWithConnectors(t *testing.T, ex web.Extractor, fs *fakeStore, connectors map[string]socialconnect.Connector) *web.Server { + t.Helper() + s, err := web.NewServer(ex, loadRealRules(t), fs, fakeTimestamper{}, t.TempDir(), t.TempDir(), connectors) if err != nil { t.Fatalf("NewServer: %v", err) } diff --git a/internal/web/templates/layout.html b/internal/web/templates/layout.html index 187d775..c43b8da 100644 --- a/internal/web/templates/layout.html +++ b/internal/web/templates/layout.html @@ -10,6 +10,7 @@