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