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>
This commit is contained in:
commit
31a027c93a
225 files changed
+38042
No files matched your search
@@ -0,0 +1,266 @@
|
||||
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,
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user