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
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:
1 parent
1af3a6e90a
commit
3709282a4f
11 files changed
+220
-15
No files matched your search
@@ -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
|
||||
|
||||
Reference in new issue
Block a user