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>
This commit is contained in:
marcandClaude Opus 5 committed 2026-08-24 13:43:22 +02:00
1 parent 3019e84681
commit 9f44215698
18 files changed
+814 -26

No files matched your search

+139
View File
@@ -0,0 +1,139 @@
package oidc
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"golang.org/x/oauth2"
"aether/internal/avatar"
)
// Die Bildadresse stammt aus einem Token, also von außen. Erlaubt ist genau
// der Ursprung des Ausstellers – alles andere wäre eine Aufforderung an den
// Server, für einen Fremden beliebige Adressen anzufragen.
func TestAllowedHost(t *testing.T) {
h := &Handler{cfg: Config{Issuer: "https://id.example.com"}}
erlaubt := []string{
"https://id.example.com/api/users/1/profile-picture.png",
"https://ID.EXAMPLE.COM/bild.png", // Groß-/Kleinschreibung zählt nicht
"https://id.example.com:443/bild.png",
}
for _, raw := range erlaubt {
u, err := url.Parse(raw)
if err != nil {
t.Fatal(err)
}
if err := h.allowedHost(u); err != nil {
t.Errorf("%s abgelehnt: %v", raw, err)
}
}
verboten := []string{
"http://169.254.169.254/latest/meta-data/", // Metadaten der Umgebung
"http://localhost:8080/api/v1/admin/users", // die eigene App
"http://postgres:5432/", // Nachbarcontainer
"https://id.example.com.angreifer.net/x.png", // Name nur vorne gleich
"https://id.example.com:9200/x.png", // gleicher Rechner, anderer Dienst
"file:///etc/passwd",
"gopher://id.example.com/",
}
for _, raw := range verboten {
u, err := url.Parse(raw)
if err != nil {
t.Fatal(err)
}
if err := h.allowedHost(u); err == nil {
t.Errorf("%s wurde durchgelassen", raw)
}
}
}
// Ein Anbieter, der selbst auf einem Port läuft, muss zu sich selbst passen.
func TestAllowedHostMitPortImIssuer(t *testing.T) {
h := &Handler{cfg: Config{Issuer: "http://id.example.com:9000"}}
ok, _ := url.Parse("http://id.example.com:9000/bild.png")
if err := h.allowedHost(ok); err != nil {
t.Errorf("eigener Port abgelehnt: %v", err)
}
nein, _ := url.Parse("http://id.example.com/bild.png")
if err := h.allowedHost(nein); err == nil {
t.Error("Port 80 wurde für einen Anbieter auf 9000 durchgelassen")
}
}
// Der ganze Weg über das Netz – mit einem Anbieter, der erst 401 sagt und das
// Bild nur gegen Token herausgibt. Genau so verhalten sich Anbieter, die
// Profilbilder nicht öffentlich stellen.
func TestFetchAvatarHoltMitTokenNach(t *testing.T) {
var ohneToken, mitToken int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer geheim" {
ohneToken++
w.WriteHeader(http.StatusUnauthorized)
return
}
mitToken++
w.Write([]byte("PNG-Attrappe"))
}))
defer srv.Close()
h := &Handler{cfg: Config{Issuer: srv.URL}}
body, err := h.fetchAvatar(context.Background(), srv.URL+"/bild.png",
&oauth2.Token{AccessToken: "geheim"})
if err != nil {
t.Fatalf("fetchAvatar: %v", err)
}
if string(body) != "PNG-Attrappe" {
t.Errorf("Inhalt %q", body)
}
if ohneToken != 1 || mitToken != 1 {
t.Errorf("erwartet je ein Versuch, gemessen ohne=%d mit=%d", ohneToken, mitToken)
}
}
// Eine Weiterleitung darf den Anbieter nicht verlassen – sonst wäre die
// Prüfung der ersten Adresse wertlos.
func TestFetchAvatarFolgtKeinerFremdenWeiterleitung(t *testing.T) {
fremd := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Error("der fremde Server wurde angefragt")
}))
defer fremd.Close()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, fremd.URL+"/beute", http.StatusFound)
}))
defer srv.Close()
h := &Handler{cfg: Config{Issuer: srv.URL}}
if _, err := h.fetchAvatar(context.Background(), srv.URL+"/bild.png", nil); err == nil {
t.Fatal("Weiterleitung auf einen fremden Rechner wurde befolgt")
}
}
// Was der Anbieter schickt, ist unbegrenzt groß denkbar. Mehr als MaxBytes
// darf nie in den Speicher wandern.
func TestFetchAvatarBegrenztDieGroesse(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
buf := make([]byte, 1<<20)
for i := 0; i < 8; i++ {
if _, err := w.Write(buf); err != nil {
return
}
}
}))
defer srv.Close()
h := &Handler{cfg: Config{Issuer: srv.URL}}
body, err := h.fetchAvatar(context.Background(), srv.URL+"/riesig.png", nil)
if err != nil {
t.Fatalf("fetchAvatar: %v", err)
}
if len(body) > avatar.MaxBytes {
t.Errorf("%d Bytes gelesen, erlaubt sind %d", len(body), avatar.MaxBytes)
}
}