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

+2 -1
View File
@@ -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
+22
View File
@@ -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
+35
View File
@@ -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/<id>/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 (`""`, `<svg/>`, 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
+8 -1
View File
@@ -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)
@@ -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;
@@ -0,0 +1,12 @@
-- Profilbild eines Kontos.
--
-- avatar_key Schlüssel im Blob-Speicher (avatars/<user>/<zufall>.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;
+10 -1
View File
@@ -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/<id>/avatar?v=<AvatarVer>.
//
// 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"`
}
+53 -17
View File
@@ -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
}
+159
View File
@@ -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)
}
+102
View File
@@ -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{"", "<svg/>", "nicht mal ansatzweise ein bild"} {
if _, err := Process(strings.NewReader(s)); err == nil {
t.Errorf("kein Fehler für %q", s)
}
}
}
+21 -1
View File
@@ -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)
+161
View File
@@ -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)
}
+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)
}
}
+29 -1
View File
@@ -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 }
@@ -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;
@@ -0,0 +1,12 @@
-- Profilbild eines Kontos.
--
-- avatar_key Schlüssel im Blob-Speicher (avatars/<user>/<zufall>.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;
+13
View File
@@ -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<User, 'id' | 'avatar_ver'>): 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. */
+28 -4
View File
@@ -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' }}
>
<Avatar name={name} size={26} />
<Avatar name={name} src={avatarUrl(user)} size={26} />
<span style={{
fontSize: 13, fontWeight: 500, color: 'var(--color-text)',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
@@ -157,16 +158,39 @@ export default function UserMenu() {
)
}
/** Der farbige Kreis mit dem ersten Buchstaben. */
export function Avatar({ name, size = 26 }: { name: string; size?: number }) {
/**
* Das Profilbild – und solange keines da ist, der farbige Kreis mit dem ersten
* Buchstaben.
*
* Der Buchstabe liegt **unter** dem Bild statt an seiner Stelle: Lädt das Bild
* nicht (Anbieter offline, Bild inzwischen gelöscht), steht der Kreis schon da,
* statt dass ein Loch in der Leiste bleibt. Deshalb auch kein Zustand für
* „fehlgeschlagen" – ein `<img>`, das nichts anzeigt, gibt den Kreis von selbst
* frei.
*/
export function Avatar({ name, src, size = 26 }: { name: string; src?: string | null; size?: number }) {
return (
<span style={{
width: size, height: size, borderRadius: '50%',
position: 'relative', width: size, height: size, borderRadius: '50%',
background: 'var(--color-primary)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: Math.round(size * 0.45), fontWeight: 700, color: '#fff', flexShrink: 0,
overflow: 'hidden',
}}>
{(name[0] ?? '?').toUpperCase()}
{src && (
<img
src={src}
alt=""
width={size}
height={size}
style={{
position: 'absolute', inset: 0,
width: '100%', height: '100%', objectFit: 'cover',
}}
onError={e => { e.currentTarget.style.display = 'none' }}
/>
)}
</span>
)
}