Files
Aether/backend/internal/auth/apppassword.go
T
HoneyandClaude Opus 5 31a027c93a 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>
2026-08-24 09:25:36 +02:00

265 lines
8.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}