Files
marcandClaude Opus 5 9f44215698 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>
2026-08-24 13:43:22 +02:00

248 lines
8.9 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package auth
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 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,
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")
type Store struct {
db *pgxpool.Pool
}
func NewStore(db *pgxpool.Pool) *Store {
return &Store{db: db}
}
func (s *Store) GetByUsername(ctx context.Context, username string) (*User, string, error) {
var u User
var passwordHash string
err := s.db.QueryRow(ctx,
`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.AvatarVer, &u.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, "", ErrNotFound
}
if err != nil {
return nil, "", fmt.Errorf("query user: %w", err)
}
return &u, passwordHash, nil
}
func (s *Store) GetByID(ctx context.Context, id uuid.UUID) (*User, error) {
var u User
err := s.db.QueryRow(ctx,
`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.AvatarVer, &u.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("query user: %w", err)
}
return &u, nil
}
func (s *Store) Create(ctx context.Context, username, passwordHash string, isAdmin bool) (*User, error) {
var u User
err := s.db.QueryRow(ctx,
`INSERT INTO users (username, password, is_admin)
VALUES ($1, $2, $3)
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.AvatarVer, &u.CreatedAt)
if err != nil {
if isUniqueViolation(err) {
return nil, ErrUsernameTaken
}
return nil, fmt.Errorf("create user: %w", err)
}
return &u, nil
}
// GetOrCreateByOIDC findet den Nutzer über sein OIDC-Subject – oder legt ihn an.
//
// linkByUsername entscheidet über den heiklen Fall: Es gibt bereits ein Konto
// mit diesem Benutzernamen, aber ohne Bindung an eine OIDC-Identität.
//
// - **aus (Voreinstellung):** Es entsteht ein *zweites* Konto. Da der Name
// schon vergeben ist, heißt es „name2" – leer, ohne Adminrechte, ohne die
// bisherigen Dateien. Wer seine Cloud bisher mit Passwort benutzt hat und
// dann OIDC einschaltet, landet also in einer fremden, leeren Cloud.
// - **an:** Das bestehende Konto wird mit der Identität verbunden. Dateien,
// Adminrecht und Freigaben bleiben, künftig meldet man sich darüber an.
//
// **Warum das nicht die Voreinstellung ist:** Verbinden heißt, dem
// Identitätsanbieter zu glauben, dass „marc" bei ihm derselbe „marc" ist wie
// hier. Bei einem eigenen Anbieter, dessen Konten man selbst anlegt, stimmt
// das. Bei einem Anbieter, bei dem sich Fremde registrieren und ihren
// Benutzernamen frei wählen können, wäre es die Übernahme fremder Konten.
// Deshalb eine bewusste Entscheidung in der .env statt einer stillen Annahme.
//
// Verbunden wird nur, was **noch keine** Bindung hat: Ein Konto, das bereits zu
// einer anderen Identität gehört, wird nie überschrieben.
func (s *Store) GetOrCreateByOIDC(ctx context.Context, sub, username, displayName string, linkByUsername bool) (*User, error) {
// First: try to find by oidc_sub.
var u User
err := s.db.QueryRow(ctx,
`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.AvatarVer, &u.CreatedAt)
if err == nil {
return &u, nil
}
// Bestehendes Konto übernehmen, falls gewünscht und noch ungebunden.
if linkByUsername {
err = s.db.QueryRow(ctx,
`UPDATE users SET oidc_sub = $1
WHERE username = $2 AND (oidc_sub IS NULL OR oidc_sub = '')
RETURNING `+userCols,
sub, username,
).Scan(&u.ID, &u.Username, &u.DisplayName, &u.IsAdmin, &u.QuotaBytes,
&u.PrimaryColor, &u.AccentColor, &u.UseCustomTheme, &u.IsOIDC, &u.AvatarVer, &u.CreatedAt)
if err == nil {
return &u, nil
}
// Kein Treffer heißt: kein solches Konto oder schon anderweitig
// gebunden. Beides führt weiter unten zum Anlegen eines neuen.
}
// Not found: create a new user. Ensure username uniqueness.
candidate := username
for i := 2; i <= 99; i++ {
err = s.db.QueryRow(ctx,
`INSERT INTO users (username, display_name, password, oidc_sub)
VALUES ($1, $2, '', $3)
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.AvatarVer, &u.CreatedAt)
if err == nil {
return &u, nil
}
if isUniqueViolation(err) {
candidate = fmt.Sprintf("%s%d", username, i)
continue
}
return nil, fmt.Errorf("create oidc user: %w", err)
}
return nil, fmt.Errorf("could not find unique username for %q", username)
}
func (s *Store) UpdateDisplayName(ctx context.Context, id uuid.UUID, displayName string) error {
_, err := s.db.Exec(ctx, `UPDATE users SET display_name=$2 WHERE id=$1`, id, displayName)
return err
}
// SetUseCustomTheme legt um, ob die persönliche Palette angewendet wird.
// Die gespeicherten Farben bleiben dabei unangetastet.
func (s *Store) SetUseCustomTheme(ctx context.Context, id uuid.UUID, enabled bool) error {
_, err := s.db.Exec(ctx, `UPDATE users SET use_custom_theme=$2 WHERE id=$1`, id, enabled)
return err
}
// UpdateColors setzt die persönliche Palette. Leere Werte werden als NULL
// gespeichert – dann gilt wieder die globale Einstellung des Administrators.
func (s *Store) UpdateColors(ctx context.Context, id uuid.UUID, primary, accent string) error {
var p, a *string
if primary != "" {
p = &primary
}
if accent != "" {
a = &accent
}
_, err := s.db.Exec(ctx,
`UPDATE users SET primary_color=$2, accent_color=$3 WHERE id=$1`, id, p, a)
return err
}
func (s *Store) UpdatePassword(ctx context.Context, id uuid.UUID, newHash string) error {
_, err := s.db.Exec(ctx, `UPDATE users SET password=$2 WHERE id=$1`, id, newHash)
return err
}
func (s *Store) AdminExists(ctx context.Context) (bool, error) {
var count int
err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM users WHERE is_admin = true`).Scan(&count)
return count > 0, err
}
func isUniqueViolation(err error) bool {
return err != nil && (containsCode(err, "23505"))
}
func containsCode(err error, code string) bool {
return err != nil && len(err.Error()) > 0 &&
(func() bool {
type pgErr interface{ SQLState() string }
var pe pgErr
if errors.As(err, &pe) {
return pe.SQLState() == code
}
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
}