diff --git a/.env.example b/.env.example index c6f8a575f..a346ec302 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 9925d07b9..fcf8e42ca 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", @@ -119,9 +121,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" : "Log in 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 d174ca77a..8f367844a 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", @@ -119,9 +121,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/internal/handler/auth.go b/server/internal/handler/auth.go index caa421772..f10a253b5 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,68 @@ 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] + if err == nil { + return user, nil + } + if !isNotFound(err) { + return db.User{}, err + } + + // New user creation path. Check if signups are allowed. + if err := h.checkSignupAllowed(email); err != nil { + return db.User{}, err + } + + 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) error { + if os.Getenv("ALLOW_SIGNUP") == "false" { + return ErrSignupProhibited + } + + allowedDomainsStr := os.Getenv("ALLOWED_EMAIL_DOMAINS") + allowedEmailsStr := os.Getenv("ALLOWED_EMAILS") + + if allowedDomainsStr == "" && allowedEmailsStr == "" { + return nil + } + + allowed := false + emailLower := strings.ToLower(email) + + if allowedDomainsStr != "" { + for _, domain := range strings.Split(allowedDomainsStr, ",") { + domain = strings.TrimSpace(domain) + if domain != "" && strings.HasSuffix(emailLower, "@"+strings.ToLower(domain)) { + allowed = true + break + } } - user, err = h.Queries.CreateUser(ctx, db.CreateUserParams{ - Name: name, - Email: email, - }) - if err != nil { - return db.User{}, err + } + + if !allowed && allowedEmailsStr != "" { + for _, allowedEmail := range strings.Split(allowedEmailsStr, ",") { + allowedEmail = strings.TrimSpace(allowedEmail) + if allowedEmail != "" && strings.EqualFold(emailLower, allowedEmail) { + allowed = true + break + } } } - return user, nil + + if !allowed { + return ErrEmailNotAllowed + } + + return nil } func (h *Handler) SendCode(w http.ResponseWriter, r *http.Request) { @@ -110,6 +168,24 @@ func (h *Handler) SendCode(w http.ResponseWriter, r *http.Request) { return } + // Short-circuit: If not already a user, check if signups are allowed for this email + _, err := h.Queries.GetUserByEmail(r.Context(), email) + if err != nil { + if !isNotFound(err) { + writeError(w, http.StatusInternalServerError, "failed to look up user") + return + } + if err := h.checkSignupAllowed(email); err != nil { + msg := "user registration is disabled" + var signupErr SignupError + if errors.As(err, &signupErr) { + msg = signupErr.Message + } + writeError(w, http.StatusForbidden, msg) + 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 +256,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 +417,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 }