mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-04 17:18:35 +02:00
feat: add ALLOW_SIGNUP + ALLOWED_EMAIL_* for self-hosted instances (#1098)
Closes #930 - Added environment variables to control signups - Updated frontend to hide signup text when disabled - Added backend check to block new user creation via magic link - Updated .env.example
This commit is contained in:
15
.env.example
15
.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=
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
108
server/internal/handler/auth_signup_test.go
Normal file
108
server/internal/handler/auth_signup_test.go
Normal file
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user