Files
Aether/backend/internal/auth/store.go
T
HoneyandClaude Opus 5 31a027c93a feat(aether): initial commit – self-hosted Cloud, Phasen 1–7
Go-Backend (chi, pgx, golang-migrate) + React/TS-Frontend als PWA.
Enthalten: Auth inkl. OIDC, Dateien mit Versionen und Papierkorb,
Freigaben (Links + intern), WebDAV, Gruppenordner, Admin/Theming,
Volltextsuche mit OCR, eigener PDF-Betrachter und WOPI-Office
(Collabora/OnlyOffice).

Nicht im Repo: deploy/.env (Sitzungsschlüssel, DB- und Admin-Passwort)
sowie alle Daten- und Build-Verzeichnisse – siehe .gitignore.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 09:25:36 +02:00

212 lines
7.8 KiB
Go
Raw 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"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// userCols ist die Spaltenliste jeder Nutzerabfrage – einmal, weil vier
// 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`
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, created_at
FROM users WHERE username = $1`,
username,
).Scan(&u.ID, &u.Username, &u.DisplayName, &passwordHash, &u.IsAdmin, &u.QuotaBytes, &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 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
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)
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 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`,
username, passwordHash, isAdmin,
).Scan(&u.ID, &u.Username, &u.DisplayName, &u.IsAdmin, &u.QuotaBytes,
&u.PrimaryColor, &u.AccentColor, &u.UseCustomTheme, &u.IsOIDC, &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 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
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)
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.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 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`,
candidate, displayName, sub,
).Scan(&u.ID, &u.Username, &u.DisplayName, &u.IsAdmin, &u.QuotaBytes,
&u.PrimaryColor, &u.AccentColor, &u.UseCustomTheme, &u.IsOIDC, &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
}())
}