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>
248 lines
8.9 KiB
Go
248 lines
8.9 KiB
Go
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
|
||
}
|