fix(backend): disable one-registration-per-IP by default

Shared NAT egress makes client IP unusable for uniqueness checks. Gate the
behavior behind REGISTRATION_ENFORCE_IP (default false) for public/invite signup.
This commit is contained in:
ahp
2026-10-07 23:13:22 +03:30
parent 92b1681241
commit 3cce2158e9
15 changed files with 103 additions and 53 deletions
+2 -10
View File
@@ -130,16 +130,8 @@ func (s *Server) handleRegisterInvite(w http.ResponseWriter, r *http.Request) {
return
}
ip := clientAddress(r)
if ip == "" || ip == "unknown" {
writeErr(w, http.StatusBadRequest, "آدرس شبکه شناسایی نشد")
return
}
if taken, err := s.store.HasRegistrationIP(r.Context(), ip); err != nil {
writeErr(w, http.StatusInternalServerError, "بررسی محدودیت ثبت‌نام ممکن نشد")
return
} else if taken {
writeErr(w, http.StatusConflict, "از این شبکه قبلاً ثبت‌نام انجام شده است")
ip, ok := s.registrationClientIP(w, r)
if !ok {
return
}
+1
View File
@@ -130,6 +130,7 @@ func TestInviteRevoke(t *testing.T) {
}
func TestInviteDuplicatePhoneAndIP(t *testing.T) {
t.Setenv("REGISTRATION_ENFORCE_IP", "true")
e := setup(t)
inv := createInvite(t, e, 5, "", nil)
token := inv["token"].(string)
+25 -10
View File
@@ -27,6 +27,29 @@ func (s *Server) registrationRequireWhitelist(r *http.Request) bool {
return s.settingTruthy(r, "registration_require_whitelist", true)
}
// registrationClientIP returns the IP to bind to a registration when enforcement is on.
// When REGISTRATION_ENFORCE_IP is false (default), returns "" so NAT / shared egress is ignored.
func (s *Server) registrationClientIP(w http.ResponseWriter, r *http.Request) (string, bool) {
if s.cfg == nil || !s.cfg.RegistrationEnforceIP {
return "", true
}
ip := clientAddress(r)
if ip == "" || ip == "unknown" {
writeErr(w, http.StatusBadRequest, "آدرس شبکه شناسایی نشد")
return "", false
}
taken, err := s.store.HasRegistrationIP(r.Context(), ip)
if err != nil {
writeErr(w, http.StatusInternalServerError, "بررسی محدودیت ثبت‌نام ممکن نشد")
return "", false
}
if taken {
writeErr(w, http.StatusConflict, "از این شبکه قبلاً ثبت‌نام انجام شده است")
return "", false
}
return ip, true
}
func (s *Server) handleRegisterStatus(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"enabled": s.registrationEnabled(r),
@@ -83,16 +106,8 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
return
}
ip := clientAddress(r)
if ip == "" || ip == "unknown" {
writeErr(w, http.StatusBadRequest, "آدرس شبکه شناسایی نشد")
return
}
if taken, err := s.store.HasRegistrationIP(r.Context(), ip); err != nil {
writeErr(w, http.StatusInternalServerError, "بررسی محدودیت ثبت‌نام ممکن نشد")
return
} else if taken {
writeErr(w, http.StatusConflict, "از این شبکه قبلاً ثبت‌نام انجام شده است")
ip, ok := s.registrationClientIP(w, r)
if !ok {
return
}
+21
View File
@@ -261,6 +261,7 @@ func TestRegisterDuplicatePhone(t *testing.T) {
}
func TestRegisterSameIPRejected(t *testing.T) {
t.Setenv("REGISTRATION_ENFORCE_IP", "true")
e := setup(t)
enableRegistration(t, e)
whitelistPhones(t, e, "09121110007", "09121110008")
@@ -278,7 +279,27 @@ func TestRegisterSameIPRejected(t *testing.T) {
}
}
func TestRegisterSameIPAllowedWhenEnforcementOff(t *testing.T) {
t.Setenv("REGISTRATION_ENFORCE_IP", "false")
e := setup(t)
enableRegistration(t, e)
whitelistPhones(t, e, "09121110007", "09121110008")
headers := map[string]string{"X-Forwarded-For": "203.0.113.40"}
code, body := e.doHeaders(t, "POST", "/api/auth/register", "", headers,
registerBody(t, e, "09121110007", "reg-ipoff1@test.dev", "regipoff1"))
if code != http.StatusCreated {
t.Fatalf("first: %d %v", code, body)
}
code, body = e.doHeaders(t, "POST", "/api/auth/register", "", headers,
registerBody(t, e, "09121110008", "reg-ipoff2@test.dev", "regipoff2"))
if code != http.StatusCreated {
t.Fatalf("same IP must be allowed when REGISTRATION_ENFORCE_IP=false: %d %v", code, body)
}
}
func TestRegisterUsesForwardedIP(t *testing.T) {
t.Setenv("REGISTRATION_ENFORCE_IP", "true")
e := setup(t)
enableRegistration(t, e)
whitelistPhones(t, e, "09121110009", "09121110010", "09121110011")
+4
View File
@@ -46,6 +46,9 @@ type Config struct {
PhysicalCertEnabled bool
PhysicalCertPriceIRR int
PhysicalCertWindowDays int
// RegistrationEnforceIP limits one successful registration per client IP.
// Default false: behind NAT / shared egress the peer IP is not a real client identity.
RegistrationEnforceIP bool
}
func getEnv(key, fallback string) string {
@@ -140,6 +143,7 @@ func Load() *Config {
PhysicalCertEnabled: getEnv("PHYSICAL_CERT_ENABLED", "false") == "true",
PhysicalCertPriceIRR: getEnvInt("PHYSICAL_CERT_PRICE_IRR", 2_500_000),
PhysicalCertWindowDays: getEnvInt("PHYSICAL_CERT_WINDOW_DAYS", 30),
RegistrationEnforceIP: getEnv("REGISTRATION_ENFORCE_IP", "false") == "true",
}
}
+17 -15
View File
@@ -344,16 +344,14 @@ func EvaluateInviteAvailability(inv *models.RegistrationInvite, now time.Time) e
return nil
}
// RegisterInviteStudent creates a student, consumes the IP, and atomically consumes one invite seat.
// RegisterInviteStudent creates a student, optionally records the client IP, and consumes one invite seat.
// Empty ip skips one-registration-per-IP enforcement (NAT / shared egress).
func (s *Store) RegisterInviteStudent(
ctx context.Context,
inviteID int64,
name, email, username, passwordHash, phone, securityQuestion, securityAnswerHash, ip string,
) (*models.User, error) {
ip = strings.TrimSpace(ip)
if ip == "" {
return nil, errors.New("empty registration ip")
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
@@ -366,12 +364,14 @@ func (s *Store) RegisterInviteStudent(
return nil, ErrPhoneBlacklisted
}
var taken bool
if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM registration_ips WHERE ip=$1)`, ip).Scan(&taken); err != nil {
return nil, err
}
if taken {
return nil, ErrRegistrationIPTaken
if ip != "" {
var taken bool
if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM registration_ips WHERE ip=$1)`, ip).Scan(&taken); err != nil {
return nil, err
}
if taken {
return nil, ErrRegistrationIPTaken
}
}
tag, err := tx.Exec(ctx, `
@@ -436,12 +436,14 @@ func (s *Store) RegisterInviteStudent(
ON CONFLICT (user_id) DO NOTHING`, u.ID); err != nil {
return nil, err
}
if _, err := tx.Exec(ctx, `
INSERT INTO registration_ips (ip, user_id) VALUES ($1, $2)`, ip, u.ID); err != nil {
if isUniqueViolation(err) {
return nil, ErrRegistrationIPTaken
if ip != "" {
if _, err := tx.Exec(ctx, `
INSERT INTO registration_ips (ip, user_id) VALUES ($1, $2)`, ip, u.ID); err != nil {
if isUniqueViolation(err) {
return nil, ErrRegistrationIPTaken
}
return nil, err
}
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
+17 -16
View File
@@ -331,18 +331,15 @@ func (s *Store) HasRegistrationIP(ctx context.Context, ip string) (bool, error)
return exists, err
}
// RegisterPublicStudent creates a student and consumes the IP atomically.
// RegisterPublicStudent creates a student and optionally records the client IP.
// When requireWhitelist is true, the phone must be an available whitelist entry.
// Concurrent same-IP registrations: one commits; the other rolls back on unique IP conflict.
// Empty ip skips one-registration-per-IP enforcement (NAT / shared egress).
func (s *Store) RegisterPublicStudent(
ctx context.Context,
name, email, username, passwordHash, phone, securityQuestion, securityAnswerHash, ip string,
requireWhitelist bool,
) (*models.User, error) {
ip = strings.TrimSpace(ip)
if ip == "" {
return nil, errors.New("empty registration ip")
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
@@ -355,12 +352,14 @@ func (s *Store) RegisterPublicStudent(
return nil, ErrPhoneBlacklisted
}
var taken bool
if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM registration_ips WHERE ip=$1)`, ip).Scan(&taken); err != nil {
return nil, err
}
if taken {
return nil, ErrRegistrationIPTaken
if ip != "" {
var taken bool
if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM registration_ips WHERE ip=$1)`, ip).Scan(&taken); err != nil {
return nil, err
}
if taken {
return nil, ErrRegistrationIPTaken
}
}
row := tx.QueryRow(ctx, `
@@ -380,12 +379,14 @@ func (s *Store) RegisterPublicStudent(
ON CONFLICT (user_id) DO NOTHING`, u.ID); err != nil {
return nil, err
}
if _, err := tx.Exec(ctx, `
INSERT INTO registration_ips (ip, user_id) VALUES ($1, $2)`, ip, u.ID); err != nil {
if isUniqueViolation(err) {
return nil, ErrRegistrationIPTaken
if ip != "" {
if _, err := tx.Exec(ctx, `
INSERT INTO registration_ips (ip, user_id) VALUES ($1, $2)`, ip, u.ID); err != nil {
if isUniqueViolation(err) {
return nil, ErrRegistrationIPTaken
}
return nil, err
}
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err