feat: technisches Grundgerüst für Instagram-/TikTok-OAuth (Plattform-Verbindung)
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.
This commit is contained in:
82
internal/socialconnect/connector.go
Normal file
82
internal/socialconnect/connector.go
Normal file
@@ -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
|
||||
}
|
||||
103
internal/socialconnect/instagram.go
Normal file
103
internal/socialconnect/instagram.go
Normal file
@@ -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
|
||||
}
|
||||
82
internal/socialconnect/instagram_test.go
Normal file
82
internal/socialconnect/instagram_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
95
internal/socialconnect/tiktok.go
Normal file
95
internal/socialconnect/tiktok.go
Normal file
@@ -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
|
||||
}
|
||||
70
internal/socialconnect/tiktok_test.go
Normal file
70
internal/socialconnect/tiktok_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE platform_connection;
|
||||
21
internal/store/migrations/0006_platform_connection.up.sql
Normal file
21
internal/store/migrations/0006_platform_connection.up.sql
Normal file
@@ -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)
|
||||
);
|
||||
108
internal/store/platformconnection.go
Normal file
108
internal/store/platformconnection.go
Normal file
@@ -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
|
||||
}
|
||||
114
internal/store/platformconnection_test.go
Normal file
114
internal/store/platformconnection_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
142
internal/web/oauth_handlers.go
Normal file
142
internal/web/oauth_handlers.go
Normal file
@@ -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)
|
||||
}
|
||||
225
internal/web/oauth_handlers_test.go
Normal file
225
internal/web/oauth_handlers_test.go
Normal file
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
<nav>
|
||||
<a href="/">Prüfen</a>
|
||||
<a href="/beitraege">Beiträge</a>
|
||||
<a href="/verbindungen">Verbindungen</a>
|
||||
{{if .IsAdmin}}<a href="/admin">Admin</a>{{end}}
|
||||
<form method="post" action="/logout" style="display:inline">
|
||||
<button type="submit">Abmelden</button>
|
||||
|
||||
41
internal/web/templates/verbindungen.html
Normal file
41
internal/web/templates/verbindungen.html
Normal file
@@ -0,0 +1,41 @@
|
||||
{{define "verbindungen"}}<!doctype html>
|
||||
<html lang="de">
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<h1>Verbindungen</h1>
|
||||
<p class="hinweis">
|
||||
Verbinde deinen eigenen Instagram- oder TikTok-Account, damit die
|
||||
Beweissicherung veröffentlichte Beiträge künftig direkt abrufen kann.
|
||||
Ohne Verbindung funktioniert die Prüfung wie gewohnt mit manuellem
|
||||
Screenshot-Upload.
|
||||
</p>
|
||||
|
||||
<ul class="beteiligte">
|
||||
{{range .Connections}}
|
||||
<li class="beteiligter">
|
||||
<div class="beteiligter-kopf">
|
||||
<strong>{{.Platform}}</strong>
|
||||
{{if .Connected}}
|
||||
<span class="status status-published">verbunden seit {{.ConnectedAt}}</span>
|
||||
{{else if .Configured}}
|
||||
<span class="status status-mittel">nicht verbunden</span>
|
||||
{{else}}
|
||||
<span class="status">noch nicht konfiguriert</span>
|
||||
{{end}}
|
||||
</div>
|
||||
{{if .Connected}}
|
||||
<form method="post" action="/verbindungen/{{.Platform}}/trennen">
|
||||
<button type="submit" class="entfernen">Trennen</button>
|
||||
</form>
|
||||
{{else if .Configured}}
|
||||
<p><a href="/oauth/{{.Platform}}/start">Verbinden</a></p>
|
||||
{{end}}
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user