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)
}