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>
267 lines
8.0 KiB
Go
267 lines
8.0 KiB
Go
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,
|
||
})
|
||
}
|