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) }