Files
marcandClaude Opus 5 9f44215698 feat(oidc): Profilbild vom Identitätsanbieter übernehmen
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>
2026-08-24 13:43:22 +02:00

162 lines
4.9 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package oidc
import (
"bytes"
"context"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strings"
"time"
"github.com/rs/zerolog/log"
"golang.org/x/oauth2"
"aether/internal/auth"
"aether/internal/avatar"
)
// avatarMaxAge: Auch wenn sich die Bildadresse nicht ändert, wird höchstens
// einmal am Tag nachgesehen. Ein Anbieter, der das Bild unter gleicher Adresse
// austauscht (PocketID tut das), soll nicht ewig mit dem alten dastehen –
// aber auch nicht bei jeder Anmeldung eine Runde durchs Netz kosten.
const avatarMaxAge = 24 * time.Hour
// syncAvatar holt das Profilbild vom Identitätsanbieter, wenn nötig.
//
// Fehler bleiben hier: Die Anmeldung ist an dieser Stelle längst gelungen, und
// ein nicht erreichbares Bild darf sie nicht scheitern lassen. Es fehlt dann
// eben – die Oberfläche zeigt den Buchstaben.
func (h *Handler) syncAvatar(ctx context.Context, user *auth.User, pictureURL string, token *oauth2.Token) {
if pictureURL == "" {
return
}
key, source, updated, err := h.authStore.AvatarOf(ctx, user.ID)
if err != nil {
return
}
if key != "" && source == pictureURL && time.Since(updated) < avatarMaxAge {
return
}
data, err := h.fetchAvatar(ctx, pictureURL, token)
if err != nil {
log.Debug().Err(err).Str("user", user.Username).Msg("oidc: profilbild nicht geholt")
return
}
png, err := avatar.Process(bytes.NewReader(data))
if err != nil {
log.Debug().Err(err).Str("user", user.Username).Msg("oidc: profilbild nicht verwendbar")
return
}
if err := avatar.Store(ctx, h.blobs, h.authStore, user.ID, png, pictureURL); err != nil {
log.Warn().Err(err).Str("user", user.Username).Msg("oidc: profilbild nicht gespeichert")
return
}
user.AvatarVer = time.Now().Unix()
}
// fetchAvatar lädt die Bilddatei – aber nur vom Identitätsanbieter selbst.
//
// Die Adresse kommt aus einem Token, also von außen. Ohne Prüfung wäre das
// eine Aufforderung an den Server, eine beliebige Adresse anzufragen (SSRF):
// interne Dienste, Metadaten-Endpunkte der Cloud-Umgebung, alles. Deshalb
// zählt genau ein Rechnername – der aus OIDC_ISSUER –, und Weiterleitungen
// dürfen ihn nicht verlassen.
func (h *Handler) fetchAvatar(ctx context.Context, raw string, token *oauth2.Token) ([]byte, error) {
u, err := url.Parse(raw)
if err != nil {
return nil, fmt.Errorf("picture-adresse unlesbar: %w", err)
}
if err := h.allowedHost(u); err != nil {
return nil, err
}
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
client := &http.Client{
Timeout: 5 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 3 {
return fmt.Errorf("zu viele weiterleitungen")
}
return h.allowedHost(req.URL)
},
}
body, status, err := getBody(ctx, client, u.String(), "")
// Manche Anbieter geben das Bild nur mit Token heraus. Erst ohne fragen
// hält den Zugriffsschlüssel aus Zugriffsprotokollen heraus, wo er nicht
// hingehört; nur wenn es sein muss, geht er mit – und dann an genau den
// Anbieter, der ihn ausgestellt hat.
if (status == http.StatusUnauthorized || status == http.StatusForbidden) && token != nil && token.AccessToken != "" {
body, status, err = getBody(ctx, client, u.String(), token.AccessToken)
}
if err != nil {
return nil, err
}
if status != http.StatusOK {
return nil, fmt.Errorf("picture-adresse antwortet mit %d", status)
}
return body, nil
}
func getBody(ctx context.Context, client *http.Client, url, bearer string) ([]byte, int, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, 0, err
}
if bearer != "" {
req.Header.Set("Authorization", "Bearer "+bearer)
}
resp, err := client.Do(req)
if err != nil {
return nil, 0, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, resp.StatusCode, nil
}
b, err := io.ReadAll(io.LimitReader(resp.Body, avatar.MaxBytes))
if err != nil {
return nil, resp.StatusCode, err
}
return b, resp.StatusCode, nil
}
// allowedHost lässt nur den Ursprung durch, der auch der Aussteller ist.
//
// Verglichen wird mit Port: Derselbe Rechner auf einem anderen Port ist ein
// anderer Dienst, und genau dorthin zielte ein Angriff.
func (h *Handler) allowedHost(u *url.URL) error {
if u.Scheme != "https" && u.Scheme != "http" {
return fmt.Errorf("picture-adresse hat kein http(s): %q", u.Scheme)
}
iss, err := url.Parse(h.cfg.Issuer)
if err != nil {
return err
}
if !strings.EqualFold(originOf(u), originOf(iss)) {
return fmt.Errorf("picture-adresse zeigt auf %q, erlaubt ist nur der Anbieter %q",
originOf(u), originOf(iss))
}
return nil
}
// originOf ergänzt den weggelassenen Standardport, damit
// "https://id.example.com" und "https://id.example.com:443" gleich zählen.
func originOf(u *url.URL) string {
host := u.Host
if _, _, err := net.SplitHostPort(host); err != nil {
if u.Scheme == "https" {
host += ":443"
} else {
host += ":80"
}
}
return strings.ToLower(u.Scheme + "://" + host)
}