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>
265 lines
8.1 KiB
Go
265 lines
8.1 KiB
Go
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)
|
||
}
|