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)
|
||||
}
|
||||
Reference in new issue
Block a user