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>
212 lines
7.8 KiB
Go
212 lines
7.8 KiB
Go
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
|
||
}())
|
||
}
|