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:
HoneyandClaude Opus 5 committed 2026-08-24 09:25:36 +02:00
commit 31a027c93a
225 files changed
+38042

No files matched your search

+264
View File
@@ -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)
}
+66
View File
@@ -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")
}
}
+266
View File
@@ -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,
})
}
+67
View File
@@ -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
}
+37
View File
@@ -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)
}
+90
View File
@@ -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")
}
})
}
+49
View File
@@ -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
}
+39
View File
@@ -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)")
}
}
+118
View File
@@ -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
}
+211
View File
@@ -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
}())
}