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