Compare commits

...

1 Commits

Author SHA1 Message Date
Devv
dd56c2211a feat(auth): add self-hosted signup policy controls (MUL-861)
Introduce three environment variables to let self-hosted operators control
who can register on their instance:

- ALLOW_SIGNUP (default true) — global kill-switch for new registrations.
- ALLOWED_EMAIL_DOMAINS — comma-separated domain whitelist.
- ALLOWED_EMAILS — comma-separated explicit email whitelist.

Whitelists take precedence over ALLOW_SIGNUP=false, so operators can lock
down the instance while still admitting specific users. Existing users
are never affected — the policy only gates new account creation.

The check runs in findOrCreateUser() for both email-code and Google
OAuth paths, and an early 403 is returned from /auth/send-code to avoid
emailing codes to disallowed addresses.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-17 14:21:57 +08:00
4 changed files with 171 additions and 0 deletions

View File

@@ -52,6 +52,21 @@ LOCAL_UPLOAD_BASE_URL=http://localhost:8080
# Example: ALLOWED_ORIGINS=https://app.multica.ai,https://staging.multica.ai
ALLOWED_ORIGINS=
# Signup policy (self-hosted instances)
# Controls who may create a new account. Existing users can always sign in.
# ALLOW_SIGNUP — global switch. Set to "false" to disable all new registrations.
# Default: true.
# ALLOWED_EMAIL_DOMAINS — comma-separated email-domain whitelist. When set,
# only emails whose domain appears in the list may register (overrides
# ALLOW_SIGNUP=false for matching domains).
# ALLOWED_EMAILS — comma-separated whitelist of full email addresses. When
# set, listed emails may always register (overrides ALLOW_SIGNUP=false).
# Example: ALLOW_SIGNUP=false plus ALLOWED_EMAIL_DOMAINS=company.com locks
# signup to @company.com addresses only.
ALLOW_SIGNUP=true
ALLOWED_EMAIL_DOMAINS=
ALLOWED_EMAILS=
# Frontend
FRONTEND_PORT=3000
FRONTEND_ORIGIN=http://localhost:3000

View File

@@ -6,6 +6,7 @@ import (
"crypto/subtle"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
@@ -82,6 +83,11 @@ func (h *Handler) findOrCreateUser(ctx context.Context, email string) (db.User,
if !isNotFound(err) {
return db.User{}, err
}
// Only new accounts are subject to the signup policy; returning users
// always sign in successfully (see isSignupAllowed).
if !isSignupAllowed(email) {
return db.User{}, errSignupDisabled
}
name := email
if at := strings.Index(email, "@"); at > 0 {
name = email[:at]
@@ -97,6 +103,9 @@ func (h *Handler) findOrCreateUser(ctx context.Context, email string) (db.User,
return user, nil
}
// signupDisabledMessage is the user-facing reason for a blocked registration.
const signupDisabledMessage = "registration is disabled for this email on this instance"
func (h *Handler) SendCode(w http.ResponseWriter, r *http.Request) {
var req SendCodeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -110,6 +119,16 @@ func (h *Handler) SendCode(w http.ResponseWriter, r *http.Request) {
return
}
// Early-reject registrations that are blocked by the self-hosted signup
// policy, but only when no account exists yet — existing users must still
// be able to request codes to sign in.
if _, err := h.Queries.GetUserByEmail(r.Context(), email); err != nil {
if isNotFound(err) && !isSignupAllowed(email) {
writeError(w, http.StatusForbidden, signupDisabledMessage)
return
}
}
// Rate limit: max 1 code per 60 seconds per email
latest, err := h.Queries.GetLatestCodeByEmail(r.Context(), email)
if err == nil && time.Since(latest.CreatedAt.Time) < 60*time.Second {
@@ -180,6 +199,10 @@ func (h *Handler) VerifyCode(w http.ResponseWriter, r *http.Request) {
user, err := h.findOrCreateUser(r.Context(), email)
if err != nil {
if errors.Is(err, errSignupDisabled) {
writeError(w, http.StatusForbidden, signupDisabledMessage)
return
}
writeError(w, http.StatusInternalServerError, "failed to create user")
return
}
@@ -336,6 +359,10 @@ func (h *Handler) GoogleLogin(w http.ResponseWriter, r *http.Request) {
user, err := h.findOrCreateUser(r.Context(), email)
if err != nil {
if errors.Is(err, errSignupDisabled) {
writeError(w, http.StatusForbidden, signupDisabledMessage)
return
}
writeError(w, http.StatusInternalServerError, "failed to create user")
return
}

View File

@@ -0,0 +1,90 @@
package handler
import (
"errors"
"os"
"strings"
)
// errSignupDisabled is returned when a new user would be created but the
// self-hosted instance has been configured to block their registration.
var errSignupDisabled = errors.New("signup disabled")
// isSignupAllowed reports whether a new account may be created for email.
//
// Policy (see .env.example):
// - ALLOWED_EMAILS (comma-separated) — explicit email whitelist. Matches
// always allow signup, regardless of other settings.
// - ALLOWED_EMAIL_DOMAINS (comma-separated) — domain whitelist. Matches
// always allow signup, regardless of ALLOW_SIGNUP.
// - ALLOW_SIGNUP (bool, default true) — global switch. When false and
// neither whitelist matches, signup is denied.
//
// Existing users (returning sign-ins) are never affected: this helper only
// gates the creation of brand-new accounts.
func isSignupAllowed(email string) bool {
email = strings.ToLower(strings.TrimSpace(email))
if email == "" {
return false
}
if emailInList(email, os.Getenv("ALLOWED_EMAILS")) {
return true
}
domain := ""
if at := strings.LastIndex(email, "@"); at >= 0 && at < len(email)-1 {
domain = email[at+1:]
}
if domain != "" && emailInList(domain, os.Getenv("ALLOWED_EMAIL_DOMAINS")) {
return true
}
// If either whitelist is configured and we got here, no rule matched —
// treat the configured whitelists as exhaustive and deny.
if hasNonEmptyEntries(os.Getenv("ALLOWED_EMAILS")) ||
hasNonEmptyEntries(os.Getenv("ALLOWED_EMAIL_DOMAINS")) {
return false
}
return parseBoolEnv("ALLOW_SIGNUP", true)
}
// emailInList reports whether needle appears (case-insensitively, trimmed)
// in a comma-separated list.
func emailInList(needle, list string) bool {
needle = strings.ToLower(strings.TrimSpace(needle))
if needle == "" {
return false
}
for _, raw := range strings.Split(list, ",") {
entry := strings.ToLower(strings.TrimSpace(raw))
if entry != "" && entry == needle {
return true
}
}
return false
}
func hasNonEmptyEntries(list string) bool {
for _, raw := range strings.Split(list, ",") {
if strings.TrimSpace(raw) != "" {
return true
}
}
return false
}
func parseBoolEnv(key string, def bool) bool {
v := strings.ToLower(strings.TrimSpace(os.Getenv(key)))
switch v {
case "":
return def
case "1", "true", "yes", "on":
return true
case "0", "false", "no", "off":
return false
default:
return def
}
}

View File

@@ -0,0 +1,39 @@
package handler
import "testing"
func TestIsSignupAllowed(t *testing.T) {
tests := []struct {
name string
allowSignup string
allowedEmails string
allowedDomain string
email string
want bool
}{
{"default allows signup", "", "", "", "alice@example.com", true},
{"ALLOW_SIGNUP=false blocks everyone", "false", "", "", "alice@example.com", false},
{"ALLOW_SIGNUP=0 blocks everyone", "0", "", "", "alice@example.com", false},
{"domain whitelist allows matching", "false", "", "company.com,example.com", "bob@company.com", true},
{"domain whitelist blocks non-matching", "false", "", "company.com", "mallory@evil.com", false},
{"domain whitelist (signup enabled) still blocks non-match", "true", "", "company.com", "mallory@evil.com", false},
{"email whitelist allows listed", "false", "bob@company.com, carol@x.io", "", "carol@x.io", true},
{"email whitelist is case-insensitive", "false", "Bob@Company.com", "", "bob@company.com", true},
{"email whitelist blocks unlisted", "false", "bob@company.com", "", "alice@company.com", false},
{"domain + email whitelists both apply", "false", "vip@other.com", "company.com", "vip@other.com", true},
{"empty email never allowed", "true", "", "", "", false},
{"malformed ALLOW_SIGNUP falls back to default", "maybe", "", "", "alice@example.com", true},
{"whitespace tolerant in lists", "false", "", " company.com , example.com ", "a@example.com", true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("ALLOW_SIGNUP", tc.allowSignup)
t.Setenv("ALLOWED_EMAILS", tc.allowedEmails)
t.Setenv("ALLOWED_EMAIL_DOMAINS", tc.allowedDomain)
if got := isSignupAllowed(tc.email); got != tc.want {
t.Errorf("isSignupAllowed(%q) = %v, want %v", tc.email, got, tc.want)
}
})
}
}