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:
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)
|
||||
}
|
||||
Reference in New Issue
Block a user