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

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