fix(auth): longer IDs and invite codes, register limits, single-use invites (FLAW-32, FLAW-39, FLAW-40)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m2s

- NewID uses 96 random bits; invite codes get their own 100 bits in Base32.
- Registration and forgot-password are limited per address via loginGuard.
- The invite is claimed atomically before the account is created and
  released again when the account cannot be created.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-29 19:28:09 +01:00
1 parent 1af3a6e90a
commit 3709282a4f
11 files changed
+220 -15

No files matched your search

+35 -3
View File
@@ -1,6 +1,8 @@
package storage
import (
"crypto/rand"
"encoding/base32"
"errors"
"fmt"
"strings"
@@ -332,7 +334,7 @@ func (s *Store) CreateUserInvite(createdBy, email, role, orgID string, validDays
if validDays <= 0 || validDays > 365 {
validDays = 7
}
code := "INV-" + strings.ToUpper(domain.NewID("c")[2:])
code := newInviteCode()
now := time.Now().UTC()
inv := &domain.UserInvite{
ID: domain.NewID("inv"),
@@ -377,6 +379,15 @@ func (s *Store) GetUserInvites() ([]domain.UserInvite, error) {
return invites, nil
}
// newInviteCode returns INV- plus 100 random bits in Base32, grouped for typing:
// INV-ABCDE-FGHIJ-KLMNO-PQRST (FLAW-32).
func newInviteCode() string {
b := make([]byte, 13)
_, _ = rand.Read(b)
raw := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b)[:20]
return "INV-" + raw[0:5] + "-" + raw[5:10] + "-" + raw[10:15] + "-" + raw[15:20]
}
func (s *Store) GetUserInviteByCode(code string) (*domain.UserInvite, error) {
code = strings.ToUpper(strings.TrimSpace(code))
var inv domain.UserInvite
@@ -397,11 +408,32 @@ func (s *Store) GetUserInviteByCode(code string) (*domain.UserInvite, error) {
return &inv, nil
}
func (s *Store) MarkUserInviteUsed(inviteID, usedByUserID string) error {
_, err := s.db.Exec(`UPDATE user_invites SET used_by = ? WHERE id = ?`, usedByUserID, inviteID)
// ClaimUserInvite reserves an unused invite before the account is created, so two registrations with
// the same code cannot both succeed (FLAW-40). It returns the claim marker for FinishUserInvite or
// ReleaseUserInvite.
func (s *Store) ClaimUserInvite(inviteID string) (string, error) {
claim := domain.NewID("claim")
res, err := s.db.Exec(`UPDATE user_invites SET used_by = ? WHERE id = ? AND used_by = ''`, claim, inviteID)
if err != nil {
return "", err
}
if n, _ := res.RowsAffected(); n != 1 {
return "", errors.New("Dieser Einladungscode wurde bereits eingelöst")
}
return claim, nil
}
// FinishUserInvite records the new account on a claimed invite.
func (s *Store) FinishUserInvite(inviteID, claim, userID string) error {
_, err := s.db.Exec(`UPDATE user_invites SET used_by = ? WHERE id = ? AND used_by = ?`, userID, inviteID, claim)
return err
}
// ReleaseUserInvite frees a claimed invite again when the account could not be created.
func (s *Store) ReleaseUserInvite(inviteID, claim string) {
_, _ = s.db.Exec(`UPDATE user_invites SET used_by = '' WHERE id = ? AND used_by = ?`, inviteID, claim)
}
func (s *Store) DeleteUserInvite(inviteID string) error {
_, err := s.db.Exec(`DELETE FROM user_invites WHERE id = ?`, inviteID)
return err