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>
This commit is contained in:
commit
31a027c93a
225 files changed
+38042
No files matched your search
@@ -0,0 +1,264 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"aether/internal/audit"
|
||||
"aether/pkg/middleware"
|
||||
"aether/pkg/response"
|
||||
)
|
||||
|
||||
// App-Passwörter ersetzen das Hauptpasswort in WebDAV-Clients. Ein verlorenes
|
||||
// Handy kostet dann ein Token, nicht das Konto – und der Widerruf trifft nur
|
||||
// dieses eine Gerät.
|
||||
|
||||
const (
|
||||
appPasswordGroups = 5
|
||||
appPasswordGroupLen = 5
|
||||
// Ohne 0/1/l/o/i – auf einem Handy-Keyboard abgetippt sind die zu leicht
|
||||
// zu verwechseln.
|
||||
appPasswordAlphabet = "abcdefghjkmnpqrstuvwxyz23456789"
|
||||
maxAppPasswords = 20
|
||||
// Schreiblast bremsen: WebDAV authentifiziert jeden Request einzeln, ein
|
||||
// UPDATE pro Aufruf wäre reine Verschwendung.
|
||||
lastUsedResolution = 5 * time.Minute
|
||||
)
|
||||
|
||||
var ErrTooManyAppPasswords = errors.New("too many app passwords")
|
||||
|
||||
type AppPassword struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
LastUsedAt *time.Time `json:"last_used_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// GenerateAppPassword liefert ein Token in Fünfergruppen (aaaaa-bbbbb-…),
|
||||
// damit es sich am Handy noch abtippen lässt.
|
||||
func GenerateAppPassword() (string, error) {
|
||||
buf := make([]byte, appPasswordGroups*appPasswordGroupLen)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", fmt.Errorf("read random: %w", err)
|
||||
}
|
||||
var sb strings.Builder
|
||||
for i, b := range buf {
|
||||
if i > 0 && i%appPasswordGroupLen == 0 {
|
||||
sb.WriteByte('-')
|
||||
}
|
||||
sb.WriteByte(appPasswordAlphabet[int(b)%len(appPasswordAlphabet)])
|
||||
}
|
||||
return sb.String(), nil
|
||||
}
|
||||
|
||||
// HashAppPassword: SHA-256 genügt, weil das Token zufällig und hochentropisch
|
||||
// ist. Argon2 wäre hier nur langsam, nicht sicherer – und liefe bei WebDAV bei
|
||||
// jedem einzelnen Request.
|
||||
func HashAppPassword(token string) string {
|
||||
sum := sha256.Sum256([]byte(strings.TrimSpace(token)))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// ── Store ────────────────────────────────────────────────────────────────────
|
||||
|
||||
func (s *Store) ListAppPasswords(ctx context.Context, userID uuid.UUID) ([]AppPassword, error) {
|
||||
rows, err := s.db.Query(ctx,
|
||||
`SELECT id, name, last_used_at, created_at
|
||||
FROM app_passwords WHERE user_id = $1 ORDER BY created_at DESC`,
|
||||
userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query app passwords: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []AppPassword{}
|
||||
for rows.Next() {
|
||||
var a AppPassword
|
||||
if err := rows.Scan(&a.ID, &a.Name, &a.LastUsedAt, &a.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan app password: %w", err)
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) CreateAppPassword(ctx context.Context, userID uuid.UUID, name, token string) (*AppPassword, error) {
|
||||
var count int
|
||||
if err := s.db.QueryRow(ctx,
|
||||
`SELECT count(*) FROM app_passwords WHERE user_id = $1`, userID).Scan(&count); err != nil {
|
||||
return nil, fmt.Errorf("count app passwords: %w", err)
|
||||
}
|
||||
if count >= maxAppPasswords {
|
||||
return nil, ErrTooManyAppPasswords
|
||||
}
|
||||
|
||||
var a AppPassword
|
||||
err := s.db.QueryRow(ctx,
|
||||
`INSERT INTO app_passwords (user_id, name, token_hash)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, name, last_used_at, created_at`,
|
||||
userID, name, HashAppPassword(token),
|
||||
).Scan(&a.ID, &a.Name, &a.LastUsedAt, &a.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("insert app password: %w", err)
|
||||
}
|
||||
return &a, nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteAppPassword(ctx context.Context, userID, id uuid.UUID) error {
|
||||
tag, err := s.db.Exec(ctx,
|
||||
`DELETE FROM app_passwords WHERE id = $1 AND user_id = $2`, id, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete app password: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// VerifyAppPassword sucht das Token über seinen Hash und gibt den zugehörigen
|
||||
// User zurück. Der Username muss passen, damit ein Token nicht für ein fremdes
|
||||
// Konto gilt.
|
||||
func (s *Store) VerifyAppPassword(ctx context.Context, username, token string) (*User, error) {
|
||||
hash := HashAppPassword(token)
|
||||
|
||||
var u User
|
||||
var storedHash string
|
||||
var pwID uuid.UUID
|
||||
var lastUsed *time.Time
|
||||
err := s.db.QueryRow(ctx,
|
||||
`SELECT ap.id, ap.token_hash, ap.last_used_at,
|
||||
u.id, u.username, u.display_name, u.is_admin, u.quota_bytes, u.created_at
|
||||
FROM app_passwords ap
|
||||
JOIN users u ON u.id = ap.user_id
|
||||
WHERE ap.token_hash = $1 AND u.username = $2`,
|
||||
hash, username,
|
||||
).Scan(&pwID, &storedHash, &lastUsed,
|
||||
&u.ID, &u.Username, &u.DisplayName, &u.IsAdmin, &u.QuotaBytes, &u.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query app password: %w", err)
|
||||
}
|
||||
// Gürtel und Hosenträger – der Vergleich lief schon in der Datenbank.
|
||||
if subtle.ConstantTimeCompare([]byte(storedHash), []byte(hash)) != 1 {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
if lastUsed == nil || time.Since(*lastUsed) > lastUsedResolution {
|
||||
if _, err := s.db.Exec(ctx,
|
||||
`UPDATE app_passwords SET last_used_at = now() WHERE id = $1`, pwID); err != nil {
|
||||
return nil, fmt.Errorf("touch app password: %w", err)
|
||||
}
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
// ── Handlers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// GET /api/v1/app-passwords
|
||||
func (h *Handler) ListAppPasswords(w http.ResponseWriter, r *http.Request) {
|
||||
user := UserFromContext(r.Context())
|
||||
list, err := h.store.ListAppPasswords(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "could not load app passwords")
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, list)
|
||||
}
|
||||
|
||||
// POST /api/v1/app-passwords
|
||||
func (h *Handler) CreateAppPassword(w http.ResponseWriter, r *http.Request) {
|
||||
user := UserFromContext(r.Context())
|
||||
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(body.Name)
|
||||
if name == "" {
|
||||
response.Error(w, http.StatusBadRequest, "name required")
|
||||
return
|
||||
}
|
||||
if len(name) > 60 {
|
||||
name = name[:60]
|
||||
}
|
||||
|
||||
token, err := GenerateAppPassword()
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "could not generate app password")
|
||||
return
|
||||
}
|
||||
|
||||
created, err := h.store.CreateAppPassword(r.Context(), user.ID, name, token)
|
||||
if errors.Is(err, ErrTooManyAppPasswords) {
|
||||
response.Error(w, http.StatusConflict, "app password limit reached")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "could not create app password")
|
||||
return
|
||||
}
|
||||
|
||||
h.auditor.Log(r.Context(), audit.Event{
|
||||
ActorID: &user.ID,
|
||||
ActorName: user.Username,
|
||||
Action: audit.ActionAppPasswordCreate,
|
||||
TargetType: "app_password",
|
||||
TargetID: created.ID.String(),
|
||||
TargetName: created.Name,
|
||||
IP: middleware.RealIP(r),
|
||||
})
|
||||
|
||||
// Einziger Moment, in dem das Klartext-Token existiert – danach steht nur
|
||||
// noch der Hash in der Datenbank.
|
||||
response.JSON(w, http.StatusCreated, struct {
|
||||
AppPassword
|
||||
Password string `json:"password"`
|
||||
}{AppPassword: *created, Password: token})
|
||||
}
|
||||
|
||||
// DELETE /api/v1/app-passwords/{id}
|
||||
func (h *Handler) DeleteAppPassword(w http.ResponseWriter, r *http.Request) {
|
||||
user := UserFromContext(r.Context())
|
||||
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
if err := h.store.DeleteAppPassword(r.Context(), user.ID, id); errors.Is(err, ErrNotFound) {
|
||||
response.Error(w, http.StatusNotFound, "app password not found")
|
||||
return
|
||||
} else if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "could not revoke app password")
|
||||
return
|
||||
}
|
||||
h.auditor.Log(r.Context(), audit.Event{
|
||||
ActorID: &user.ID,
|
||||
ActorName: user.Username,
|
||||
Action: audit.ActionAppPasswordRevoke,
|
||||
TargetType: "app_password",
|
||||
TargetID: id.String(),
|
||||
IP: middleware.RealIP(r),
|
||||
})
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGenerateAppPasswordFormat(t *testing.T) {
|
||||
token, err := GenerateAppPassword()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateAppPassword: %v", err)
|
||||
}
|
||||
|
||||
groups := strings.Split(token, "-")
|
||||
if len(groups) != appPasswordGroups {
|
||||
t.Fatalf("got %d groups, want %d (%q)", len(groups), appPasswordGroups, token)
|
||||
}
|
||||
for _, g := range groups {
|
||||
if len(g) != appPasswordGroupLen {
|
||||
t.Fatalf("group %q has length %d, want %d", g, len(g), appPasswordGroupLen)
|
||||
}
|
||||
for _, c := range g {
|
||||
if !strings.ContainsRune(appPasswordAlphabet, c) {
|
||||
t.Fatalf("character %q is outside the alphabet – hard to type on a phone", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateAppPasswordIsRandom(t *testing.T) {
|
||||
seen := make(map[string]bool, 100)
|
||||
for i := 0; i < 100; i++ {
|
||||
token, err := GenerateAppPassword()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateAppPassword: %v", err)
|
||||
}
|
||||
if seen[token] {
|
||||
t.Fatalf("duplicate token after %d draws: %q", i, token)
|
||||
}
|
||||
seen[token] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Der Klartext darf nie gespeichert werden – gehasht wird deterministisch,
|
||||
// damit der Login ein reiner Index-Lookup bleibt.
|
||||
func TestHashAppPassword(t *testing.T) {
|
||||
const token = "abcde-fghij-klmno-pqrst-uvwxy"
|
||||
|
||||
h := HashAppPassword(token)
|
||||
if h == token || strings.Contains(h, token) {
|
||||
t.Fatal("hash leaks the plaintext token")
|
||||
}
|
||||
if len(h) != 64 {
|
||||
t.Fatalf("hash length = %d, want 64 hex chars", len(h))
|
||||
}
|
||||
if h != HashAppPassword(token) {
|
||||
t.Fatal("hash is not deterministic")
|
||||
}
|
||||
if h == HashAppPassword("abcde-fghij-klmno-pqrst-uvwxz") {
|
||||
t.Fatal("different tokens hash to the same value")
|
||||
}
|
||||
// Clients hängen beim Kopieren gern Whitespace an.
|
||||
if h != HashAppPassword(" "+token+"\n") {
|
||||
t.Fatal("surrounding whitespace changes the hash")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"aether/internal/audit"
|
||||
"aether/pkg/middleware"
|
||||
"aether/pkg/response"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
store *Store
|
||||
sessions *SessionStore
|
||||
usage UsageQuerier
|
||||
auditor *audit.Logger
|
||||
}
|
||||
|
||||
func NewHandler(store *Store, sessions *SessionStore, usage UsageQuerier, auditor *audit.Logger) *Handler {
|
||||
return &Handler{store: store, sessions: sessions, usage: usage, auditor: auditor}
|
||||
}
|
||||
|
||||
type loginRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// POST /api/v1/auth/login
|
||||
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
|
||||
var req loginRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if req.Username == "" || req.Password == "" {
|
||||
response.Error(w, http.StatusBadRequest, "username and password required")
|
||||
return
|
||||
}
|
||||
|
||||
user, hash, err := h.store.GetByUsername(r.Context(), req.Username)
|
||||
if err != nil {
|
||||
// constant-time: always hash even if user not found to prevent timing attacks
|
||||
_, _ = VerifyPassword(req.Password, "$argon2id$v=19$m=65536,t=1,p=4$AAAAAAAAAAAAAAAAAAAAAA$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")
|
||||
h.auditor.Log(r.Context(), audit.Event{
|
||||
ActorName: req.Username,
|
||||
Action: audit.ActionLoginFailed,
|
||||
IP: middleware.RealIP(r),
|
||||
Detail: "unknown account",
|
||||
})
|
||||
response.Error(w, http.StatusUnauthorized, "invalid credentials")
|
||||
return
|
||||
}
|
||||
|
||||
ok, err := VerifyPassword(req.Password, hash)
|
||||
if err != nil || !ok {
|
||||
h.auditor.Log(r.Context(), audit.Event{
|
||||
ActorID: &user.ID,
|
||||
ActorName: user.Username,
|
||||
Action: audit.ActionLoginFailed,
|
||||
IP: middleware.RealIP(r),
|
||||
Detail: "wrong password",
|
||||
})
|
||||
response.Error(w, http.StatusUnauthorized, "invalid credentials")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.sessions.SetUser(w, r, user.ID); err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "session error")
|
||||
return
|
||||
}
|
||||
|
||||
csrfToken, err := SetCSRFCookie(w, r)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "csrf error")
|
||||
return
|
||||
}
|
||||
|
||||
h.auditor.Log(r.Context(), audit.Event{
|
||||
ActorID: &user.ID,
|
||||
ActorName: user.Username,
|
||||
Action: audit.ActionLogin,
|
||||
IP: middleware.RealIP(r),
|
||||
})
|
||||
|
||||
// Denselben Wert wie /me mitgeben: Ohne ihn stand der Speicherbalken
|
||||
// direkt nach der Anmeldung auf 0 B und sprang erst beim nächsten
|
||||
// Nutzer-Abgleich auf den echten Stand.
|
||||
if used, err := h.usage.UsedBytes(r.Context(), user.ID); err == nil {
|
||||
user.UsedBytes = used
|
||||
}
|
||||
|
||||
response.JSON(w, http.StatusOK, map[string]any{
|
||||
"user": user,
|
||||
"csrf_token": csrfToken,
|
||||
})
|
||||
}
|
||||
|
||||
// POST /api/v1/auth/logout
|
||||
func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
|
||||
// Die Route liegt bewusst nicht hinter RequireAuth – abmelden soll auch
|
||||
// mit abgelaufener Sitzung klappen und nie mit 401 scheitern. Dadurch
|
||||
// steht aber kein Nutzer im Kontext, und der Eintrag im Protokoll blieb
|
||||
// bisher aus. Also hier selbst aus der Sitzung nachschlagen.
|
||||
if userID, ok := h.sessions.GetUserID(r); ok {
|
||||
name := ""
|
||||
if u, err := h.store.GetByID(r.Context(), userID); err == nil {
|
||||
name = u.Username
|
||||
}
|
||||
h.auditor.Log(r.Context(), audit.Event{
|
||||
ActorID: &userID,
|
||||
ActorName: name,
|
||||
Action: audit.ActionLogout,
|
||||
IP: middleware.RealIP(r),
|
||||
})
|
||||
}
|
||||
_ = h.sessions.Clear(w, r)
|
||||
// clear CSRF cookie
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: csrfCookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
})
|
||||
response.NoContent(w)
|
||||
}
|
||||
|
||||
// PATCH /api/v1/auth/profile – update own display_name
|
||||
// isHexColor lässt leere Werte zu – leer bedeutet "globale Farbe verwenden".
|
||||
func isHexColor(v string) bool {
|
||||
if v == "" {
|
||||
return true
|
||||
}
|
||||
if len(v) != 7 || v[0] != '#' {
|
||||
return false
|
||||
}
|
||||
for _, c := range v[1:] {
|
||||
if !strings.ContainsRune("0123456789abcdefABCDEF", c) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
|
||||
user := UserFromContext(r.Context())
|
||||
// Zeiger, damit sich "Feld nicht mitgeschickt" von "auf leer setzen"
|
||||
// unterscheiden lässt: Der Profilname und die Farben werden getrennt
|
||||
// gespeichert, sollen sich aber nicht gegenseitig überschreiben.
|
||||
var body struct {
|
||||
DisplayName *string `json:"display_name"`
|
||||
PrimaryColor *string `json:"primary_color"`
|
||||
AccentColor *string `json:"accent_color"`
|
||||
UseCustomTheme *bool `json:"use_custom_theme"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid body")
|
||||
return
|
||||
}
|
||||
|
||||
if body.DisplayName != nil {
|
||||
name := strings.TrimSpace(*body.DisplayName)
|
||||
if err := h.store.UpdateDisplayName(r.Context(), user.ID, name); err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "database error")
|
||||
return
|
||||
}
|
||||
user.DisplayName = name
|
||||
}
|
||||
|
||||
if body.PrimaryColor != nil || body.AccentColor != nil {
|
||||
primary, accent := user.PrimaryColor, user.AccentColor
|
||||
if body.PrimaryColor != nil {
|
||||
primary = strings.TrimSpace(*body.PrimaryColor)
|
||||
}
|
||||
if body.AccentColor != nil {
|
||||
accent = strings.TrimSpace(*body.AccentColor)
|
||||
}
|
||||
if !isHexColor(primary) || !isHexColor(accent) {
|
||||
response.Error(w, http.StatusBadRequest, "colors must be hex values like #4f46e5")
|
||||
return
|
||||
}
|
||||
if err := h.store.UpdateColors(r.Context(), user.ID, primary, accent); err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "database error")
|
||||
return
|
||||
}
|
||||
user.PrimaryColor, user.AccentColor = primary, accent
|
||||
}
|
||||
|
||||
if body.UseCustomTheme != nil {
|
||||
if err := h.store.SetUseCustomTheme(r.Context(), user.ID, *body.UseCustomTheme); err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "database error")
|
||||
return
|
||||
}
|
||||
user.UseCustomTheme = *body.UseCustomTheme
|
||||
}
|
||||
|
||||
response.JSON(w, http.StatusOK, user)
|
||||
}
|
||||
|
||||
// POST /api/v1/auth/change-password
|
||||
func (h *Handler) ChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||
user := UserFromContext(r.Context())
|
||||
var body struct {
|
||||
CurrentPassword string `json:"current_password"`
|
||||
NewPassword string `json:"new_password"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.CurrentPassword == "" || body.NewPassword == "" {
|
||||
response.Error(w, http.StatusBadRequest, "current_password and new_password required")
|
||||
return
|
||||
}
|
||||
// Konten, die über den Identitätsanbieter kommen, haben hier kein Passwort.
|
||||
// Der Aufruf scheitert ohnehin – '' ist kein gültiger Argon2-Hash –, aber
|
||||
// mit „current password incorrect" statt mit dem wahren Grund. Diese
|
||||
// Prüfung sagt, was los ist; die Oberfläche blendet das Formular
|
||||
// zusätzlich aus.
|
||||
if user.IsOIDC {
|
||||
response.Error(w, http.StatusBadRequest,
|
||||
"this account signs in through your identity provider — change the password there")
|
||||
return
|
||||
}
|
||||
|
||||
_, hash, err := h.store.GetByUsername(r.Context(), user.Username)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "database error")
|
||||
return
|
||||
}
|
||||
ok, err := VerifyPassword(body.CurrentPassword, hash)
|
||||
if err != nil || !ok {
|
||||
response.Error(w, http.StatusUnauthorized, "current password incorrect")
|
||||
return
|
||||
}
|
||||
newHash, err := HashPassword(body.NewPassword)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "hash error")
|
||||
return
|
||||
}
|
||||
if err := h.store.UpdatePassword(r.Context(), user.ID, newHash); err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "database error")
|
||||
return
|
||||
}
|
||||
h.auditor.Log(r.Context(), audit.Event{
|
||||
ActorID: &user.ID,
|
||||
ActorName: user.Username,
|
||||
Action: audit.ActionPasswordChange,
|
||||
IP: middleware.RealIP(r),
|
||||
})
|
||||
response.NoContent(w)
|
||||
}
|
||||
|
||||
// GET /api/v1/auth/me
|
||||
func (h *Handler) Me(w http.ResponseWriter, r *http.Request) {
|
||||
user := UserFromContext(r.Context())
|
||||
if used, err := h.usage.UsedBytes(r.Context(), user.ID); err == nil {
|
||||
user.UsedBytes = used
|
||||
}
|
||||
// Refresh CSRF token on each /me call so frontend always has a valid one.
|
||||
csrfToken, err := SetCSRFCookie(w, r)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "csrf error")
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, map[string]any{
|
||||
"user": user,
|
||||
"csrf_token": csrfToken,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"aether/pkg/response"
|
||||
)
|
||||
|
||||
type contextKey int
|
||||
|
||||
const keyUser contextKey = iota
|
||||
|
||||
// RequireAuth loads the session user and injects it into context.
|
||||
// Returns 401 if not authenticated.
|
||||
func RequireAuth(ss *SessionStore, store *Store) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := ss.GetUserID(r)
|
||||
if !ok {
|
||||
response.Error(w, http.StatusUnauthorized, "authentication required")
|
||||
return
|
||||
}
|
||||
user, err := store.GetByID(r.Context(), id)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusUnauthorized, "authentication required")
|
||||
return
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), keyUser, user)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// RequireAdmin additionally checks that the user is an admin.
|
||||
func RequireAdmin(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
user := UserFromContext(r.Context())
|
||||
if user == nil || !user.IsAdmin {
|
||||
response.Error(w, http.StatusForbidden, "admin access required")
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// CSRF validates the double-submit CSRF token for state-changing requests.
|
||||
func CSRF(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet, http.MethodHead, http.MethodOptions:
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
if !ValidateCSRF(r) {
|
||||
response.Error(w, http.StatusForbidden, "invalid CSRF token")
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// UserFromContext retrieves the authenticated user from context (may be nil).
|
||||
func UserFromContext(ctx context.Context) *User {
|
||||
u, _ := ctx.Value(keyUser).(*User)
|
||||
return u
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"display_name"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
QuotaBytes int64 `json:"quota_bytes"`
|
||||
UsedBytes int64 `json:"used_bytes"`
|
||||
// Persönliche Palette. Sie bleibt gespeichert, auch wenn UseCustomTheme
|
||||
// aus ist – so kann man jederzeit umschalten, ohne sie neu zu wählen.
|
||||
PrimaryColor string `json:"primary_color"`
|
||||
AccentColor string `json:"accent_color"`
|
||||
UseCustomTheme bool `json:"use_custom_theme"`
|
||||
// IsOIDC heißt: Dieses Konto meldet sich über den Identitätsanbieter an
|
||||
// und hat hier gar kein Passwort (`password` steht auf ''). Die Oberfläche
|
||||
// blendet die Passwortänderung dann aus – ein Formular, das nur
|
||||
// fehlschlagen kann, ist schlimmer als keines.
|
||||
//
|
||||
// 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"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// UsageQuerier is satisfied by files.Store – keeps auth ↔ files decoupled.
|
||||
type UsageQuerier interface {
|
||||
UsedBytes(ctx context.Context, userID uuid.UUID) (int64, error)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Prüft den Fall, der beim Einschalten von OIDC über eine bestehende
|
||||
// Installation entscheidet: Wird das vorhandene Konto verbunden – oder entsteht
|
||||
// daneben ein zweites, leeres?
|
||||
//
|
||||
// Läuft nur mit TEST_DSN gegen eine echte Datenbank; ohne wird übersprungen,
|
||||
// damit `go test ./...` überall grün bleibt.
|
||||
func TestGetOrCreateByOIDC_Linking(t *testing.T) {
|
||||
dsn := os.Getenv("TEST_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_DSN nicht gesetzt")
|
||||
}
|
||||
ctx := context.Background()
|
||||
pool, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("verbinden: %v", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
s := NewStore(pool)
|
||||
|
||||
// Aufräumen, auch wenn der Test scheitert.
|
||||
cleanup := func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM users WHERE username LIKE 'linkprobe%'`)
|
||||
}
|
||||
cleanup()
|
||||
defer cleanup()
|
||||
|
||||
hash, _ := HashPassword("irrelevant-aber-gueltig")
|
||||
if _, err := pool.Exec(ctx,
|
||||
`INSERT INTO users (username, display_name, password, is_admin)
|
||||
VALUES ('linkprobe', 'Link Probe', $1, true)`, hash); err != nil {
|
||||
t.Fatalf("konto anlegen: %v", err)
|
||||
}
|
||||
|
||||
t.Run("ohne Verknüpfung entsteht ein zweites Konto", func(t *testing.T) {
|
||||
u, err := s.GetOrCreateByOIDC(ctx, "sub-A", "linkprobe", "Link Probe", false)
|
||||
if err != nil {
|
||||
t.Fatalf("aufruf: %v", err)
|
||||
}
|
||||
if u.Username == "linkprobe" {
|
||||
t.Fatal("erwartet: ein neues Konto mit abgewandeltem Namen")
|
||||
}
|
||||
if u.IsAdmin {
|
||||
t.Error("ein neu angelegtes Konto darf keine Adminrechte erben")
|
||||
}
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM users WHERE oidc_sub = 'sub-A'`)
|
||||
})
|
||||
|
||||
t.Run("mit Verknüpfung bleibt das bestehende Konto", func(t *testing.T) {
|
||||
u, err := s.GetOrCreateByOIDC(ctx, "sub-B", "linkprobe", "Link Probe", true)
|
||||
if err != nil {
|
||||
t.Fatalf("aufruf: %v", err)
|
||||
}
|
||||
if u.Username != "linkprobe" {
|
||||
t.Fatalf("erwartet linkprobe, bekam %q", u.Username)
|
||||
}
|
||||
if !u.IsAdmin {
|
||||
t.Error("Adminrecht des bestehenden Kontos ging verloren")
|
||||
}
|
||||
if !u.IsOIDC {
|
||||
t.Error("Konto sollte jetzt als OIDC-Konto gelten")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("erneutes Anmelden findet dasselbe Konto", func(t *testing.T) {
|
||||
u, err := s.GetOrCreateByOIDC(ctx, "sub-B", "linkprobe", "Link Probe", true)
|
||||
if err != nil || u.Username != "linkprobe" {
|
||||
t.Fatalf("wiederholte Anmeldung: %v / %+v", err, u)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("fremde Identität übernimmt ein gebundenes Konto nicht", func(t *testing.T) {
|
||||
u, err := s.GetOrCreateByOIDC(ctx, "sub-FREMD", "linkprobe", "Angreifer", true)
|
||||
if err != nil {
|
||||
t.Fatalf("aufruf: %v", err)
|
||||
}
|
||||
if u.Username == "linkprobe" {
|
||||
t.Fatal("ein bereits gebundenes Konto wurde übernommen – das darf nie passieren")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/argon2"
|
||||
)
|
||||
|
||||
const (
|
||||
argonTime = 1
|
||||
argonMemory = 64 * 1024
|
||||
argonThreads = 4
|
||||
argonKeyLen = 32
|
||||
saltLen = 16
|
||||
)
|
||||
|
||||
func HashPassword(password string) (string, error) {
|
||||
salt := make([]byte, saltLen)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
return "", fmt.Errorf("generate salt: %w", err)
|
||||
}
|
||||
hash := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
|
||||
return fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s",
|
||||
argonMemory, argonTime, argonThreads,
|
||||
base64.RawStdEncoding.EncodeToString(salt),
|
||||
base64.RawStdEncoding.EncodeToString(hash),
|
||||
), nil
|
||||
}
|
||||
|
||||
func VerifyPassword(password, encoded string) (bool, error) {
|
||||
parts := strings.Split(encoded, "$")
|
||||
if len(parts) != 6 || parts[1] != "argon2id" {
|
||||
return false, fmt.Errorf("invalid hash format")
|
||||
}
|
||||
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("decode salt: %w", err)
|
||||
}
|
||||
expected, err := base64.RawStdEncoding.DecodeString(parts[5])
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("decode hash: %w", err)
|
||||
}
|
||||
check := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, uint32(len(expected)))
|
||||
return subtle.ConstantTimeCompare(expected, check) == 1, nil
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHashAndVerify(t *testing.T) {
|
||||
hash, err := HashPassword("correct-horse-battery-staple")
|
||||
if err != nil {
|
||||
t.Fatalf("HashPassword: %v", err)
|
||||
}
|
||||
if hash == "" {
|
||||
t.Fatal("expected non-empty hash")
|
||||
}
|
||||
|
||||
ok, err := VerifyPassword("correct-horse-battery-staple", hash)
|
||||
if err != nil {
|
||||
t.Fatalf("VerifyPassword: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Error("expected password to match")
|
||||
}
|
||||
|
||||
ok, err = VerifyPassword("wrong-password", hash)
|
||||
if err != nil {
|
||||
t.Fatalf("VerifyPassword wrong: %v", err)
|
||||
}
|
||||
if ok {
|
||||
t.Error("expected wrong password to not match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashUniqueness(t *testing.T) {
|
||||
h1, _ := HashPassword("same-password")
|
||||
h2, _ := HashPassword("same-password")
|
||||
if h1 == h2 {
|
||||
t.Error("two hashes of the same password should differ (different salts)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/gorilla/sessions"
|
||||
|
||||
"aether/pkg/middleware"
|
||||
)
|
||||
|
||||
const (
|
||||
sessionName = "aether_session"
|
||||
sessionKeyUser = "user_id"
|
||||
csrfCookieName = "aether_csrf"
|
||||
csrfHeaderName = "X-CSRF-Token"
|
||||
)
|
||||
|
||||
type SessionStore struct {
|
||||
store *sessions.CookieStore
|
||||
maxAge int
|
||||
}
|
||||
|
||||
func NewSessionStore(secret string, maxAge int) *SessionStore {
|
||||
store := sessions.NewCookieStore([]byte(secret))
|
||||
store.Options = &sessions.Options{
|
||||
Path: "/",
|
||||
MaxAge: maxAge,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
}
|
||||
return &SessionStore{store: store, maxAge: maxAge}
|
||||
}
|
||||
|
||||
func (ss *SessionStore) SetUser(w http.ResponseWriter, r *http.Request, userID uuid.UUID) error {
|
||||
sess, err := ss.store.Get(r, sessionName)
|
||||
if err != nil {
|
||||
sess, _ = ss.store.New(r, sessionName)
|
||||
}
|
||||
sess.Values[sessionKeyUser] = userID.String()
|
||||
sess.Options.Secure = middleware.IsHTTPS(r)
|
||||
return ss.store.Save(r, w, sess)
|
||||
}
|
||||
|
||||
func (ss *SessionStore) GetUserID(r *http.Request) (uuid.UUID, bool) {
|
||||
sess, err := ss.store.Get(r, sessionName)
|
||||
if err != nil {
|
||||
return uuid.Nil, false
|
||||
}
|
||||
raw, ok := sess.Values[sessionKeyUser].(string)
|
||||
if !ok {
|
||||
return uuid.Nil, false
|
||||
}
|
||||
id, err := uuid.Parse(raw)
|
||||
if err != nil {
|
||||
return uuid.Nil, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
func (ss *SessionStore) Clear(w http.ResponseWriter, r *http.Request) error {
|
||||
sess, err := ss.store.Get(r, sessionName)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
sess.Options.MaxAge = -1
|
||||
return ss.store.Save(r, w, sess)
|
||||
}
|
||||
|
||||
// SetCSRFCookie sets a CSRF token as a readable (non-httpOnly) cookie.
|
||||
func SetCSRFCookie(w http.ResponseWriter, r *http.Request) (string, error) {
|
||||
token := make([]byte, 32)
|
||||
if _, err := rand.Read(token); err != nil {
|
||||
return "", err
|
||||
}
|
||||
encoded := base64.RawURLEncoding.EncodeToString(token)
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: csrfCookieName,
|
||||
Value: encoded,
|
||||
Path: "/",
|
||||
HttpOnly: false, // must be readable by JS
|
||||
Secure: middleware.IsHTTPS(r),
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
return encoded, nil
|
||||
}
|
||||
|
||||
// ValidateCSRF prüft, dass das mitgeschickte Token zum CSRF-Cookie passt
|
||||
// (Double-Submit).
|
||||
//
|
||||
// Neben dem Header wird auch ein Formularfeld akzeptiert. Grund: Ein Download
|
||||
// muss aus einer **Navigation** kommen, damit der Browser ihn als Datei
|
||||
// behandelt statt ihn in den Speicher zu laden – und ein abgeschicktes
|
||||
// Formular kann keine Header setzen. Am Schutz ändert das nichts: Verglichen
|
||||
// wird weiterhin gegen das Cookie, an das eine fremde Seite nicht herankommt.
|
||||
func ValidateCSRF(r *http.Request) bool {
|
||||
cookie, err := r.Cookie(csrfCookieName)
|
||||
if err != nil || cookie.Value == "" {
|
||||
return false
|
||||
}
|
||||
if token := r.Header.Get(csrfHeaderName); token != "" {
|
||||
return token == cookie.Value
|
||||
}
|
||||
// Nur bei Formularen nachsehen: Bei anderen Inhaltstypen würde ParseForm
|
||||
// den Rumpf lesen, den der eigentliche Handler danach noch braucht.
|
||||
ct := r.Header.Get("Content-Type")
|
||||
if strings.HasPrefix(ct, "application/x-www-form-urlencoded") {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
return false
|
||||
}
|
||||
token := r.PostFormValue("csrf_token")
|
||||
return token != "" && token == cookie.Value
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
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
|
||||
}())
|
||||
}
|
||||
Reference in new issue
Block a user