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>
162 lines
4.9 KiB
Go
162 lines
4.9 KiB
Go
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)
|
||
}
|