Schickt der Anbieter ein `picture`, wird es bei der Anmeldung geholt,
mittig auf 256x256 zugeschnitten und oben rechts angezeigt. Fehlt es
oder scheitert das Holen, bleibt es beim Buchstabenkreis - die
Anmeldung selbst hängt nicht davon ab.
Die Bildadresse kommt aus einem Token, also von außen. Erlaubt ist
deshalb ausschließlich der Ursprung aus OIDC_ISSUER, Weiterleitungen
eingeschlossen; dazu 4 MB und 5 Sekunden als Deckel. Ohne das wäre es
eine Aufforderung an den Server, für einen Fremden beliebige Adressen
im internen Netz anzufragen.
Neu: Migration 000019 (avatar_key/-source/-updated_at), Paket
internal/avatar, Route GET /api/v1/users/{id}/avatar. Der GC durchsucht
"avatars/" mit und kennt die Schlüssel - sonst räumte er die Bilder
nach einem Tag weg.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
237 lines
7.1 KiB
Go
237 lines
7.1 KiB
Go
package oidc
|
||
|
||
import (
|
||
"context"
|
||
"crypto/rand"
|
||
"encoding/base64"
|
||
"encoding/json"
|
||
"fmt"
|
||
"net/http"
|
||
"net/url"
|
||
"strings"
|
||
|
||
gooidc "github.com/coreos/go-oidc/v3/oidc"
|
||
"golang.org/x/oauth2"
|
||
|
||
"aether/internal/auth"
|
||
"aether/internal/storage"
|
||
"aether/pkg/middleware"
|
||
"aether/pkg/response"
|
||
)
|
||
|
||
const stateCookie = "oidc_state"
|
||
|
||
// Config holds OIDC provider settings (all required if OIDC is enabled).
|
||
type Config struct {
|
||
Issuer string
|
||
ClientID string
|
||
ClientSecret string
|
||
RedirectURL string
|
||
// LinkByUsername: bestehendes Konto gleichen Namens übernehmen statt ein
|
||
// zweites anzulegen. Siehe auth.Store.GetOrCreateByOIDC.
|
||
LinkByUsername bool
|
||
}
|
||
|
||
// Handler implements /auth/oidc/login and /auth/oidc/callback.
|
||
type Handler struct {
|
||
cfg Config
|
||
provider *gooidc.Provider
|
||
oauth2 *oauth2.Config
|
||
verifier *gooidc.IDTokenVerifier
|
||
|
||
authStore *auth.Store
|
||
sessions *auth.SessionStore
|
||
blobs storage.Storage
|
||
}
|
||
|
||
// New creates an OIDC handler. Returns nil if cfg.Issuer is empty (OIDC disabled).
|
||
func New(ctx context.Context, cfg Config, authStore *auth.Store, sessions *auth.SessionStore, blobs storage.Storage) (*Handler, error) {
|
||
if cfg.Issuer == "" {
|
||
return nil, nil
|
||
}
|
||
// Erst prüfen, ob überhaupt eine Adresse dasteht. Sonst hängt der Fehler
|
||
// tief in der Bibliothek: `unsupported protocol scheme ""` sagt nicht,
|
||
// dass jemand den *Namen* des Anbieters eingetragen hat statt seiner URL.
|
||
// Und weil ein Fehlschlag hier den Start abbricht, steht die ganze Cloud
|
||
// wegen einer Zeile in der .env – dann soll die Zeile wenigstens genannt
|
||
// werden. (Gemessen mit OIDC_ISSUER=PocketID.)
|
||
if u, uerr := url.Parse(cfg.Issuer); uerr != nil || u.Scheme == "" || u.Host == "" {
|
||
return nil, fmt.Errorf(
|
||
"OIDC_ISSUER muss die vollstaendige Adresse des Anbieters sein, "+
|
||
"z. B. https://id.example.com – gefunden: %q", cfg.Issuer)
|
||
}
|
||
provider, err := gooidc.NewProvider(ctx, cfg.Issuer)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("oidc provider %q nicht erreichbar: %w", cfg.Issuer, err)
|
||
}
|
||
oauthCfg := &oauth2.Config{
|
||
ClientID: cfg.ClientID,
|
||
ClientSecret: cfg.ClientSecret,
|
||
RedirectURL: cfg.RedirectURL,
|
||
Endpoint: provider.Endpoint(),
|
||
Scopes: []string{gooidc.ScopeOpenID, "profile"},
|
||
}
|
||
return &Handler{
|
||
cfg: cfg,
|
||
provider: provider,
|
||
oauth2: oauthCfg,
|
||
verifier: provider.Verifier(&gooidc.Config{ClientID: cfg.ClientID}),
|
||
|
||
authStore: authStore,
|
||
sessions: sessions,
|
||
blobs: blobs,
|
||
}, nil
|
||
}
|
||
|
||
// GET /api/v1/auth/oidc/login – redirect to OIDC provider.
|
||
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
|
||
state, err := randomState()
|
||
if err != nil {
|
||
response.Error(w, http.StatusInternalServerError, "state generation failed")
|
||
return
|
||
}
|
||
// Secure wie bei Sitzungs- und CSRF-Cookie: Hinter dem Reverse Proxy sieht
|
||
// die App selbst kein TLS, deshalb über IsHTTPS (das X-Forwarded-Proto
|
||
// auswertet – aber nur von vertrauenswürdigen Proxys). Ohne das ginge der
|
||
// Zustandswert auch über eine unverschlüsselte Verbindung mit.
|
||
http.SetCookie(w, &http.Cookie{
|
||
Name: stateCookie, Value: state, Path: "/",
|
||
MaxAge: 300, HttpOnly: true, SameSite: http.SameSiteLaxMode,
|
||
Secure: middleware.IsHTTPS(r),
|
||
})
|
||
http.Redirect(w, r, h.oauth2.AuthCodeURL(state), http.StatusFound)
|
||
}
|
||
|
||
// GET /api/v1/auth/oidc/callback – exchange code, create session, redirect to SPA.
|
||
func (h *Handler) Callback(w http.ResponseWriter, r *http.Request) {
|
||
// Validate state.
|
||
stateCk, err := r.Cookie(stateCookie)
|
||
if err != nil || r.URL.Query().Get("state") != stateCk.Value {
|
||
http.Redirect(w, r, "/?error=oidc_state", http.StatusFound)
|
||
return
|
||
}
|
||
// Clear state cookie.
|
||
http.SetCookie(w, &http.Cookie{Name: stateCookie, Value: "", Path: "/", MaxAge: -1})
|
||
|
||
code := r.URL.Query().Get("code")
|
||
if code == "" {
|
||
http.Redirect(w, r, "/?error=oidc_no_code", http.StatusFound)
|
||
return
|
||
}
|
||
|
||
// Exchange code for tokens.
|
||
token, err := h.oauth2.Exchange(r.Context(), code)
|
||
if err != nil {
|
||
http.Redirect(w, r, "/?error=oidc_exchange", http.StatusFound)
|
||
return
|
||
}
|
||
|
||
rawIDToken, ok := token.Extra("id_token").(string)
|
||
if !ok {
|
||
http.Redirect(w, r, "/?error=oidc_no_id_token", http.StatusFound)
|
||
return
|
||
}
|
||
|
||
idToken, err := h.verifier.Verify(r.Context(), rawIDToken)
|
||
if err != nil {
|
||
http.Redirect(w, r, "/?error=oidc_verify", http.StatusFound)
|
||
return
|
||
}
|
||
|
||
// Extract claims.
|
||
var claims struct {
|
||
Sub string `json:"sub"`
|
||
PreferredUsername string `json:"preferred_username"`
|
||
Name string `json:"name"`
|
||
Picture string `json:"picture"`
|
||
}
|
||
if err := idToken.Claims(&claims); err != nil {
|
||
http.Redirect(w, r, "/?error=oidc_claims", http.StatusFound)
|
||
return
|
||
}
|
||
|
||
// Derive username: prefer preferred_username, fall back to sub.
|
||
username := sanitizeUsername(claims.PreferredUsername)
|
||
if username == "" {
|
||
username = sanitizeUsername(claims.Sub)
|
||
}
|
||
if username == "" {
|
||
http.Redirect(w, r, "/?error=oidc_no_username", http.StatusFound)
|
||
return
|
||
}
|
||
|
||
// Find or create user.
|
||
user, err := h.authStore.GetOrCreateByOIDC(r.Context(), claims.Sub, username, claims.Name, h.cfg.LinkByUsername)
|
||
if err != nil {
|
||
http.Redirect(w, r, "/?error=oidc_user", http.StatusFound)
|
||
return
|
||
}
|
||
|
||
// Profilbild übernehmen. Steht es nicht im Token, beim UserInfo-Endpunkt
|
||
// nachfragen – manche Anbieter führen `picture` nur dort.
|
||
picture := claims.Picture
|
||
if picture == "" {
|
||
picture = h.pictureFromUserInfo(r.Context(), token)
|
||
}
|
||
h.syncAvatar(r.Context(), user, picture, token)
|
||
|
||
if err := h.sessions.SetUser(w, r, user.ID); err != nil {
|
||
http.Redirect(w, r, "/?error=oidc_session", http.StatusFound)
|
||
return
|
||
}
|
||
|
||
// Set CSRF cookie so the SPA can function immediately after redirect.
|
||
_, _ = auth.SetCSRFCookie(w, r)
|
||
|
||
http.Redirect(w, r, "/", http.StatusFound)
|
||
}
|
||
|
||
// pictureFromUserInfo fragt den UserInfo-Endpunkt nach der Bildadresse.
|
||
// Fehlschläge sind stumm: ohne Bild geht es weiter.
|
||
func (h *Handler) pictureFromUserInfo(ctx context.Context, token *oauth2.Token) string {
|
||
info, err := h.provider.UserInfo(ctx, oauth2.StaticTokenSource(token))
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
var c struct {
|
||
Picture string `json:"picture"`
|
||
}
|
||
if err := info.Claims(&c); err != nil {
|
||
return ""
|
||
}
|
||
return c.Picture
|
||
}
|
||
|
||
// PublicConfig returns whether OIDC is enabled (called without auth).
|
||
func (h *Handler) IsEnabled() bool { return h != nil }
|
||
|
||
// ConfigJSON writes the OIDC config state (for login page).
|
||
func ConfigJSON(enabled bool, cloudName string) []byte {
|
||
b, _ := json.Marshal(map[string]any{
|
||
"oidc_enabled": enabled,
|
||
"cloud_name": cloudName,
|
||
})
|
||
return b
|
||
}
|
||
|
||
// ── helpers ───────────────────────────────────────────────────────────────────
|
||
|
||
func randomState() (string, error) {
|
||
b := make([]byte, 16)
|
||
if _, err := rand.Read(b); err != nil {
|
||
return "", err
|
||
}
|
||
return base64.URLEncoding.EncodeToString(b), nil
|
||
}
|
||
|
||
func sanitizeUsername(s string) string {
|
||
s = strings.ToLower(strings.TrimSpace(s))
|
||
var out strings.Builder
|
||
for _, r := range s {
|
||
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' || r == '-' || r == '.' {
|
||
out.WriteRune(r)
|
||
}
|
||
}
|
||
return out.String()
|
||
}
|