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>
This commit is contained in:
sysops
2026-05-29 18:20:07 +02:00
parent 15a5da33fd
commit f32f83ff8e
2 changed files with 64 additions and 39 deletions
+45 -32
View File
@@ -23,12 +23,16 @@ func (s *Server) handleSignup(w http.ResponseWriter, r *http.Request) {
Username string `json:"username"`
Email string `json:"email"`
Password string `json:"password"`
Invite string `json:"invite"` // optional invite token
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
@@ -38,54 +42,63 @@ func (s *Server) handleSignup(w http.ResponseWriter, r *http.Request) {
return
}
req := userstore.CreateUserRequest{
Username: body.Username,
Email: body.Email,
Password: body.Password,
Role: userstore.RoleUser,
if s.tokenStore == nil {
writeError(w, http.StatusInternalServerError, "token store not available")
return
}
// If invite token provided, attach tenant
if body.Invite != "" && s.tokenStore != nil {
tok, err := s.tokenStore.Peek(r.Context(), tokenstore.TypeInvite, body.Invite)
if err != nil {
writeError(w, http.StatusBadRequest, "ungültiger oder abgelaufener Einladungslink")
return
}
req.TenantID = tok.TenantID
// 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(req)
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: Send "already registered" email to prevent account enumeration via
// email delivery side-channel. Every signup attempt produces an outgoing email.
if s.mailer.IsConfigured() {
go func() {
html := mailer.AlreadyRegisteredHTML(s.fqdn)
txt := mailer.AlreadyRegisteredText(s.fqdn)
_ = s.mailer.Send(body.Email, "archivmail Registrierungsversuch", html, txt)
}()
}
// 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
// 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
}
// If invite token was used — consume it now
if body.Invite != "" {
_, _ = s.tokenStore.Use(r.Context(), tokenstore.TypeInvite, body.Invite)
}
// Send verification email
// Send verification email.
go func() {
html := mailer.VerifyEmailHTML(s.fqdn, token, u.Username)
txt := mailer.VerifyEmailText(s.fqdn, token, u.Username)
@@ -101,7 +114,7 @@ func (s *Server) handleSignup(w http.ResponseWriter, r *http.Request) {
})
}
writeJSON(w, http.StatusOK, map[string]string{"message": "Wenn die E-Mail-Adresse verfügbar ist, wurde eine Bestätigungs-E-Mail gesendet."})
writeJSON(w, http.StatusOK, map[string]string{"message": signupMsg})
}
// handleVerifyEmail activates an account via a verification token.