Files
archivmail/internal/api/onboarding_handlers.go
T
sysops f32f83ff8e fix(PROJ-28): Invite-Token Pflicht bei Signup — TOCTOU + Enumeration-Leak schließen
- Signup ohne Invite-Token gibt 400 zurück (war: optional)
- Use() statt Peek() vor User-Erstellung: verhindert TOCTOU bei parallelen
  Requests mit demselben Token und Enumeration via "Token noch gültig?"
- invite_used Audit-Eintrag ergänzt
- Doppeltes IsConfigured()-Check entfernt
- Frontend: ohne ?invite= im URL wird Formular nicht gerendert

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 18:20:07 +02:00

258 lines
7.6 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/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."})
}