diff --git a/.env.example b/.env.example index c6f8a575f1..a346ec302b 100644 --- a/.env.example +++ b/.env.example @@ -63,3 +63,18 @@ NEXT_PUBLIC_WS_URL= # Remote API (optional) — set to proxy local frontend to a remote backend # Leave empty to use local backend (localhost:8080) # REMOTE_API_URL=https://multica-api.copilothub.ai + +# ==================== Self-hosting: Control Signups (fixes #930) ==================== +# Set to "false" to completely disable new user signups (recommended for private instances) +ALLOW_SIGNUP=true +# Must match ALLOW_SIGNUP for the UI to reflect the same signup setting. +# Note: in typical Next.js builds, NEXT_PUBLIC_* values are baked into the client bundle, +# so changing this usually requires rebuilding/redeploying the frontend (not just restarting the backend). +NEXT_PUBLIC_ALLOW_SIGNUP=true + +# Optional: Only allow emails from these domains (comma-separated) +ALLOWED_EMAIL_DOMAINS= + +# Optional: Only allow these exact email addresses (comma-separated) +ALLOWED_EMAILS= + diff --git a/apps/web/features/landing/i18n/en.ts b/apps/web/features/landing/i18n/en.ts index 809c2fb69e..aa43c1d90a 100644 --- a/apps/web/features/landing/i18n/en.ts +++ b/apps/web/features/landing/i18n/en.ts @@ -1,6 +1,8 @@ import { githubUrl } from "../components/shared"; import type { LandingDict } from "./types"; +export const ALLOW_SIGNUP = process.env.NEXT_PUBLIC_ALLOW_SIGNUP !== "false"; + export const en: LandingDict = { header: { github: "GitHub", @@ -120,9 +122,10 @@ export const en: LandingDict = { headlineFaded: "in the next hour.", steps: [ { - title: "Sign up & create your workspace", - description: - "Enter your email, verify with a code, and you\u2019re in. Your workspace is created automatically \u2014 no setup wizard, no configuration forms.", + title: ALLOW_SIGNUP ? "Sign up & create your workspace" : "Login to your workspace", + description: ALLOW_SIGNUP + ? "Enter your email, verify with a code, and you\u2019re in. Your workspace is created automatically \u2014 no setup wizard, no configuration forms." + : "Enter your email, verify with a code, and you\u2019re logged into your workspace \u2014 no setup wizard, no configuration forms.", }, { title: "Install the CLI & connect your machine", diff --git a/apps/web/features/landing/i18n/zh.ts b/apps/web/features/landing/i18n/zh.ts index 0ae5bd64a2..f0d91872ed 100644 --- a/apps/web/features/landing/i18n/zh.ts +++ b/apps/web/features/landing/i18n/zh.ts @@ -1,6 +1,8 @@ import { githubUrl } from "../components/shared"; import type { LandingDict } from "./types"; +export const ALLOW_SIGNUP = process.env.NEXT_PUBLIC_ALLOW_SIGNUP !== "false"; + export const zh: LandingDict = { header: { github: "GitHub", @@ -120,9 +122,10 @@ export const zh: LandingDict = { headlineFaded: "\u53ea\u9700\u4e00\u5c0f\u65f6\u3002", steps: [ { - title: "\u6ce8\u518c\u5e76\u521b\u5efa\u5de5\u4f5c\u533a", - description: - "\u8f93\u5165\u90ae\u7bb1\uff0c\u9a8c\u8bc1\u7801\u786e\u8ba4\uff0c\u5373\u53ef\u8fdb\u5165\u3002\u5de5\u4f5c\u533a\u81ea\u52a8\u521b\u5efa\u2014\u2014\u65e0\u9700\u8bbe\u7f6e\u5411\u5bfc\uff0c\u65e0\u9700\u914d\u7f6e\u8868\u5355\u3002", + title: ALLOW_SIGNUP ? "注册并创建您的工作空间" : "登录到您的工作空间", + description: ALLOW_SIGNUP + ? "输入您的邮箱,验证代码后即可使用。工作空间会自动创建——无需设置向导或配置表单。" + : "输入您的邮箱,验证代码后即可登录到您的工作空间——无需设置向导或配置表单。", }, { title: "\u5b89\u88c5 CLI \u5e76\u8fde\u63a5\u4f60\u7684\u673a\u5668", diff --git a/server/cmd/server/router.go b/server/cmd/server/router.go index 705002a1c8..d6fa593230 100644 --- a/server/cmd/server/router.go +++ b/server/cmd/server/router.go @@ -70,7 +70,13 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route } cfSigner := auth.NewCloudFrontSignerFromEnv() - h := handler.New(queries, pool, hub, bus, emailSvc, store, cfSigner) + + signupConfig := handler.Config{ + AllowSignup: os.Getenv("ALLOW_SIGNUP") != "false", + AllowedEmails: splitAndTrim(os.Getenv("ALLOWED_EMAILS")), + AllowedEmailDomains: splitAndTrim(os.Getenv("ALLOWED_EMAIL_DOMAINS")), + } + h := handler.New(queries, pool, hub, bus, emailSvc, store, cfSigner, signupConfig) r := chi.NewRouter() @@ -414,3 +420,18 @@ func parseUUID(s string) pgtype.UUID { } return u } + +func splitAndTrim(s string) []string { + if s == "" { + return nil + } + parts := strings.Split(s, ",") + res := make([]string, 0, len(parts)) + for _, p := range parts { + trimmed := strings.TrimSpace(p) + if trimmed != "" { + res = append(res, trimmed) + } + } + return res +} diff --git a/server/internal/handler/auth.go b/server/internal/handler/auth.go index caa4217723..5b7ce07fd8 100644 --- a/server/internal/handler/auth.go +++ b/server/internal/handler/auth.go @@ -2,6 +2,7 @@ package handler import ( "context" + "errors" "crypto/rand" "crypto/subtle" "encoding/binary" @@ -22,6 +23,18 @@ import ( db "github.com/multica-ai/multica/server/pkg/db/generated" ) +// SignupError represents signup restriction errors +type SignupError struct { + Message string +} + +func (e SignupError) Error() string { + return e.Message +} + +var ErrSignupProhibited = SignupError{Message: "user registration is disabled on this self-hosted instance"} +var ErrEmailNotAllowed = SignupError{Message: "email address or domain not allowed on this instance"} + type UserResponse struct { ID string `json:"id"` Name string `json:"name"` @@ -78,23 +91,70 @@ func (h *Handler) issueJWT(user db.User) (string, error) { func (h *Handler) findOrCreateUser(ctx context.Context, email string) (db.User, error) { user, err := h.Queries.GetUserByEmail(ctx, email) - if err != nil { - if !isNotFound(err) { - return db.User{}, err - } - name := email - if at := strings.Index(email, "@"); at > 0 { - name = email[:at] - } - user, err = h.Queries.CreateUser(ctx, db.CreateUserParams{ - Name: name, - Email: email, - }) - if err != nil { - return db.User{}, err + isNewUser := isNotFound(err) + if err != nil && !isNewUser { + return db.User{}, err + } + + if err := h.checkSignupAllowed(email, isNewUser); err != nil { + return db.User{}, err + } + + if !isNewUser { + return user, nil + } + + name := email + if at := strings.Index(email, "@"); at > 0 { + name = email[:at] + } + return h.Queries.CreateUser(ctx, db.CreateUserParams{ + Name: name, + Email: email, + }) +} + +func (h *Handler) checkSignupAllowed(email string, isNewUser bool) error { + if !isNewUser { + return nil // existing users always allowed to log in + } + + email = strings.ToLower(email) + domain := "" + if at := strings.Index(email, "@"); at > 0 { + domain = email[at+1:] + } + + // 1. explicit email whitelist always wins + if len(h.cfg.AllowedEmails) > 0 && contains(h.cfg.AllowedEmails, email) { + return nil + } + + // 2. domain whitelist always wins + if len(h.cfg.AllowedEmailDomains) > 0 && contains(h.cfg.AllowedEmailDomains, domain) { + return nil + } + + // 3. general signup flag + if !h.cfg.AllowSignup { + return ErrSignupProhibited + } + + // 4. if allowlists are set but didn't match, block + if len(h.cfg.AllowedEmailDomains) > 0 || len(h.cfg.AllowedEmails) > 0 { + return ErrSignupProhibited + } + + return nil +} + +func contains(slice []string, s string) bool { + for _, item := range slice { + if strings.EqualFold(item, s) { + return true } } - return user, nil + return false } func (h *Handler) SendCode(w http.ResponseWriter, r *http.Request) { @@ -110,6 +170,40 @@ func (h *Handler) SendCode(w http.ResponseWriter, r *http.Request) { return } + // Check signup restrictions before sending magic link + _, err := h.Queries.GetUserByEmail(r.Context(), email) + if err != nil { + if !isNotFound(err) { + // Real database/query error → return 500 + writeError(w, http.StatusInternalServerError, "failed to lookup user") + return + } + // User does not exist → treat as new user + isNewUser := true + if err := h.checkSignupAllowed(email, isNewUser); err != nil { + var signupErr SignupError + if errors.As(err, &signupErr) { + writeError(w, http.StatusForbidden, signupErr.Error()) + } else { + writeError(w, http.StatusForbidden, "user registration is disabled") + } + return + } + } else { + // User already exists → always allowed to login + isNewUser := false + if err := h.checkSignupAllowed(email, isNewUser); err != nil { + // This should rarely happen, but handle it anyway + var signupErr SignupError + if errors.As(err, &signupErr) { + writeError(w, http.StatusForbidden, signupErr.Error()) + } else { + writeError(w, http.StatusForbidden, "user registration is disabled") + } + 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 +274,11 @@ func (h *Handler) VerifyCode(w http.ResponseWriter, r *http.Request) { user, err := h.findOrCreateUser(r.Context(), email) if err != nil { + var signupErr SignupError + if errors.As(err, &signupErr) { + writeError(w, http.StatusForbidden, signupErr.Error()) + return + } writeError(w, http.StatusInternalServerError, "failed to create user") return } @@ -336,6 +435,11 @@ func (h *Handler) GoogleLogin(w http.ResponseWriter, r *http.Request) { user, err := h.findOrCreateUser(r.Context(), email) if err != nil { + var signupErr SignupError + if errors.As(err, &signupErr) { + writeError(w, http.StatusForbidden, signupErr.Error()) + return + } writeError(w, http.StatusInternalServerError, "failed to create user") return } diff --git a/server/internal/handler/auth_signup_test.go b/server/internal/handler/auth_signup_test.go new file mode 100644 index 0000000000..cc3e14554b --- /dev/null +++ b/server/internal/handler/auth_signup_test.go @@ -0,0 +1,108 @@ +package handler + +import ( + "context" + "strings" + "testing" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + db "github.com/multica-ai/multica/server/pkg/db/generated" +) + +func newTestHandler(cfg Config) *Handler { + return &Handler{ + cfg: cfg, + } +} + +func TestSignupGating(t *testing.T) { + tests := []struct { + name string + cfg Config + email string + isNew bool + wantErr bool + }{ + {"allow_signup_true_new", Config{AllowSignup: true}, "a@x.com", true, false}, + {"allow_signup_false_new", Config{AllowSignup: false}, "a@x.com", true, true}, + {"allow_signup_false_existing", Config{AllowSignup: false}, "a@x.com", false, false}, + {"domain_allowlist_match", Config{AllowSignup: false, AllowedEmailDomains: []string{"company.com"}}, "user@company.com", true, false}, + {"domain_allowlist_mismatch", Config{AllowSignup: false, AllowedEmailDomains: []string{"company.com"}}, "user@other.com", true, true}, + {"email_allowlist_match", Config{AllowSignup: false, AllowedEmails: []string{"boss@x.com"}}, "boss@x.com", true, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + h := newTestHandler(tt.cfg) + err := h.checkSignupAllowed(tt.email, tt.isNew) + if (err != nil) != tt.wantErr { + t.Fatalf("got err=%v wantErr=%v", err, tt.wantErr) + } + }) + } +} + +type mockDB struct { + db.DBTX + getUserErr error +} + +func (m *mockDB) QueryRow(ctx context.Context, sql string, args ...interface{}) pgx.Row { + return &mockRow{err: m.getUserErr} +} + +func (m *mockDB) Exec(ctx context.Context, sql string, args ...interface{}) (pgconn.CommandTag, error) { + return pgconn.NewCommandTag("INSERT 1"), nil +} + +type mockRow struct { + pgx.Row + err error +} + +func (m *mockRow) Scan(dest ...interface{}) error { + return m.err +} + +func TestFindOrCreateUserGating(t *testing.T) { + t.Run("new_user_blocked", func(t *testing.T) { + cfg := Config{AllowSignup: false} + h := newTestHandler(cfg) + h.Queries = db.New(&mockDB{getUserErr: pgx.ErrNoRows}) + + _, err := h.findOrCreateUser(context.Background(), "new@blocked.com") + if err == nil { + t.Fatal("expected error for new user when signup disabled") + } + if !strings.Contains(err.Error(), "registration is disabled") { + t.Fatalf("expected registration disabled error, got %v", err) + } + }) + + t.Run("existing_user_allowed", func(t *testing.T) { + cfg := Config{AllowSignup: false} + h := newTestHandler(cfg) + // mockDB returns nil error for Scan, simulating user found + h.Queries = db.New(&mockDB{getUserErr: nil}) + + _, err := h.findOrCreateUser(context.Background(), "existing@test.com") + if err != nil { + t.Fatalf("expected no error for existing user, got %v", err) + } + }) + + t.Run("whitelisted_user_allowed", func(t *testing.T) { + cfg := Config{AllowSignup: false, AllowedEmails: []string{"whitelisted@test.com"}} + h := newTestHandler(cfg) + h.Queries = db.New(&mockDB{getUserErr: pgx.ErrNoRows}) + + // This will pass checkSignupAllowed and move to CreateUser. + // Our mockDB Exec returns success, but Queries.CreateUser might expect QueryRow for RETURNING id. + // Let's see if it works. + _, err := h.findOrCreateUser(context.Background(), "whitelisted@test.com") + if err != nil && strings.Contains(err.Error(), "registration is disabled") { + t.Fatalf("expected whitelisted user to pass signup check, but got %v", err) + } + }) +} diff --git a/server/internal/handler/handler.go b/server/internal/handler/handler.go index ece521f9a4..fc75396448 100644 --- a/server/internal/handler/handler.go +++ b/server/internal/handler/handler.go @@ -31,6 +31,12 @@ type dbExecutor interface { QueryRow(ctx context.Context, sql string, args ...any) pgx.Row } +type Config struct { + AllowSignup bool + AllowedEmails []string + AllowedEmailDomains []string +} + type Handler struct { Queries *db.Queries DB dbExecutor @@ -44,9 +50,10 @@ type Handler struct { UpdateStore *UpdateStore Storage storage.Storage CFSigner *auth.CloudFrontSigner + cfg Config } -func New(queries *db.Queries, txStarter txStarter, hub *realtime.Hub, bus *events.Bus, emailService *service.EmailService, store storage.Storage, cfSigner *auth.CloudFrontSigner) *Handler { +func New(queries *db.Queries, txStarter txStarter, hub *realtime.Hub, bus *events.Bus, emailService *service.EmailService, store storage.Storage, cfSigner *auth.CloudFrontSigner, cfg Config) *Handler { var executor dbExecutor if candidate, ok := txStarter.(dbExecutor); ok { executor = candidate @@ -66,6 +73,7 @@ func New(queries *db.Queries, txStarter txStarter, hub *realtime.Hub, bus *event UpdateStore: NewUpdateStore(), Storage: store, CFSigner: cfSigner, + cfg: cfg, } } diff --git a/server/internal/handler/handler_test.go b/server/internal/handler/handler_test.go index 278386df53..4ce77f8a92 100644 --- a/server/internal/handler/handler_test.go +++ b/server/internal/handler/handler_test.go @@ -54,7 +54,7 @@ func TestMain(m *testing.M) { go hub.Run() bus := events.New() emailSvc := service.NewEmailService() - testHandler = New(queries, pool, hub, bus, emailSvc, nil, nil) + testHandler = New(queries, pool, hub, bus, emailSvc, nil, nil, Config{AllowSignup: true}) testPool = pool testUserID, testWorkspaceID, err = setupHandlerTestFixture(ctx, pool) @@ -821,6 +821,39 @@ func TestSendCode(t *testing.T) { }) } +func TestSendCodeDbError(t *testing.T) { + // We can't easily mock the DB here without changing architecture, + // but we can simulate a DB error by closing the pool temporarily or + // using a cancelled context if the query respects it. + + // Create a handler with a "broken" queries object is hard because it's a struct. + // Instead, let's use a context that is already cancelled. + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + w := httptest.NewRecorder() + body := map[string]string{"email": "dberror-test@multica.ai"} + var buf bytes.Buffer + json.NewEncoder(&buf).Encode(body) + req := httptest.NewRequest("POST", "/auth/send-code", &buf) + req.Header.Set("Content-Type", "application/json") + req = req.WithContext(ctx) + + testHandler.SendCode(w, req) + + // If the DB query respects the cancelled context, it should return an error. + // pgx usually returns context.Canceled which is not what isNotFound checks for. + if w.Code != http.StatusInternalServerError { + t.Fatalf("SendCode (db error): expected 500, got %d: %s", w.Code, w.Body.String()) + } + + var resp map[string]string + json.NewDecoder(w.Body).Decode(&resp) + if resp["error"] != "failed to lookup user" { + t.Fatalf("SendCode (db error): expected error message 'failed to lookup user', got '%s'", resp["error"]) + } +} + func TestSendCodeRateLimit(t *testing.T) { const email = "ratelimit-test@multica.ai" t.Cleanup(func() {