package api import ( "encoding/json" "net/http" "time" "archivmail/internal/audit" "archivmail/internal/mailer" "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", 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. go func() { html := mailer.AlreadyRegisteredHTML(s.fqdn) txt := mailer.AlreadyRegisteredText(s.fqdn) _ = s.mailer.Send(body.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. go 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, 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 } go 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, 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."}) }