diff --git a/internal/api/onboarding_handlers.go b/internal/api/onboarding_handlers.go index 07f9b46..82177d1 100644 --- a/internal/api/onboarding_handlers.go +++ b/internal/api/onboarding_handlers.go @@ -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. diff --git a/src/app/signup/page.tsx b/src/app/signup/page.tsx index 32bfa1e..8a945e4 100644 --- a/src/app/signup/page.tsx +++ b/src/app/signup/page.tsx @@ -49,7 +49,10 @@ function SignupForm() { const [error, setError] = useState(""); useEffect(() => { - if (!invite) return; + if (!invite) { + setInviteError("Registrierung nur mit gültigem Einladungslink möglich."); + return; + } checkInvite(invite).then((name) => { if (name === null) setInviteError("Ungültiger oder abgelaufener Einladungslink."); else setTenantName(name); @@ -95,8 +98,15 @@ function SignupForm() { )} - {inviteError &&

{inviteError}

} -
+ {inviteError && ( +
+

{inviteError}

+ +
+ )} + {!inviteError &&
setUsername(e.target.value)} required autoComplete="username" /> @@ -114,10 +124,12 @@ function SignupForm() { - -

- Zur Anmeldung -

+ } + {!inviteError && ( +

+ Zur Anmeldung +

+ )} );