Files
archivmail/internal/api/onboarding_handlers.go
T
sysopsandClaude Sonnet 5 798cb2817c fix: Crash-Robustheit + Performance in Backend und Frontend härten
Backend: recover() in allen langlebigen Goroutinen (neues internal/safego-
Paket), MIME-Multipart-Tiefenlimit gegen Stack-Overflow, IMAP-Zeilenlängen-
und FETCH-Result-Limits gegen OOM, MBOX-Buffer-Aliasing-Bug (Datenkorruption
beim Import), ungeprüfte Type Assertions abgesichert, Data Race im
API-Key-Rate-Limiter behoben, SMTP-Session-Panic führt jetzt zu 451-Retry
statt Prozessabsturz. Performance: O(n²)-String-Concat in Mailparser und
IMAP-Parser durch strings.Builder ersetzt.

Frontend: Error Boundaries für Root und Mail-Detailansicht ergänzt (gab es
vorher nicht), zahlreiche Guards gegen nil-Slices aus dem Backend-JSON die
sonst .map()/.length-Crashes/White-Screens auslösten, defekte JSON-Antworten
in api/core.ts abgefangen, zwei React-Key-Bugs bei löschbaren Listen
korrigiert.

Verifiziert auf 192.168.1.132: Build und Tests der geänderten Pakete
fehlerfrei, keine Regressionen gegenüber vorbestehendem Stand.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019j28kGcaJAhBnrYX34hGdt
2026-08-05 13:39:00 +02:00

263 lines
7.8 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 api
import (
"encoding/json"
"net/http"
"time"
"archivmail/internal/audit"
"archivmail/internal/mailer"
"archivmail/internal/safego"
"archivmail/internal/tokenstore"
"archivmail/internal/userstore"
)
// handleSignup creates an inactive account and sends a verification email.
// POST /api/auth/signup (PROJ-28)
func (s *Server) handleSignup(w http.ResponseWriter, r *http.Request) {
if !s.mailer.IsConfigured() {
writeError(w, http.StatusServiceUnavailable, "E-Mail-Versand nicht konfiguriert (smtp_out)")
return
}
var body struct {
Username string `json:"username"`
Email string `json:"email"`
Password string `json:"password"`
Invite string `json:"invite"` // required invite token
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid body")
return
}
if body.Invite == "" {
writeError(w, http.StatusBadRequest, "Registrierung nur mit gültigem Einladungslink möglich")
return
}
if body.Username == "" || body.Email == "" || body.Password == "" {
writeError(w, http.StatusBadRequest, "username, email and password required")
return
}
if len(body.Password) < 8 {
writeError(w, http.StatusBadRequest, "password must be at least 8 characters")
return
}
if s.tokenStore == nil {
writeError(w, http.StatusInternalServerError, "token store not available")
return
}
// Consume the invite token atomically BEFORE creating the user.
// Using Use (not Peek) here prevents TOCTOU: two parallel requests with the same
// token both pass Peek but only one wins the Use, ensuring one-time use.
// The token is consumed regardless of whether user creation succeeds — this also
// prevents account enumeration via "does this invite still work?" probing.
inviteTok, err := s.tokenStore.Use(r.Context(), tokenstore.TypeInvite, body.Invite)
if err != nil {
writeError(w, http.StatusBadRequest, "ungültiger oder abgelaufener Einladungslink")
return
}
if s.audlog != nil {
s.audlog.Log(audit.Entry{
EventType: "invite_used",
TenantID: inviteTok.TenantID,
IPAddress: s.remoteIP(r),
Success: true,
})
}
const signupMsg = "Wenn die E-Mail-Adresse verfügbar ist, wurde eine Bestätigungs-E-Mail gesendet."
u, err := s.users.CreateInactive(userstore.CreateUserRequest{
Username: body.Username,
Email: body.Email,
Password: body.Password,
Role: userstore.RoleUser,
TenantID: inviteTok.TenantID,
})
if err != nil {
// SEC: token already consumed — response is identical to success to prevent
// enumeration of whether the email address already existed.
email := body.Email
safego.Go(s.logger, "send already-registered mail", func() {
html := mailer.AlreadyRegisteredHTML(s.fqdn)
txt := mailer.AlreadyRegisteredText(s.fqdn)
_ = s.mailer.Send(email, "archivmail Registrierungsversuch", html, txt)
})
writeJSON(w, http.StatusOK, map[string]string{"message": signupMsg})
return
}
// Generate verification token.
uid := u.ID
token, err := s.tokenStore.Create(r.Context(), tokenstore.TypeVerify, &uid, nil, 24*time.Hour)
if err != nil {
// Invite is consumed and user exists but has no verify token.
// Log the error; admin can delete and re-invite the user.
s.logger.Error("signup: failed to create verify token", "user_id", uid, "err", err)
writeError(w, http.StatusInternalServerError, "token error")
return
}
// Send verification email.
safego.Go(s.logger, "send verify mail", func() {
html := mailer.VerifyEmailHTML(s.fqdn, token, u.Username)
txt := mailer.VerifyEmailText(s.fqdn, token, u.Username)
_ = s.mailer.Send(u.Email, "archivmail E-Mail bestätigen", html, txt)
})
if s.audlog != nil {
s.audlog.Log(audit.Entry{
EventType: "signup",
Username: u.Username,
TenantID: u.TenantID,
IPAddress: s.remoteIP(r),
Success: true,
})
}
writeJSON(w, http.StatusOK, map[string]string{"message": signupMsg})
}
// handleVerifyEmail activates an account via a verification token.
// GET /api/auth/verify?token=... (PROJ-28)
func (s *Server) handleVerifyEmail(w http.ResponseWriter, r *http.Request) {
plain := r.URL.Query().Get("token")
if plain == "" {
writeError(w, http.StatusBadRequest, "token required")
return
}
tok, err := s.tokenStore.Use(r.Context(), tokenstore.TypeVerify, plain)
if err != nil {
writeError(w, http.StatusBadRequest, "ungültiger oder abgelaufener Link")
return
}
if tok.UserID == nil {
writeError(w, http.StatusBadRequest, "invalid token")
return
}
if err := s.users.Activate(r.Context(), *tok.UserID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if s.audlog != nil {
s.audlog.Log(audit.Entry{
EventType: "email_verified",
IPAddress: s.remoteIP(r),
Success: true,
})
}
writeJSON(w, http.StatusOK, map[string]string{"message": "E-Mail-Adresse bestätigt. Du kannst dich jetzt anmelden."})
}
// handleForgotPassword sends a password-reset email.
// POST /api/auth/forgot-password (PROJ-28)
func (s *Server) handleForgotPassword(w http.ResponseWriter, r *http.Request) {
// Always respond with same message to avoid info-leakage
const msg = "Falls ein Account mit dieser E-Mail existiert, wurde ein Reset-Link gesendet."
if !s.mailer.IsConfigured() {
writeError(w, http.StatusServiceUnavailable, "E-Mail-Versand nicht konfiguriert")
return
}
var body struct {
Email string `json:"email"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Email == "" {
writeJSON(w, http.StatusOK, map[string]string{"message": msg})
return
}
u, err := s.users.GetByEmail(r.Context(), body.Email)
if err != nil || u == nil {
writeJSON(w, http.StatusOK, map[string]string{"message": msg})
return
}
// LDAP users cannot reset password here
if u.Source != "local" {
writeJSON(w, http.StatusOK, map[string]string{"message": msg})
return
}
uid := u.ID
token, err := s.tokenStore.Create(r.Context(), tokenstore.TypeReset, &uid, nil, 1*time.Hour)
if err != nil {
writeJSON(w, http.StatusOK, map[string]string{"message": msg})
return
}
safego.Go(s.logger, "send password-reset mail", func() {
html := mailer.ResetPasswordHTML(s.fqdn, token, u.Username)
txt := mailer.ResetPasswordText(s.fqdn, token, u.Username)
_ = s.mailer.Send(u.Email, "archivmail Passwort zurücksetzen", html, txt)
})
if s.audlog != nil {
s.audlog.Log(audit.Entry{
EventType: "password_reset_requested",
Username: u.Username,
TenantID: u.TenantID,
IPAddress: s.remoteIP(r),
Success: true,
})
}
writeJSON(w, http.StatusOK, map[string]string{"message": msg})
}
// handleResetPassword sets a new password via a reset token.
// POST /api/auth/reset-password (PROJ-28)
func (s *Server) handleResetPassword(w http.ResponseWriter, r *http.Request) {
var body struct {
Token string `json:"token"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid body")
return
}
if body.Token == "" || body.Password == "" {
writeError(w, http.StatusBadRequest, "token and password required")
return
}
if len(body.Password) < 8 {
writeError(w, http.StatusBadRequest, "password must be at least 8 characters")
return
}
tok, err := s.tokenStore.Use(r.Context(), tokenstore.TypeReset, body.Token)
if err != nil {
writeError(w, http.StatusBadRequest, "ungültiger oder abgelaufener Link")
return
}
if tok.UserID == nil {
writeError(w, http.StatusBadRequest, "invalid token")
return
}
if err := s.users.SetPassword(r.Context(), *tok.UserID, body.Password); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if s.audlog != nil {
s.audlog.Log(audit.Entry{
EventType: "password_reset_done",
IPAddress: s.remoteIP(r),
Success: true,
})
}
writeJSON(w, http.StatusOK, map[string]string{"message": "Passwort erfolgreich zurückgesetzt."})
}