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
}