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:
@@ -23,12 +23,16 @@ func (s *Server) handleSignup(w http.ResponseWriter, r *http.Request) {
|
|||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
Password string `json:"password"`
|
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 {
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "invalid body")
|
writeError(w, http.StatusBadRequest, "invalid body")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if body.Invite == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "Registrierung nur mit gültigem Einladungslink möglich")
|
||||||
|
return
|
||||||
|
}
|
||||||
if body.Username == "" || body.Email == "" || body.Password == "" {
|
if body.Username == "" || body.Email == "" || body.Password == "" {
|
||||||
writeError(w, http.StatusBadRequest, "username, email and password required")
|
writeError(w, http.StatusBadRequest, "username, email and password required")
|
||||||
return
|
return
|
||||||
@@ -38,54 +42,63 @@ func (s *Server) handleSignup(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
req := userstore.CreateUserRequest{
|
if s.tokenStore == nil {
|
||||||
Username: body.Username,
|
writeError(w, http.StatusInternalServerError, "token store not available")
|
||||||
Email: body.Email,
|
return
|
||||||
Password: body.Password,
|
|
||||||
Role: userstore.RoleUser,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If invite token provided, attach tenant
|
// Consume the invite token atomically BEFORE creating the user.
|
||||||
if body.Invite != "" && s.tokenStore != nil {
|
// Using Use (not Peek) here prevents TOCTOU: two parallel requests with the same
|
||||||
tok, err := s.tokenStore.Peek(r.Context(), tokenstore.TypeInvite, body.Invite)
|
// 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 {
|
if err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "ungültiger oder abgelaufener Einladungslink")
|
writeError(w, http.StatusBadRequest, "ungültiger oder abgelaufener Einladungslink")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
req.TenantID = tok.TenantID
|
|
||||||
|
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."
|
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 {
|
if err != nil {
|
||||||
// SEC: Send "already registered" email to prevent account enumeration via
|
// SEC: token already consumed — response is identical to success to prevent
|
||||||
// email delivery side-channel. Every signup attempt produces an outgoing email.
|
// enumeration of whether the email address already existed.
|
||||||
if s.mailer.IsConfigured() {
|
|
||||||
go func() {
|
go func() {
|
||||||
html := mailer.AlreadyRegisteredHTML(s.fqdn)
|
html := mailer.AlreadyRegisteredHTML(s.fqdn)
|
||||||
txt := mailer.AlreadyRegisteredText(s.fqdn)
|
txt := mailer.AlreadyRegisteredText(s.fqdn)
|
||||||
_ = s.mailer.Send(body.Email, "archivmail – Registrierungsversuch", html, txt)
|
_ = s.mailer.Send(body.Email, "archivmail – Registrierungsversuch", html, txt)
|
||||||
}()
|
}()
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, map[string]string{"message": signupMsg})
|
writeJSON(w, http.StatusOK, map[string]string{"message": signupMsg})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate verification token
|
// Generate verification token.
|
||||||
uid := u.ID
|
uid := u.ID
|
||||||
token, err := s.tokenStore.Create(r.Context(), tokenstore.TypeVerify, &uid, nil, 24*time.Hour)
|
token, err := s.tokenStore.Create(r.Context(), tokenstore.TypeVerify, &uid, nil, 24*time.Hour)
|
||||||
if err != nil {
|
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")
|
writeError(w, http.StatusInternalServerError, "token error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// If invite token was used — consume it now
|
// Send verification email.
|
||||||
if body.Invite != "" {
|
|
||||||
_, _ = s.tokenStore.Use(r.Context(), tokenstore.TypeInvite, body.Invite)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send verification email
|
|
||||||
go func() {
|
go func() {
|
||||||
html := mailer.VerifyEmailHTML(s.fqdn, token, u.Username)
|
html := mailer.VerifyEmailHTML(s.fqdn, token, u.Username)
|
||||||
txt := mailer.VerifyEmailText(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.
|
// handleVerifyEmail activates an account via a verification token.
|
||||||
|
|||||||
+16
-4
@@ -49,7 +49,10 @@ function SignupForm() {
|
|||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!invite) return;
|
if (!invite) {
|
||||||
|
setInviteError("Registrierung nur mit gültigem Einladungslink möglich.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
checkInvite(invite).then((name) => {
|
checkInvite(invite).then((name) => {
|
||||||
if (name === null) setInviteError("Ungültiger oder abgelaufener Einladungslink.");
|
if (name === null) setInviteError("Ungültiger oder abgelaufener Einladungslink.");
|
||||||
else setTenantName(name);
|
else setTenantName(name);
|
||||||
@@ -95,8 +98,15 @@ function SignupForm() {
|
|||||||
)}
|
)}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{inviteError && <p className="text-sm text-destructive mb-4">{inviteError}</p>}
|
{inviteError && (
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-destructive">{inviteError}</p>
|
||||||
|
<Button variant="outline" className="w-full" onClick={() => router.push("/")}>
|
||||||
|
Zur Anmeldung
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!inviteError && <form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="username">Benutzername</Label>
|
<Label htmlFor="username">Benutzername</Label>
|
||||||
<Input id="username" value={username} onChange={(e) => setUsername(e.target.value)} required autoComplete="username" />
|
<Input id="username" value={username} onChange={(e) => setUsername(e.target.value)} required autoComplete="username" />
|
||||||
@@ -114,10 +124,12 @@ function SignupForm() {
|
|||||||
<Button type="submit" className="w-full" disabled={loading}>
|
<Button type="submit" className="w-full" disabled={loading}>
|
||||||
{loading ? "Registrierung..." : "Account erstellen"}
|
{loading ? "Registrierung..." : "Account erstellen"}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>}
|
||||||
|
{!inviteError && (
|
||||||
<p className="mt-4 text-center text-sm text-muted-foreground">
|
<p className="mt-4 text-center text-sm text-muted-foreground">
|
||||||
<a href="/" className="underline hover:text-foreground">Zur Anmeldung</a>
|
<a href="/" className="underline hover:text-foreground">Zur Anmeldung</a>
|
||||||
</p>
|
</p>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user