diff --git a/CLAUDE.md b/CLAUDE.md index 94d5b5a..4cf75e3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,6 +18,7 @@ aether/ │ │ ├── sharing/ # Öffentliche Share-Links │ │ ├── admin/ # Admin-Panel: User, Gruppen(-mitgliedschaft), Settings/Theming │ │ ├── oidc/ # OIDC-Login (optional, via .env) +│ │ ├── avatar/ # Profilbild: zuschneiden, ablegen, ausliefern │ │ ├── webdav/ # WebDAV-Endpoint /dav/ (Basic Auth – nur App-Passwörter) │ │ ├── acl/ # Interne Freigaben an User/Gruppen (Rechteauflösung) │ │ ├── audit/ # Audit-Log (Schreibpfad; Endpunkte liegen in admin/) @@ -127,7 +128,7 @@ Admin setzt globale Tokens; User-Override nur für eigene Palette (Scope: `[data - **Jede Migration in BEIDE Verzeichnisse legen:** `backend/pkg/db/migrations/` (wird ins Binary embedded und beim Start ausgeführt) UND `backend/db/migrations/` (Spiegelkopie). Beide müssen identisch sein. - Keine destruktiven Änderungen ohne explizites Down-Migration - Migration beim App-Start automatisch ausführen (via `migrate.Up()`) -- Aktueller Stand: bis `000018_tag_icons` (Emoji an Etiketten und Absendern) +- Aktueller Stand: bis `000019_user_avatar` (Profilbild eines Kontos) ## Commit-Regeln diff --git a/README.md b/README.md index 4584857..f355188 100644 --- a/README.md +++ b/README.md @@ -188,6 +188,28 @@ Das Konto bleibt, das Passwort stünde sonst dauerhaft in einer Datei. (Vorgabe „Sign in with SSO / OIDC"). „Anmelden mit PocketID" sagt dem, der davorsteht, mehr als eine Abkürzung, die man kennen muss. +### Profilbild vom Anbieter + +Schickt der Identitätsanbieter im Token ein `picture` (PocketID tut das, wenn +im Konto ein Bild hinterlegt ist), holt Aether es bei der Anmeldung ab, +schneidet es mittig quadratisch auf 256 px zu und zeigt es oben rechts. Ohne +Bild bleibt es beim farbigen Kreis mit dem Anfangsbuchstaben. + +Es gibt dafür **keinen Schalter** – nichts einzustellen, nichts, was +fehlkonfiguriert sein kann: + +* Geholt wird nur vom Anbieter selbst. Die Bildadresse kommt aus einem Token, + also von außen; erlaubt ist ausschließlich der Ursprung aus `OIDC_ISSUER`, + Weiterleitungen eingeschlossen. Sonst wäre es eine Aufforderung an den + Server, für einen Fremden beliebige Adressen im internen Netz anzufragen. +* Höchstens 4 MB, höchstens fünf Sekunden, höchstens einmal am Tag je Konto – + und nur, wenn sich die Adresse geändert hat oder noch kein Bild da ist. +* Scheitert etwas davon, geht die Anmeldung trotzdem durch. Ein nicht + erreichbares Bild ist kein Grund, jemanden auszusperren. + +Ändert sich das Bild beim Anbieter, steht es hier spätestens am nächsten Tag – +oder sofort nach dem nächsten Abmelden und Anmelden. + **`OIDC_LINK_BY_USERNAME`** verbindet ein bestehendes Konto mit derselben Kennung, statt ein zweites anzulegen. **Vor der ersten Anmeldung setzen** – danach existiert das zweite Konto (`marc2`, leer, ohne Adminrecht) schon, und diff --git a/TESTING.md b/TESTING.md index e1de044..f25a043 100644 --- a/TESTING.md +++ b/TESTING.md @@ -1274,6 +1274,41 @@ anbieten. Anlass: Bei einem Bild stand nirgends, wie groß es in **Pixeln** ist. --- +## Profilbild vom Identitätsanbieter (24.08.2026) + +Gemessen an der laufenden Instanz auf `127.0.0.1:8090`: + +| Fall | Erwartet | Gemessen | +|---|---|---| +| Bild hinterlegt, `/me` | `avatar_ver` > 0 | 1787571663 | +| `GET /users//avatar?v=…` | `200`, `image/png`, 256×256 | ✅, `private, max-age=604800, immutable` | +| ohne Anmeldung | `401` | ✅ | +| Konto ohne Bild | `404` | ✅ | +| erfundene Konto-ID | `404`, nicht `403` | ✅ | +| Blob fehlt, Zeile steht | Buchstabenkreis, kein Loch | ✅ (Screenshot) | +| GC-Lauf | Bild bleibt, 0 Waisen | ✅ (122 gescannt) | +| verwaiste Datei unter `avatars/` | wird gefunden, Schonfrist greift | ✅ (123 gescannt, 1 jung) | + +Geprüft mit Go-Tests, nicht am laufenden Anbieter: + +- Zuschnitt: 800×200, 200×800, 512×512 und 16×16 ergeben alle 256×256; bei + einem breiten Bild bleibt die Mitte stehen (linke Kante rot, rechte blau). +- JPEG wird angenommen, Unsinn (`""`, ``, Text) abgelehnt. +- **SSRF:** `169.254.169.254`, `localhost:8080`, `postgres:5432`, + `id.example.com.angreifer.net`, `id.example.com:9200`, `file://`, `gopher://` + werden alle abgewiesen; eine Weiterleitung auf einen fremden Rechner wird + nicht befolgt. +- Ein Anbieter, der erst `401` sagt, bekommt genau einen zweiten Versuch mit + Token – nicht mehr. +- Eine 8-MB-Antwort wird bei 4 MB abgeschnitten. + +**Noch offen (braucht deinen echten PocketID):** +- [ ] Meldet PocketID `picture` im ID-Token oder erst über UserInfo? +- [ ] Kommt das Bild ohne Token heraus, oder greift der zweite Versuch? +- [ ] Bild in PocketID austauschen → steht nach dem nächsten Anmelden hier +- [ ] Bild in PocketID entfernen → das alte bleibt hier stehen (so gebaut: + gelöscht wird nur beim Austausch, nicht auf Verdacht) + ## Automatisiert geprüft (22.08.2026) Diese Punkte hat Claude per API/`curl` gegen Wegwerf-Konten durchgespielt; sie diff --git a/backend/cmd/aether/main.go b/backend/cmd/aether/main.go index c02a65c..11ac8ba 100644 --- a/backend/cmd/aether/main.go +++ b/backend/cmd/aether/main.go @@ -25,6 +25,7 @@ import ( "aether/internal/admin" "aether/internal/audit" "aether/internal/auth" + "aether/internal/avatar" "aether/internal/documents" "aether/internal/files" "aether/internal/gc" @@ -240,6 +241,8 @@ func main() { } } + avatarHandler := avatar.NewHandler(authStore, blobStore) + // WebDAV davHandler := appwebdav.NewHandler(authStore, fileStore, aclStore, blobStore) @@ -251,7 +254,7 @@ func main() { RedirectURL: cfg.OIDCRedirectURL, LinkByUsername: cfg.OIDCLinkByUsername, - }, authStore, sessions) + }, authStore, sessions, blobStore) if err != nil { log.Fatal().Err(err).Msg("OIDC init failed") } @@ -487,6 +490,10 @@ func main() { r.Delete("/acl/{id}", aclHandler.RevokeACL) // App passwords (WebDAV/sync clients) + // Profilbild eines Kontos. Sichtbar für jedes angemeldete Konto – + // es steht ohnehin an Freigaben und in Nutzerlisten. + r.Get("/users/{id}/avatar", avatarHandler.Serve) + r.Get("/app-passwords", authHandler.ListAppPasswords) r.Post("/app-passwords", authHandler.CreateAppPassword) r.Delete("/app-passwords/{id}", authHandler.DeleteAppPassword) diff --git a/backend/db/migrations/000019_user_avatar.down.sql b/backend/db/migrations/000019_user_avatar.down.sql new file mode 100644 index 0000000..95dfff0 --- /dev/null +++ b/backend/db/migrations/000019_user_avatar.down.sql @@ -0,0 +1,4 @@ +ALTER TABLE users + DROP COLUMN IF EXISTS avatar_key, + DROP COLUMN IF EXISTS avatar_source, + DROP COLUMN IF EXISTS avatar_updated_at; diff --git a/backend/db/migrations/000019_user_avatar.up.sql b/backend/db/migrations/000019_user_avatar.up.sql new file mode 100644 index 0000000..04724d8 --- /dev/null +++ b/backend/db/migrations/000019_user_avatar.up.sql @@ -0,0 +1,12 @@ +-- Profilbild eines Kontos. +-- +-- avatar_key Schlüssel im Blob-Speicher (avatars//.png). +-- Der Zufallsanteil ist der Cache-Brecher: Ein Bild unter +-- gleichem Namen bliebe im Browser hängen. +-- avatar_source Woher es stammt (die picture-Adresse des Anbieters). Wird +-- verglichen, um bei jeder Anmeldung nicht neu zu laden. +-- avatar_updated_at Zugleich Zeitstempel und Version in der Bild-Adresse. +ALTER TABLE users + ADD COLUMN avatar_key TEXT, + ADD COLUMN avatar_source TEXT, + ADD COLUMN avatar_updated_at TIMESTAMPTZ; diff --git a/backend/internal/auth/model.go b/backend/internal/auth/model.go index f0b2aac..4d95639 100644 --- a/backend/internal/auth/model.go +++ b/backend/internal/auth/model.go @@ -27,7 +27,16 @@ type User struct { // Nicht sicherheitsrelevant, sondern eine Auskunft: Anmeldung und // Passwortänderung scheitern für solche Konten ohnehin schon daran, dass // '' kein gültiger Argon2-Hash ist. - IsOIDC bool `json:"is_oidc"` + IsOIDC bool `json:"is_oidc"` + // AvatarVer ist der Zeitstempel des Profilbildes in Sekunden – 0 heißt: + // keins vorhanden. Die Oberfläche baut daraus die Adresse + // /api/v1/users//avatar?v=. + // + // Warum eine Zahl statt einer fertigen Adresse: Das Bild darf im Browser + // lange liegen bleiben, muss aber sofort wechseln, wenn jemand es + // austauscht. Der Zeitstempel leistet beides und kostet keine Spalte + // extra. + AvatarVer int64 `json:"avatar_ver"` CreatedAt time.Time `json:"created_at"` } diff --git a/backend/internal/auth/store.go b/backend/internal/auth/store.go index 182c91a..a77d1de 100644 --- a/backend/internal/auth/store.go +++ b/backend/internal/auth/store.go @@ -4,18 +4,21 @@ import ( "context" "errors" "fmt" + "time" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) -// userCols ist die Spaltenliste jeder Nutzerabfrage – einmal, weil vier +// userCols ist die Spaltenliste jeder Nutzerabfrage – einmal, weil fünf // Abfragen mit derselben Reihenfolge scannen und eine übersehene Ergänzung // sonst erst zur Laufzeit auffällt. const userCols = `id, username, display_name, is_admin, quota_bytes, COALESCE(primary_color,''), COALESCE(accent_color,''), use_custom_theme, - oidc_sub IS NOT NULL AND oidc_sub <> '' AS is_oidc, created_at` + oidc_sub IS NOT NULL AND oidc_sub <> '' AS is_oidc, + COALESCE(EXTRACT(EPOCH FROM avatar_updated_at)::BIGINT, 0) AS avatar_ver, + created_at` var ErrNotFound = errors.New("user not found") var ErrUsernameTaken = errors.New("username already taken") @@ -32,10 +35,12 @@ func (s *Store) GetByUsername(ctx context.Context, username string) (*User, stri var u User var passwordHash string err := s.db.QueryRow(ctx, - `SELECT id, username, display_name, password, is_admin, quota_bytes, created_at + `SELECT id, username, display_name, password, is_admin, quota_bytes, + COALESCE(EXTRACT(EPOCH FROM avatar_updated_at)::BIGINT, 0), created_at FROM users WHERE username = $1`, username, - ).Scan(&u.ID, &u.Username, &u.DisplayName, &passwordHash, &u.IsAdmin, &u.QuotaBytes, &u.CreatedAt) + ).Scan(&u.ID, &u.Username, &u.DisplayName, &passwordHash, &u.IsAdmin, &u.QuotaBytes, + &u.AvatarVer, &u.CreatedAt) if errors.Is(err, pgx.ErrNoRows) { return nil, "", ErrNotFound } @@ -48,12 +53,11 @@ func (s *Store) GetByUsername(ctx context.Context, username string) (*User, stri func (s *Store) GetByID(ctx context.Context, id uuid.UUID) (*User, error) { var u User err := s.db.QueryRow(ctx, - `SELECT id, username, display_name, is_admin, quota_bytes, - COALESCE(primary_color,''), COALESCE(accent_color,''), use_custom_theme, oidc_sub IS NOT NULL AND oidc_sub <> '' AS is_oidc, created_at + `SELECT `+userCols+` FROM users WHERE id = $1`, id, ).Scan(&u.ID, &u.Username, &u.DisplayName, &u.IsAdmin, &u.QuotaBytes, - &u.PrimaryColor, &u.AccentColor, &u.UseCustomTheme, &u.IsOIDC, &u.CreatedAt) + &u.PrimaryColor, &u.AccentColor, &u.UseCustomTheme, &u.IsOIDC, &u.AvatarVer, &u.CreatedAt) if errors.Is(err, pgx.ErrNoRows) { return nil, ErrNotFound } @@ -68,11 +72,10 @@ func (s *Store) Create(ctx context.Context, username, passwordHash string, isAdm err := s.db.QueryRow(ctx, `INSERT INTO users (username, password, is_admin) VALUES ($1, $2, $3) - RETURNING id, username, display_name, is_admin, quota_bytes, - COALESCE(primary_color,''), COALESCE(accent_color,''), use_custom_theme, oidc_sub IS NOT NULL AND oidc_sub <> '' AS is_oidc, created_at`, + RETURNING `+userCols+``, username, passwordHash, isAdmin, ).Scan(&u.ID, &u.Username, &u.DisplayName, &u.IsAdmin, &u.QuotaBytes, - &u.PrimaryColor, &u.AccentColor, &u.UseCustomTheme, &u.IsOIDC, &u.CreatedAt) + &u.PrimaryColor, &u.AccentColor, &u.UseCustomTheme, &u.IsOIDC, &u.AvatarVer, &u.CreatedAt) if err != nil { if isUniqueViolation(err) { return nil, ErrUsernameTaken @@ -107,12 +110,11 @@ func (s *Store) GetOrCreateByOIDC(ctx context.Context, sub, username, displayNam // First: try to find by oidc_sub. var u User err := s.db.QueryRow(ctx, - `SELECT id, username, display_name, is_admin, quota_bytes, - COALESCE(primary_color,''), COALESCE(accent_color,''), use_custom_theme, oidc_sub IS NOT NULL AND oidc_sub <> '' AS is_oidc, created_at + `SELECT `+userCols+` FROM users WHERE oidc_sub=$1`, sub, ).Scan(&u.ID, &u.Username, &u.DisplayName, &u.IsAdmin, &u.QuotaBytes, - &u.PrimaryColor, &u.AccentColor, &u.UseCustomTheme, &u.IsOIDC, &u.CreatedAt) + &u.PrimaryColor, &u.AccentColor, &u.UseCustomTheme, &u.IsOIDC, &u.AvatarVer, &u.CreatedAt) if err == nil { return &u, nil } @@ -125,7 +127,7 @@ func (s *Store) GetOrCreateByOIDC(ctx context.Context, sub, username, displayNam RETURNING `+userCols, sub, username, ).Scan(&u.ID, &u.Username, &u.DisplayName, &u.IsAdmin, &u.QuotaBytes, - &u.PrimaryColor, &u.AccentColor, &u.UseCustomTheme, &u.IsOIDC, &u.CreatedAt) + &u.PrimaryColor, &u.AccentColor, &u.UseCustomTheme, &u.IsOIDC, &u.AvatarVer, &u.CreatedAt) if err == nil { return &u, nil } @@ -139,11 +141,10 @@ func (s *Store) GetOrCreateByOIDC(ctx context.Context, sub, username, displayNam err = s.db.QueryRow(ctx, `INSERT INTO users (username, display_name, password, oidc_sub) VALUES ($1, $2, '', $3) - RETURNING id, username, display_name, is_admin, quota_bytes, - COALESCE(primary_color,''), COALESCE(accent_color,''), use_custom_theme, oidc_sub IS NOT NULL AND oidc_sub <> '' AS is_oidc, created_at`, + RETURNING `+userCols+``, candidate, displayName, sub, ).Scan(&u.ID, &u.Username, &u.DisplayName, &u.IsAdmin, &u.QuotaBytes, - &u.PrimaryColor, &u.AccentColor, &u.UseCustomTheme, &u.IsOIDC, &u.CreatedAt) + &u.PrimaryColor, &u.AccentColor, &u.UseCustomTheme, &u.IsOIDC, &u.AvatarVer, &u.CreatedAt) if err == nil { return &u, nil } @@ -209,3 +210,38 @@ func containsCode(err error, code string) bool { return false }()) } + +// ── Profilbild ──────────────────────────────────────────────────────────────── + +// AvatarOf liefert Speicherschlüssel und Herkunft des Profilbildes. Beides ist +// leer, wenn keines hinterlegt ist. +func (s *Store) AvatarOf(ctx context.Context, id uuid.UUID) (key, source string, updated time.Time, err error) { + err = s.db.QueryRow(ctx, + `SELECT COALESCE(avatar_key,''), COALESCE(avatar_source,''), + COALESCE(avatar_updated_at, 'epoch'::TIMESTAMPTZ) + FROM users WHERE id = $1`, id, + ).Scan(&key, &source, &updated) + if errors.Is(err, pgx.ErrNoRows) { + return "", "", time.Time{}, ErrNotFound + } + return key, source, updated, err +} + +// SetAvatar trägt ein neues Profilbild ein und gibt den Schlüssel des alten +// zurück – der Aufrufer löscht dessen Blob, nachdem die Zeile steht. +// +// Reihenfolge wie beim Logo: erst schreiben, dann eintragen, dann das alte +// wegräumen. Andersherum stünde bei einem Abbruch dazwischen ein Schlüssel in +// der Datenbank, zu dem es keine Datei mehr gibt. +func (s *Store) SetAvatar(ctx context.Context, id uuid.UUID, key, source string) (oldKey string, err error) { + err = s.db.QueryRow(ctx, + `UPDATE users u SET avatar_key = $2, avatar_source = $3, avatar_updated_at = NOW() + FROM (SELECT id, avatar_key FROM users WHERE id = $1) old + WHERE u.id = old.id + RETURNING COALESCE(old.avatar_key,'')`, id, key, source, + ).Scan(&oldKey) + if errors.Is(err, pgx.ErrNoRows) { + return "", ErrNotFound + } + return oldKey, err +} diff --git a/backend/internal/avatar/avatar.go b/backend/internal/avatar/avatar.go new file mode 100644 index 0000000..1a1c73a --- /dev/null +++ b/backend/internal/avatar/avatar.go @@ -0,0 +1,159 @@ +// Package avatar hält das Profilbild eines Kontos: aufbereiten, ablegen, +// ausliefern. +// +// Aufbereiten heißt hier immer: quadratisch, 256×256, PNG. Ein Profilbild wird +// als Kreis angezeigt, deshalb wird mittig zugeschnitten statt gerahmt – anders +// als beim App-Symbol, wo ein Zuschnitt ein Logo anschneiden würde. +package avatar + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "image" + "image/png" + "io" + "net/http" + "strconv" + + // Dekodierer für die verbreiteten Formate. Ohne diese Zeilen kennt + // image.Decode nur das, was sonst noch importiert ist. + _ "image/gif" + _ "image/jpeg" + _ "image/png" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + "golang.org/x/image/draw" + _ "golang.org/x/image/webp" + + "aether/internal/auth" + "aether/internal/storage" + "aether/pkg/response" +) + +// Size ist die Kantenlänge des abgelegten Bildes. 256 reicht für jede Stelle, +// an der es vorkommt (26 px in der Leiste, 64 px in den Einstellungen), auch +// auf Bildschirmen mit doppelter Auflösung. +const Size = 256 + +// MaxBytes begrenzt, was überhaupt entgegengenommen wird – sowohl beim +// Herunterladen vom Identitätsanbieter als auch bei einem späteren Upload. +const MaxBytes = 4 << 20 + +// Process liest ein Bild und gibt ein quadratisches PNG zurück. +func Process(r io.Reader) ([]byte, error) { + src, _, err := image.Decode(io.LimitReader(r, MaxBytes)) + if err != nil { + return nil, fmt.Errorf("bild nicht lesbar: %w", err) + } + b := src.Bounds() + if b.Dx() <= 0 || b.Dy() <= 0 { + return nil, fmt.Errorf("bild ist leer") + } + + // Mittiges Quadrat aus der Vorlage – die längere Seite wird beschnitten. + side := b.Dx() + if b.Dy() < side { + side = b.Dy() + } + crop := image.Rect(0, 0, side, side). + Add(image.Pt(b.Min.X+(b.Dx()-side)/2, b.Min.Y+(b.Dy()-side)/2)) + + dst := image.NewRGBA(image.Rect(0, 0, Size, Size)) + // CatmullRom statt des BiLinear aus internal/thumbnails: Ein Gesicht auf + // 26 px verkleinert braucht die schärfere Rechnung, und es passiert + // einmal je Anmeldung, nicht je Vorschaubild. + draw.CatmullRom.Scale(dst, dst.Bounds(), src, crop, draw.Src, nil) + + var out bytes.Buffer + if err := png.Encode(&out, dst); err != nil { + return nil, fmt.Errorf("bild nicht schreibbar: %w", err) + } + return out.Bytes(), nil +} + +// Key erzeugt einen neuen Speicherschlüssel für ein Konto. Der Zufallsanteil +// ist der Cache-Brecher: Unter gleichem Namen bliebe das alte Bild im Browser +// hängen, obwohl längst ein anderes hinterlegt ist. +func Key(userID uuid.UUID) (string, error) { + b := make([]byte, 8) + if _, err := rand.Read(b); err != nil { + return "", err + } + return "avatars/" + userID.String() + "/" + hex.EncodeToString(b) + ".png", nil +} + +// Store legt ein aufbereitetes Bild ab und trägt es beim Konto ein. Das +// vorherige Bild wird erst danach gelöscht. +func Store(ctx context.Context, st storage.Storage, users *auth.Store, userID uuid.UUID, data []byte, source string) error { + key, err := Key(userID) + if err != nil { + return err + } + if _, _, err := st.Put(ctx, key, bytes.NewReader(data)); err != nil { + return fmt.Errorf("avatar ablegen: %w", err) + } + oldKey, err := users.SetAvatar(ctx, userID, key, source) + if err != nil { + // Die Zeile steht nicht – dann darf auch der Blob nicht bleiben, + // sonst räumt ihn erst der GC nach einem Tag weg. + _ = st.Delete(ctx, key) + return fmt.Errorf("avatar eintragen: %w", err) + } + if oldKey != "" && oldKey != key { + _ = st.Delete(ctx, oldKey) + } + return nil +} + +// ── Ausliefern ──────────────────────────────────────────────────────────────── + +type Handler struct { + users *auth.Store + storage storage.Storage +} + +func NewHandler(users *auth.Store, st storage.Storage) *Handler { + return &Handler{users: users, storage: st} +} + +// Serve beantwortet GET /api/v1/users/{id}/avatar. +// +// Jedes angemeldete Konto darf jedes Profilbild sehen – es steht ohnehin an +// jeder Freigabe und in jeder Nutzerliste. Ohne Anmeldung dagegen nicht: Der +// Endpunkt hängt hinter der geschützten Route. +func (h *Handler) Serve(w http.ResponseWriter, r *http.Request) { + id, err := uuid.Parse(chi.URLParam(r, "id")) + if err != nil { + response.Error(w, http.StatusNotFound, "not found") + return + } + key, _, _, err := h.users.AvatarOf(r.Context(), id) + if err != nil || key == "" { + // Auch ein unbekanntes Konto endet hier mit 404 – nicht 403. Sonst + // verriete der Unterschied, welche Kennungen es gibt. + response.Error(w, http.StatusNotFound, "not found") + return + } + rc, err := h.storage.Get(r.Context(), key) + if err != nil { + response.Error(w, http.StatusNotFound, "not found") + return + } + defer rc.Close() + + w.Header().Set("Content-Type", "image/png") + w.Header().Set("X-Content-Type-Options", "nosniff") + // Lange zwischenspeichern ist gefahrlos, weil die Adresse den Zeitstempel + // trägt: Ein neues Bild ist eine neue Adresse. "private", weil es zu einem + // Konto gehört und in keinem gemeinsamen Zwischenspeicher liegen soll. + if r.URL.Query().Get("v") != "" { + w.Header().Set("Cache-Control", "private, max-age="+strconv.Itoa(7*24*60*60)+", immutable") + } else { + w.Header().Set("Cache-Control", "private, no-cache") + } + _, _ = io.Copy(w, rc) +} diff --git a/backend/internal/avatar/avatar_test.go b/backend/internal/avatar/avatar_test.go new file mode 100644 index 0000000..7761bb4 --- /dev/null +++ b/backend/internal/avatar/avatar_test.go @@ -0,0 +1,102 @@ +package avatar + +import ( + "bytes" + "image" + "image/color" + "image/jpeg" + "image/png" + "strings" + "testing" +) + +// Ein Bild, dessen linke Hälfte rot und rechte Hälfte blau ist – so lässt sich +// nachrechnen, welcher Ausschnitt genommen wurde. +func striped(w, h int) image.Image { + img := image.NewRGBA(image.Rect(0, 0, w, h)) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + if x < w/2 { + img.Set(x, y, color.RGBA{255, 0, 0, 255}) + } else { + img.Set(x, y, color.RGBA{0, 0, 255, 255}) + } + } + } + return img +} + +func encodePNG(t *testing.T, img image.Image) []byte { + t.Helper() + var b bytes.Buffer + if err := png.Encode(&b, img); err != nil { + t.Fatal(err) + } + return b.Bytes() +} + +func TestProcessMachtQuadrat(t *testing.T) { + cases := []struct { + name string + w, h int + }{ + {"breit", 800, 200}, + {"hoch", 200, 800}, + {"quadratisch", 512, 512}, + {"winzig", 16, 16}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + out, err := Process(bytes.NewReader(encodePNG(t, striped(c.w, c.h)))) + if err != nil { + t.Fatalf("Process: %v", err) + } + img, _, err := image.Decode(bytes.NewReader(out)) + if err != nil { + t.Fatalf("Ergebnis nicht lesbar: %v", err) + } + b := img.Bounds() + if b.Dx() != Size || b.Dy() != Size { + t.Errorf("Größe %dx%d, erwartet %dx%d", b.Dx(), b.Dy(), Size, Size) + } + }) + } +} + +// Bei einem breiten Bild wird mittig geschnitten, nicht links angesetzt: Die +// linke Kante des Ergebnisses muss deshalb noch rot sein, die rechte blau. +func TestProcessSchneidetMittig(t *testing.T) { + out, err := Process(bytes.NewReader(encodePNG(t, striped(1000, 200)))) + if err != nil { + t.Fatal(err) + } + img, _, _ := image.Decode(bytes.NewReader(out)) + r, _, bl, _ := img.At(4, Size/2).RGBA() + if r < 0x8000 || bl > 0x4000 { + t.Errorf("linke Kante ist nicht rot – Ausschnitt sitzt falsch") + } + r, _, bl = func() (uint32, uint32, uint32) { a, b, c, _ := img.At(Size-5, Size/2).RGBA(); return a, b, c }() + if bl < 0x8000 || r > 0x4000 { + t.Errorf("rechte Kante ist nicht blau – Ausschnitt sitzt falsch") + } +} + +func TestProcessNimmtJPEG(t *testing.T) { + var b bytes.Buffer + if err := jpeg.Encode(&b, striped(300, 300), nil); err != nil { + t.Fatal(err) + } + if _, err := Process(bytes.NewReader(b.Bytes())); err != nil { + t.Errorf("JPEG abgelehnt: %v", err) + } +} + +// Was kein Bild ist, muss als Fehler zurückkommen – und nicht etwa als leere +// Datei im Speicher landen. +func TestProcessLehntUnsinnAb(t *testing.T) { + for _, s := range []string{"", "", "nicht mal ansatzweise ein bild"} { + if _, err := Process(strings.NewReader(s)); err == nil { + t.Errorf("kein Fehler für %q", s) + } + } +} diff --git a/backend/internal/gc/gc.go b/backend/internal/gc/gc.go index c4bf949..331ebfb 100644 --- a/backend/internal/gc/gc.go +++ b/backend/internal/gc/gc.go @@ -72,7 +72,7 @@ func (c *Collector) Run(ctx context.Context) (Result, error) { // "tmp/" bleibt außen vor: Das gehört dem Upload-Pfad und wird dort // aufgeräumt; ein GC, der dort mitmischt, zerrisse laufende Uploads. - for _, prefix := range []string{"users", "thumbs", "branding"} { + for _, prefix := range []string{"users", "thumbs", "branding", "avatars"} { err := c.storage.Walk(ctx, prefix, func(key string, info storage.FileInfo) error { res.Scanned++ if referenced[key] { @@ -194,6 +194,26 @@ func (c *Collector) referencedKeys(ctx context.Context) (map[string]bool, error) } } + // Profilbilder stehen in der Nutzerzeile, nicht in files. Ohne diese + // Abfrage hielte der GC sie für verwaist, sobald "avatars" mitläuft. + arows, err := c.db.Query(ctx, + `SELECT avatar_key FROM users WHERE avatar_key IS NOT NULL AND avatar_key <> ''`) + if err != nil { + return nil, fmt.Errorf("query avatar keys: %w", err) + } + for arows.Next() { + var key string + if err := arows.Scan(&key); err != nil { + arows.Close() + return nil, fmt.Errorf("scan avatar key: %w", err) + } + keys[strings.TrimPrefix(key, "/")] = true + } + arows.Close() + if err := arows.Err(); err != nil { + return nil, fmt.Errorf("iterate avatar keys: %w", err) + } + vrows, err := c.db.Query(ctx, `SELECT storage_key FROM file_versions`) if err != nil { return nil, fmt.Errorf("query version keys: %w", err) diff --git a/backend/internal/oidc/avatar.go b/backend/internal/oidc/avatar.go new file mode 100644 index 0000000..d0f8dae --- /dev/null +++ b/backend/internal/oidc/avatar.go @@ -0,0 +1,161 @@ +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) +} diff --git a/backend/internal/oidc/avatar_test.go b/backend/internal/oidc/avatar_test.go new file mode 100644 index 0000000..da53735 --- /dev/null +++ b/backend/internal/oidc/avatar_test.go @@ -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) + } +} diff --git a/backend/internal/oidc/handler.go b/backend/internal/oidc/handler.go index 72c826b..7e13e5b 100644 --- a/backend/internal/oidc/handler.go +++ b/backend/internal/oidc/handler.go @@ -14,6 +14,7 @@ import ( "golang.org/x/oauth2" "aether/internal/auth" + "aether/internal/storage" "aether/pkg/middleware" "aether/pkg/response" ) @@ -40,10 +41,11 @@ type Handler struct { 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) (*Handler, error) { +func New(ctx context.Context, cfg Config, authStore *auth.Store, sessions *auth.SessionStore, blobs storage.Storage) (*Handler, error) { if cfg.Issuer == "" { return nil, nil } @@ -77,6 +79,7 @@ func New(ctx context.Context, cfg Config, authStore *auth.Store, sessions *auth. authStore: authStore, sessions: sessions, + blobs: blobs, }, nil } @@ -140,6 +143,7 @@ func (h *Handler) Callback(w http.ResponseWriter, r *http.Request) { 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) @@ -163,6 +167,14 @@ func (h *Handler) Callback(w http.ResponseWriter, r *http.Request) { 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 @@ -174,6 +186,22 @@ func (h *Handler) Callback(w http.ResponseWriter, r *http.Request) { 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 } diff --git a/backend/pkg/db/migrations/000019_user_avatar.down.sql b/backend/pkg/db/migrations/000019_user_avatar.down.sql new file mode 100644 index 0000000..95dfff0 --- /dev/null +++ b/backend/pkg/db/migrations/000019_user_avatar.down.sql @@ -0,0 +1,4 @@ +ALTER TABLE users + DROP COLUMN IF EXISTS avatar_key, + DROP COLUMN IF EXISTS avatar_source, + DROP COLUMN IF EXISTS avatar_updated_at; diff --git a/backend/pkg/db/migrations/000019_user_avatar.up.sql b/backend/pkg/db/migrations/000019_user_avatar.up.sql new file mode 100644 index 0000000..04724d8 --- /dev/null +++ b/backend/pkg/db/migrations/000019_user_avatar.up.sql @@ -0,0 +1,12 @@ +-- Profilbild eines Kontos. +-- +-- avatar_key Schlüssel im Blob-Speicher (avatars//.png). +-- Der Zufallsanteil ist der Cache-Brecher: Ein Bild unter +-- gleichem Namen bliebe im Browser hängen. +-- avatar_source Woher es stammt (die picture-Adresse des Anbieters). Wird +-- verglichen, um bei jeder Anmeldung nicht neu zu laden. +-- avatar_updated_at Zugleich Zeitstempel und Version in der Bild-Adresse. +ALTER TABLE users + ADD COLUMN avatar_key TEXT, + ADD COLUMN avatar_source TEXT, + ADD COLUMN avatar_updated_at TIMESTAMPTZ; diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 54ab5dd..a833450 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -15,9 +15,22 @@ export interface User { /** Konto meldet sich über den Identitätsanbieter an und hat hier kein * Passwort – die Passwortänderung wird dann ausgeblendet. */ is_oidc?: boolean + /** Zeitstempel des Profilbildes in Sekunden; 0 oder fehlend = keins. + * Adresse dazu über avatarUrl(). */ + avatar_ver?: number created_at: string } +/** + * Adresse des Profilbildes – oder null, wenn keines hinterlegt ist. + * + * Der Zeitstempel hängt als ?v= dran, damit der Browser das Bild lange behalten + * darf und es trotzdem sofort wechselt, sobald ein anderes hinterlegt wird. + */ +export function avatarUrl(user: Pick): string | null { + return user.avatar_ver ? `/api/v1/users/${user.id}/avatar?v=${user.avatar_ver}` : null +} + export interface FileItem { id: string /** Datum auf dem Beleg (YYYY-MM-DD) – nicht das des Uploads. */ diff --git a/frontend/src/components/UserMenu.tsx b/frontend/src/components/UserMenu.tsx index 7d2e9ea..a6662f6 100644 --- a/frontend/src/components/UserMenu.tsx +++ b/frontend/src/components/UserMenu.tsx @@ -1,5 +1,6 @@ import { createContext, useContext, useEffect, useRef, useState } from 'react' import type { ReactNode } from 'react' +import { avatarUrl } from '../api/client' import type { User } from '../api/client' import type { View } from '../hooks/useNavHistory' @@ -94,7 +95,7 @@ export default function UserMenu() { onMouseEnter={e => { if (!open) e.currentTarget.style.background = 'var(--color-surface-2)' }} onMouseLeave={e => { if (!open) e.currentTarget.style.background = 'transparent' }} > - + `, das nichts anzeigt, gibt den Kreis von selbst + * frei. + */ +export function Avatar({ name, src, size = 26 }: { name: string; src?: string | null; size?: number }) { return ( {(name[0] ?? '?').toUpperCase()} + {src && ( + { e.currentTarget.style.display = 'none' }} + /> + )} ) }