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:
1 parent
3019e84681
commit
9f44215698
18 files changed
+814
-26
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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. */
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user