Files
Aether/backend/internal/auth/handler.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

267 lines
8.0 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 (
"encoding/json"
"net/http"
"strings"
"aether/internal/audit"
"aether/pkg/middleware"
"aether/pkg/response"
)
type Handler struct {
store *Store
sessions *SessionStore
usage UsageQuerier
auditor *audit.Logger
}
func NewHandler(store *Store, sessions *SessionStore, usage UsageQuerier, auditor *audit.Logger) *Handler {
return &Handler{store: store, sessions: sessions, usage: usage, auditor: auditor}
}
type loginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
// POST /api/v1/auth/login
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
var req loginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Username == "" || req.Password == "" {
response.Error(w, http.StatusBadRequest, "username and password required")
return
}
user, hash, err := h.store.GetByUsername(r.Context(), req.Username)
if err != nil {
// constant-time: always hash even if user not found to prevent timing attacks
_, _ = VerifyPassword(req.Password, "$argon2id$v=19$m=65536,t=1,p=4$AAAAAAAAAAAAAAAAAAAAAA$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")
h.auditor.Log(r.Context(), audit.Event{
ActorName: req.Username,
Action: audit.ActionLoginFailed,
IP: middleware.RealIP(r),
Detail: "unknown account",
})
response.Error(w, http.StatusUnauthorized, "invalid credentials")
return
}
ok, err := VerifyPassword(req.Password, hash)
if err != nil || !ok {
h.auditor.Log(r.Context(), audit.Event{
ActorID: &user.ID,
ActorName: user.Username,
Action: audit.ActionLoginFailed,
IP: middleware.RealIP(r),
Detail: "wrong password",
})
response.Error(w, http.StatusUnauthorized, "invalid credentials")
return
}
if err := h.sessions.SetUser(w, r, user.ID); err != nil {
response.Error(w, http.StatusInternalServerError, "session error")
return
}
csrfToken, err := SetCSRFCookie(w, r)
if err != nil {
response.Error(w, http.StatusInternalServerError, "csrf error")
return
}
h.auditor.Log(r.Context(), audit.Event{
ActorID: &user.ID,
ActorName: user.Username,
Action: audit.ActionLogin,
IP: middleware.RealIP(r),
})
// Denselben Wert wie /me mitgeben: Ohne ihn stand der Speicherbalken
// direkt nach der Anmeldung auf 0 B und sprang erst beim nächsten
// Nutzer-Abgleich auf den echten Stand.
if used, err := h.usage.UsedBytes(r.Context(), user.ID); err == nil {
user.UsedBytes = used
}
response.JSON(w, http.StatusOK, map[string]any{
"user": user,
"csrf_token": csrfToken,
})
}
// POST /api/v1/auth/logout
func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
// Die Route liegt bewusst nicht hinter RequireAuth – abmelden soll auch
// mit abgelaufener Sitzung klappen und nie mit 401 scheitern. Dadurch
// steht aber kein Nutzer im Kontext, und der Eintrag im Protokoll blieb
// bisher aus. Also hier selbst aus der Sitzung nachschlagen.
if userID, ok := h.sessions.GetUserID(r); ok {
name := ""
if u, err := h.store.GetByID(r.Context(), userID); err == nil {
name = u.Username
}
h.auditor.Log(r.Context(), audit.Event{
ActorID: &userID,
ActorName: name,
Action: audit.ActionLogout,
IP: middleware.RealIP(r),
})
}
_ = h.sessions.Clear(w, r)
// clear CSRF cookie
http.SetCookie(w, &http.Cookie{
Name: csrfCookieName,
Value: "",
Path: "/",
MaxAge: -1,
})
response.NoContent(w)
}
// PATCH /api/v1/auth/profile – update own display_name
// isHexColor lässt leere Werte zu – leer bedeutet "globale Farbe verwenden".
func isHexColor(v string) bool {
if v == "" {
return true
}
if len(v) != 7 || v[0] != '#' {
return false
}
for _, c := range v[1:] {
if !strings.ContainsRune("0123456789abcdefABCDEF", c) {
return false
}
}
return true
}
func (h *Handler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
user := UserFromContext(r.Context())
// Zeiger, damit sich "Feld nicht mitgeschickt" von "auf leer setzen"
// unterscheiden lässt: Der Profilname und die Farben werden getrennt
// gespeichert, sollen sich aber nicht gegenseitig überschreiben.
var body struct {
DisplayName *string `json:"display_name"`
PrimaryColor *string `json:"primary_color"`
AccentColor *string `json:"accent_color"`
UseCustomTheme *bool `json:"use_custom_theme"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
response.Error(w, http.StatusBadRequest, "invalid body")
return
}
if body.DisplayName != nil {
name := strings.TrimSpace(*body.DisplayName)
if err := h.store.UpdateDisplayName(r.Context(), user.ID, name); err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
user.DisplayName = name
}
if body.PrimaryColor != nil || body.AccentColor != nil {
primary, accent := user.PrimaryColor, user.AccentColor
if body.PrimaryColor != nil {
primary = strings.TrimSpace(*body.PrimaryColor)
}
if body.AccentColor != nil {
accent = strings.TrimSpace(*body.AccentColor)
}
if !isHexColor(primary) || !isHexColor(accent) {
response.Error(w, http.StatusBadRequest, "colors must be hex values like #4f46e5")
return
}
if err := h.store.UpdateColors(r.Context(), user.ID, primary, accent); err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
user.PrimaryColor, user.AccentColor = primary, accent
}
if body.UseCustomTheme != nil {
if err := h.store.SetUseCustomTheme(r.Context(), user.ID, *body.UseCustomTheme); err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
user.UseCustomTheme = *body.UseCustomTheme
}
response.JSON(w, http.StatusOK, user)
}
// POST /api/v1/auth/change-password
func (h *Handler) ChangePassword(w http.ResponseWriter, r *http.Request) {
user := UserFromContext(r.Context())
var body struct {
CurrentPassword string `json:"current_password"`
NewPassword string `json:"new_password"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.CurrentPassword == "" || body.NewPassword == "" {
response.Error(w, http.StatusBadRequest, "current_password and new_password required")
return
}
// Konten, die über den Identitätsanbieter kommen, haben hier kein Passwort.
// Der Aufruf scheitert ohnehin – '' ist kein gültiger Argon2-Hash –, aber
// mit „current password incorrect" statt mit dem wahren Grund. Diese
// Prüfung sagt, was los ist; die Oberfläche blendet das Formular
// zusätzlich aus.
if user.IsOIDC {
response.Error(w, http.StatusBadRequest,
"this account signs in through your identity provider — change the password there")
return
}
_, hash, err := h.store.GetByUsername(r.Context(), user.Username)
if err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
ok, err := VerifyPassword(body.CurrentPassword, hash)
if err != nil || !ok {
response.Error(w, http.StatusUnauthorized, "current password incorrect")
return
}
newHash, err := HashPassword(body.NewPassword)
if err != nil {
response.Error(w, http.StatusInternalServerError, "hash error")
return
}
if err := h.store.UpdatePassword(r.Context(), user.ID, newHash); err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
h.auditor.Log(r.Context(), audit.Event{
ActorID: &user.ID,
ActorName: user.Username,
Action: audit.ActionPasswordChange,
IP: middleware.RealIP(r),
})
response.NoContent(w)
}
// GET /api/v1/auth/me
func (h *Handler) Me(w http.ResponseWriter, r *http.Request) {
user := UserFromContext(r.Context())
if used, err := h.usage.UsedBytes(r.Context(), user.ID); err == nil {
user.UsedBytes = used
}
// Refresh CSRF token on each /me call so frontend always has a valid one.
csrfToken, err := SetCSRFCookie(w, r)
if err != nil {
response.Error(w, http.StatusInternalServerError, "csrf error")
return
}
response.JSON(w, http.StatusOK, map[string]any{
"user": user,
"csrf_token": csrfToken,
})
}