diff --git a/backend/api-files/openapi.yaml b/backend/api-files/openapi.yaml index 50d41a4e..dc6287ce 100644 --- a/backend/api-files/openapi.yaml +++ b/backend/api-files/openapi.yaml @@ -306,6 +306,31 @@ components: type: string format: date-time + InstanceConfig: + type: object + description: Public configuration of this instance, used by clients to hide flows the backend would reject + required: + - signup_enabled + - password_login_enabled + - billing_enabled + - auth_providers + properties: + signup_enabled: + type: boolean + description: False when DISABLE_SIGNUP is set and the instance already has users; new accounts then need a team invitation + password_login_enabled: + type: boolean + description: False when DISABLE_PASSWORD_LOGIN is set; only social providers can be used + billing_enabled: + type: boolean + description: True when Stripe billing is configured + auth_providers: + type: array + description: Social login providers that are configured on this instance + items: + type: string + enum: [google, slack, github] + securitySchemes: BearerAuth: type: http @@ -332,6 +357,18 @@ paths: "200": description: Metrics in Prometheus format + /api/config: + get: + summary: Public instance configuration + description: Unauthenticated. Tells clients which sign-up and login flows are enabled on this instance. + responses: + "200": + description: Instance configuration + content: + application/json: + schema: + $ref: "#/components/schemas/InstanceConfig" + /api/auth/social/:provider: get: summary: Initiate social login with specified provider diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 62ce0b4d..c7c4a7b2 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -33,6 +33,12 @@ type Config struct { GitHubRedirect string CallbackURL string SessionSecret string + // DisableSignup rejects new accounts that do not come with a team + // invitation. The very first account of an instance is still allowed. + DisableSignup bool + // DisablePasswordLogin turns off email/password authentication, leaving + // only the configured social providers. + DisablePasswordLogin bool } Livekit struct { APIKey string @@ -145,6 +151,12 @@ func Load() (*Config, error) { c.Auth.GitHubSecret = os.Getenv("GITHUB_SECRET") c.Auth.GitHubRedirect = fmt.Sprintf("https://%s/api/auth/social/github/callback", c.Server.DeployDomain) + c.Auth.DisableSignup = os.Getenv("DISABLE_SIGNUP") == "true" + c.Auth.DisablePasswordLogin = os.Getenv("DISABLE_PASSWORD_LOGIN") == "true" + if c.Auth.DisablePasswordLogin && !c.hasWebLoginProvider() { + return nil, fmt.Errorf("DISABLE_PASSWORD_LOGIN is set but neither Google nor GitHub login is configured, nobody could sign in") + } + c.Database.DSN = os.Getenv("DATABASE_DSN") c.Database.RedisURI = os.Getenv("REDIS_URI") @@ -221,3 +233,31 @@ func (c *Config) IsTurnstileEnabled() bool { func (c *Config) IsStripeEnabled() bool { return c.Stripe.SecretKey != "" } + +// SocialProviders returns the names of the social login providers that have +// both a key and a secret configured, in the order they are registered. +func (c *Config) SocialProviders() []string { + providers := []string{} + if c.Auth.GoogleKey != "" && c.Auth.GoogleSecret != "" { + providers = append(providers, "google") + } + if c.Auth.SlackKey != "" && c.Auth.SlackSecret != "" { + providers = append(providers, "slack") + } + if c.Auth.GitHubKey != "" && c.Auth.GitHubSecret != "" { + providers = append(providers, "github") + } + return providers +} + +// hasWebLoginProvider reports whether a social provider is configured that +// the web app offers a login button for. Slack is registered when configured, +// but its button is disabled in the web app, so it does not count. +func (c *Config) hasWebLoginProvider() bool { + for _, provider := range c.SocialProviders() { + if provider != "slack" { + return true + } + } + return false +} diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go new file mode 100644 index 00000000..0fcc22d0 --- /dev/null +++ b/backend/internal/config/config_test.go @@ -0,0 +1,34 @@ +package config + +import "testing" + +func TestLoad_DisablePasswordLoginRequiresProvider(t *testing.T) { + t.Setenv("ENV_STACK", "") + t.Setenv("SESSION_SECRET", "test-secret") + t.Setenv("DISABLE_PASSWORD_LOGIN", "true") + for _, name := range []string{"GOOGLE_KEY", "GOOGLE_SECRET", "SLACK_KEY", "SLACK_SECRET", "GITHUB_KEY", "GITHUB_SECRET"} { + t.Setenv(name, "") + } + + if _, err := Load(); err == nil { + t.Fatal("expected Load to fail when password login is disabled and no provider is configured") + } + + // Slack alone is not enough: the web app has no Slack login button. + t.Setenv("SLACK_KEY", "key") + t.Setenv("SLACK_SECRET", "secret") + if _, err := Load(); err == nil { + t.Fatal("expected Load to fail when only Slack is configured") + } + + t.Setenv("GITHUB_KEY", "key") + t.Setenv("GITHUB_SECRET", "secret") + + cfg, err := Load() + if err != nil { + t.Fatalf("expected Load to succeed with a configured provider, got: %v", err) + } + if !cfg.Auth.DisablePasswordLogin { + t.Fatal("expected DisablePasswordLogin to be set") + } +} diff --git a/backend/internal/handlers/handlers.go b/backend/internal/handlers/handlers.go index d2a423e5..44fc2547 100644 --- a/backend/internal/handlers/handlers.go +++ b/backend/internal/handlers/handlers.go @@ -138,6 +138,13 @@ func (h *AuthHandler) SocialLoginCallback(c echo.Context) error { var isAdmin = false // If no team invitation, we need to create a new team if assignedTeamID == nil { + allowed, err := h.uninvitedSignupAllowed(tx) + if err != nil { + return err + } + if !allowed { + return errSignupDisabled + } isAdmin = true // Provider-specific handling to get team name switch providerName { @@ -304,6 +311,11 @@ func (h *AuthHandler) SocialLoginCallback(c echo.Context) error { return nil }) + if errors.Is(err, errSignupDisabled) { + // This is a browser redirect from the provider, so send the user back to + // the login page instead of answering with a JSON error. + return c.Redirect(http.StatusFound, "/login?error=signup_disabled") + } if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } @@ -420,6 +432,14 @@ func (h *AuthHandler) ManualSignUp(c echo.Context) error { // user insert (e.g. duplicate email) can't leave an orphan team behind. err := h.DB.Transaction(func(tx *gorm.DB) error { if u.TeamID == nil { + allowed, err := h.uninvitedSignupAllowed(tx) + if err != nil { + return err + } + if !allowed { + return errSignupDisabled + } + teamName := strings.TrimSpace(req.TeamName) if teamName == "" { teamName = fmt.Sprintf("%s-Team", u.FirstName) @@ -436,6 +456,9 @@ func (h *AuthHandler) ManualSignUp(c echo.Context) error { // overwriting the team_id decided above. return tx.Omit(clause.Associations).Create(u).Error }) + if errors.Is(err, errSignupDisabled) { + return echo.NewHTTPError(http.StatusForbidden, signupDisabledMessage) + } if errors.Is(err, gorm.ErrDuplicatedKey) { return echo.NewHTTPError(409, "user with this email already exists") } diff --git a/backend/internal/handlers/instanceConfig.go b/backend/internal/handlers/instanceConfig.go new file mode 100644 index 00000000..6d998352 --- /dev/null +++ b/backend/internal/handlers/instanceConfig.go @@ -0,0 +1,73 @@ +package handlers + +import ( + "errors" + "net/http" + + "hopp-backend/internal/models" + + "github.com/labstack/echo/v4" + "gorm.io/gorm" +) + +const ( + signupDisabledMessage = "Sign-up is disabled on this instance. Ask a team admin for an invitation link." + passwordLoginDisabledMessage = "Email and password login is disabled on this instance." +) + +// errSignupDisabled is returned from inside the sign-up transactions when an +// account without a team invitation is rejected because of DISABLE_SIGNUP. +var errSignupDisabled = errors.New("sign-up is disabled") + +// InstanceConfigResponse is the public, unauthenticated description of how this +// instance is configured. Clients use it to hide flows the backend would reject. +type InstanceConfigResponse struct { + SignupEnabled bool `json:"signup_enabled"` + PasswordLoginEnabled bool `json:"password_login_enabled"` + BillingEnabled bool `json:"billing_enabled"` + AuthProviders []string `json:"auth_providers"` +} + +// GetInstanceConfig returns the instance configuration relevant to clients. +func (h *AuthHandler) GetInstanceConfig(c echo.Context) error { + // Reported as enabled while the instance is empty, so the first account + // can be created from the web app even with DISABLE_SIGNUP. + signupEnabled, err := h.uninvitedSignupAllowed(h.DB) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to load instance configuration") + } + + return c.JSON(http.StatusOK, InstanceConfigResponse{ + SignupEnabled: signupEnabled, + PasswordLoginEnabled: !h.Config.Auth.DisablePasswordLogin, + BillingEnabled: h.Config.IsStripeEnabled(), + AuthProviders: h.Config.SocialProviders(), + }) +} + +// RequirePasswordLogin rejects email/password endpoints when +// DISABLE_PASSWORD_LOGIN is set. +func (h *AuthHandler) RequirePasswordLogin(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + if h.Config.Auth.DisablePasswordLogin { + return echo.NewHTTPError(http.StatusForbidden, passwordLoginDisabledMessage) + } + return next(c) + } +} + +// uninvitedSignupAllowed reports whether an account may be created without a +// team invitation. With DISABLE_SIGNUP this is only the case while the instance +// has no users at all, so a fresh deployment can still be bootstrapped. +func (h *AuthHandler) uninvitedSignupAllowed(tx *gorm.DB) (bool, error) { + if !h.Config.Auth.DisableSignup { + return true, nil + } + + var count int64 + // Unscoped: a soft-deleted user still means the instance was bootstrapped. + if err := tx.Unscoped().Model(&models.User{}).Count(&count).Error; err != nil { + return false, err + } + return count == 0, nil +} diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index b130d4af..7011920b 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -330,11 +330,19 @@ func (s *Server) setupGothProviders() { // Set the session secret for Goth gothic.Store = s.Store - goth.UseProviders( - google.New(s.Config.Auth.GoogleKey, s.Config.Auth.GoogleSecret, s.Config.Auth.GoogleRedirect, "email", "profile", "openid"), - slack.New(s.Config.Auth.SlackKey, s.Config.Auth.SlackSecret, s.Config.Auth.SlackRedirect, "users:read", "users:read.email", "team:read"), - github.New(s.Config.Auth.GitHubKey, s.Config.Auth.GitHubSecret, s.Config.Auth.GitHubRedirect, "user:email", "read:user"), - ) + // goth keeps providers in a package-level registry, so start clean and + // register only the ones that are configured. + goth.ClearProviders() + for _, name := range s.Config.SocialProviders() { + switch name { + case "google": + goth.UseProviders(google.New(s.Config.Auth.GoogleKey, s.Config.Auth.GoogleSecret, s.Config.Auth.GoogleRedirect, "email", "profile", "openid")) + case "slack": + goth.UseProviders(slack.New(s.Config.Auth.SlackKey, s.Config.Auth.SlackSecret, s.Config.Auth.SlackRedirect, "users:read", "users:read.email", "team:read")) + case "github": + goth.UseProviders(github.New(s.Config.Auth.GitHubKey, s.Config.Auth.GitHubSecret, s.Config.Auth.GitHubRedirect, "user:email", "read:user")) + } + } } func (s *Server) setupEmailClient() { @@ -378,6 +386,7 @@ func (s *Server) setupRoutes() { return c.String(200, "OK") }) api.GET("/metrics", echoprometheus.NewHandler()) + api.GET("/config", auth.GetInstanceConfig) // Add invitation details endpoint api.GET("/invitation-details/:uuid", auth.GetInvitationDetails) @@ -398,10 +407,10 @@ func (s *Server) setupRoutes() { // Authentication endpoints api.GET("/auth/social/:provider", auth.SocialLogin) api.GET("/auth/social/:provider/callback", auth.SocialLoginCallback) - api.POST("/sign-up", auth.ManualSignUp) - api.POST("/sign-in", auth.ManualSignIn) - api.POST("/forgot-password", auth.ForgotPassword) - api.PATCH("/reset-password/:token", auth.ResetPassword) + api.POST("/sign-up", auth.ManualSignUp, auth.RequirePasswordLogin) + api.POST("/sign-in", auth.ManualSignIn, auth.RequirePasswordLogin) + api.POST("/forgot-password", auth.ForgotPassword, auth.RequirePasswordLogin) + api.PATCH("/reset-password/:token", auth.ResetPassword, auth.RequirePasswordLogin) // Protected API routes group protectedAPI := api.Group("/auth", s.JwtIssuer.Middleware()) diff --git a/backend/test/integration/feature_switches_test.go b/backend/test/integration/feature_switches_test.go new file mode 100644 index 00000000..26e8e099 --- /dev/null +++ b/backend/test/integration/feature_switches_test.go @@ -0,0 +1,333 @@ +//go:build integration +// +build integration + +package integration + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/google/uuid" + "github.com/labstack/gommon/log" + "github.com/markbates/goth" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "hopp-backend/internal/config" + "hopp-backend/internal/handlers" + "hopp-backend/internal/models" + "hopp-backend/internal/server" +) + +// setupTestServerWithConfig builds a test server like setupTestServerFast, but +// lets the caller adjust the config before the server is initialized. +func setupTestServerWithConfig(t *testing.T, configure func(cfg *config.Config)) (*server.Server, func()) { + cfg := &config.Config{} + cfg.Server.Port = "8080" + cfg.Server.Host = "localhost" + cfg.Server.DeployDomain = "localhost:8080" + cfg.Server.Debug = false + cfg.Database.DSN = "file::memory:?cache=shared" + cfg.Database.RedisURI = "" + cfg.Auth.SessionSecret = "test-secret-key-for-testing-only" + cfg.Resend.DefaultSender = "test@example.com" + configure(cfg) + + srv := server.New(cfg) + srv.Echo.Logger.SetLevel(log.ERROR) + + require.NoError(t, srv.Initialize()) + + cleanup := func() { + if srv.DB != nil { + if sqlDB, _ := srv.DB.DB(); sqlDB != nil { + sqlDB.Close() + } + } + } + + return srv, cleanup +} + +func setupTestServerSignupDisabled(t *testing.T) (*server.Server, func()) { + return setupTestServerWithConfig(t, func(cfg *config.Config) { + cfg.Auth.DisableSignup = true + }) +} + +func signUpPayload(email string) map[string]interface{} { + return map[string]interface{}{ + "first_name": "Jane", + "last_name": "Doe", + "email": email, + "password": "securepassword123", + } +} + +func createTestInvitation(t *testing.T, srv *server.Server, teamID uint) string { + inviteUUID, err := uuid.NewV7() + require.NoError(t, err) + invitation := models.TeamInvitation{TeamID: int(teamID), UniqueID: inviteUUID.String()} + require.NoError(t, srv.DB.Create(&invitation).Error) + return inviteUUID.String() +} + +func userCount(t *testing.T, srv *server.Server) int64 { + var count int64 + require.NoError(t, srv.DB.Model(&models.User{}).Count(&count).Error) + return count +} + +// socialCallback runs the social login callback with a mocked provider result +// and an optional team invitation stored in the session. +func socialCallback(t *testing.T, srv *server.Server, user goth.User, inviteUUID string) *httptest.ResponseRecorder { + authHandler := handlers.NewAuthHandler(srv.DB, srv.Config, srv.JwtIssuer, srv.Redis, &MockSocialAuthProvider{User: user}) + authHandler.ServerState.EmailClient = srv.EmailClient + srv.Echo.Router().Add(http.MethodGet, "/api/auth/social/:provider/callback", authHandler.SocialLoginCallback) + + req := httptest.NewRequest(http.MethodGet, "/api/auth/social/google/callback", nil) + rec := httptest.NewRecorder() + + if inviteUUID != "" { + sess, _ := srv.Store.Get(req, "session") + sess.Values["team_invite_uuid"] = inviteUUID + require.NoError(t, sess.Save(req, rec)) + req.Header.Set("Cookie", rec.Header().Get("Set-Cookie")) + } + + srv.Echo.ServeHTTP(rec, req) + return rec +} + +func TestInstanceConfig_Defaults(t *testing.T) { + srv, cleanup := setupTestServerFast(t) + defer cleanup() + + req := httptest.NewRequest(http.MethodGet, "/api/config", nil) + rec := httptest.NewRecorder() + srv.Echo.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + var resp handlers.InstanceConfigResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + assert.True(t, resp.SignupEnabled) + assert.True(t, resp.PasswordLoginEnabled) + assert.False(t, resp.BillingEnabled) + assert.Empty(t, resp.AuthProviders) + // Clients iterate over the list, so it must serialize as [] and not null. + assert.Contains(t, rec.Body.String(), `"auth_providers":[]`) +} + +func TestInstanceConfig_ReflectsFlags(t *testing.T) { + srv, cleanup := setupTestServerWithConfig(t, func(cfg *config.Config) { + cfg.Auth.DisableSignup = true + cfg.Auth.DisablePasswordLogin = true + cfg.Auth.GitHubKey = "key" + cfg.Auth.GitHubSecret = "secret" + cfg.Auth.GoogleKey = "key-without-secret" + cfg.Stripe.SecretKey = "sk_test_dummy" + }) + defer cleanup() + createTestUser(t, srv.DB, "existing@gmail.com", "Existing", "User", "securepassword123", false) + + req := httptest.NewRequest(http.MethodGet, "/api/config", nil) + rec := httptest.NewRecorder() + srv.Echo.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + var resp handlers.InstanceConfigResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + assert.False(t, resp.SignupEnabled) + assert.False(t, resp.PasswordLoginEnabled) + assert.True(t, resp.BillingEnabled) + assert.Equal(t, []string{"github"}, resp.AuthProviders) +} + +func TestInstanceConfig_SignupEnabledWhileInstanceIsEmpty(t *testing.T) { + srv, cleanup := setupTestServerSignupDisabled(t) + defer cleanup() + + req := httptest.NewRequest(http.MethodGet, "/api/config", nil) + rec := httptest.NewRecorder() + srv.Echo.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + var resp handlers.InstanceConfigResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + assert.True(t, resp.SignupEnabled) +} + +func TestSocialProviders_OnlyConfiguredAreRegistered(t *testing.T) { + _, cleanup := setupTestServerWithConfig(t, func(cfg *config.Config) { + cfg.Auth.GitHubKey = "key" + cfg.Auth.GitHubSecret = "secret" + }) + defer cleanup() + + providers := goth.GetProviders() + assert.Len(t, providers, 1) + assert.Contains(t, providers, "github") +} + +func TestDisableSignup_FirstAccountAllowed(t *testing.T) { + srv, cleanup := setupTestServerSignupDisabled(t) + defer cleanup() + + rec := postJSON(t, srv, "/api/sign-up", signUpPayload("first@gmail.com")) + require.Equal(t, http.StatusCreated, rec.Code, rec.Body.String()) + + var user models.User + require.NoError(t, srv.DB.Where("email = ?", "first@gmail.com").First(&user).Error) + assert.True(t, user.IsAdmin) + assert.NotNil(t, user.TeamID) +} + +func TestDisableSignup_UninvitedRejected(t *testing.T) { + srv, cleanup := setupTestServerSignupDisabled(t) + defer cleanup() + createTestUser(t, srv.DB, "existing@gmail.com", "Existing", "User", "securepassword123", false) + + rec := postJSON(t, srv, "/api/sign-up", signUpPayload("stranger@gmail.com")) + + assert.Equal(t, http.StatusForbidden, rec.Code) + assert.Equal(t, int64(1), userCount(t, srv)) + + // The rejected sign-up must not leave an orphan team behind. + var teams int64 + require.NoError(t, srv.DB.Model(&models.Team{}).Count(&teams).Error) + assert.Equal(t, int64(1), teams) +} + +func TestDisableSignup_InvalidInviteRejected(t *testing.T) { + srv, cleanup := setupTestServerSignupDisabled(t) + defer cleanup() + createTestUser(t, srv.DB, "existing@gmail.com", "Existing", "User", "securepassword123", false) + + payload := signUpPayload("stranger@gmail.com") + payload["team_invite_uuid"] = "not-a-real-invitation" + rec := postJSON(t, srv, "/api/sign-up", payload) + + assert.Equal(t, http.StatusForbidden, rec.Code) + assert.Equal(t, int64(1), userCount(t, srv)) +} + +func TestDisableSignup_InvitedAllowed(t *testing.T) { + srv, cleanup := setupTestServerSignupDisabled(t) + defer cleanup() + admin := createTestUser(t, srv.DB, "admin@gmail.com", "Admin", "User", "securepassword123", false) + + payload := signUpPayload("invited@gmail.com") + payload["team_invite_uuid"] = createTestInvitation(t, srv, *admin.TeamID) + rec := postJSON(t, srv, "/api/sign-up", payload) + + require.Equal(t, http.StatusCreated, rec.Code, rec.Body.String()) + var user models.User + require.NoError(t, srv.DB.Where("email = ?", "invited@gmail.com").First(&user).Error) + assert.Equal(t, *admin.TeamID, *user.TeamID) + assert.False(t, user.IsAdmin) +} + +func TestDisableSignup_SocialUninvitedRejected(t *testing.T) { + srv, cleanup := setupTestServerSignupDisabled(t) + defer cleanup() + createTestUser(t, srv.DB, "existing@gmail.com", "Existing", "User", "securepassword123", false) + + rec := socialCallback(t, srv, goth.User{Email: "stranger@gmail.com", FirstName: "Stranger"}, "") + + assert.Equal(t, http.StatusFound, rec.Code) + assert.Equal(t, "/login?error=signup_disabled", rec.Header().Get("Location")) + assert.Equal(t, int64(1), userCount(t, srv)) +} + +func TestDisableSignup_SocialInvitedAllowed(t *testing.T) { + srv, cleanup := setupTestServerSignupDisabled(t) + defer cleanup() + admin := createTestUser(t, srv.DB, "admin@gmail.com", "Admin", "User", "securepassword123", false) + invite := createTestInvitation(t, srv, *admin.TeamID) + + rec := socialCallback(t, srv, goth.User{Email: "invited@gmail.com", FirstName: "Invited"}, invite) + + assert.Equal(t, http.StatusFound, rec.Code) + assert.Contains(t, rec.Header().Get("Location"), "/login?token=") + var user models.User + require.NoError(t, srv.DB.Where("email = ?", "invited@gmail.com").First(&user).Error) + assert.Equal(t, *admin.TeamID, *user.TeamID) +} + +func TestDisableSignup_SocialExistingUserCanSignIn(t *testing.T) { + srv, cleanup := setupTestServerSignupDisabled(t) + defer cleanup() + createTestUser(t, srv.DB, "existing@gmail.com", "Existing", "User", "securepassword123", false) + + rec := socialCallback(t, srv, goth.User{Email: "existing@gmail.com", FirstName: "Existing"}, "") + + assert.Equal(t, http.StatusFound, rec.Code) + assert.Contains(t, rec.Header().Get("Location"), "/login?token=") +} + +func TestDisableSignup_ExistingUserCanSignIn(t *testing.T) { + srv, cleanup := setupTestServerSignupDisabled(t) + defer cleanup() + createTestUser(t, srv.DB, "existing@gmail.com", "Existing", "User", "securepassword123", false) + + rec := postJSON(t, srv, "/api/sign-in", map[string]interface{}{ + "email": "existing@gmail.com", + "password": "securepassword123", + }) + + assert.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) +} + +func TestDisablePasswordLogin_EndpointsRejected(t *testing.T) { + srv, cleanup := setupTestServerWithConfig(t, func(cfg *config.Config) { + cfg.Auth.DisablePasswordLogin = true + cfg.Auth.GitHubKey = "key" + cfg.Auth.GitHubSecret = "secret" + }) + defer cleanup() + createTestUser(t, srv.DB, "existing@gmail.com", "Existing", "User", "securepassword123", false) + + cases := []struct { + name string + method string + path string + payload map[string]interface{} + }{ + {"sign-in", http.MethodPost, "/api/sign-in", map[string]interface{}{"email": "existing@gmail.com", "password": "securepassword123"}}, + {"sign-up", http.MethodPost, "/api/sign-up", signUpPayload("new@gmail.com")}, + {"forgot-password", http.MethodPost, "/api/forgot-password", map[string]interface{}{"email": "existing@gmail.com"}}, + {"reset-password", http.MethodPatch, "/api/reset-password/some-token", map[string]interface{}{"password": "anothersecurepassword"}}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + body, err := json.Marshal(tc.payload) + require.NoError(t, err) + req := httptest.NewRequest(tc.method, tc.path, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + srv.Echo.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusForbidden, rec.Code, rec.Body.String()) + }) + } + + assert.Equal(t, int64(1), userCount(t, srv)) +} + +func TestDisablePasswordLogin_SocialLoginStillWorks(t *testing.T) { + srv, cleanup := setupTestServerWithConfig(t, func(cfg *config.Config) { + cfg.Auth.DisablePasswordLogin = true + cfg.Auth.GitHubKey = "key" + cfg.Auth.GitHubSecret = "secret" + }) + defer cleanup() + + rec := socialCallback(t, srv, goth.User{Email: "new@gmail.com", FirstName: "New"}, "") + + assert.Equal(t, http.StatusFound, rec.Code) + assert.Contains(t, rec.Header().Get("Location"), "/login?token=") +} diff --git a/docs/src/content/docs/open-source/self-hosting.md b/docs/src/content/docs/open-source/self-hosting.md index 9591fc6a..68a73ff7 100644 --- a/docs/src/content/docs/open-source/self-hosting.md +++ b/docs/src/content/docs/open-source/self-hosting.md @@ -57,6 +57,24 @@ curl https://hopp.example.com/api/health Open `https://hopp.example.com` in a browser and sign up. The first registered account becomes the team admin owner. +## Access control + +By default anyone who can reach your instance can sign up and create their own team. Lock it down in `.env`: + +| Variable | Effect when set to `true` | +| ------------------------ | -------------------------------------------------------------------------------------------------------------- | +| `DISABLE_SIGNUP` | New accounts need a team invitation link. The very first account of a fresh instance can still be created. | +| `DISABLE_PASSWORD_LOGIN` | Email/password sign-in, sign-up and password reset are rejected. Only the configured OAuth providers are left. | + +Notes: + +- With `DISABLE_SIGNUP`, create your admin account first, then invite teammates from the dashboard. Existing users keep signing in as before. +- `DISABLE_PASSWORD_LOGIN` requires Google or GitHub login to be configured (`GOOGLE_*` or `GITHUB_*`), otherwise the backend refuses to start. Slack alone does not count, the web app has no Slack login button. +- OAuth providers without a key and secret are not offered on the login page. +- Billing is off unless `STRIPE_SECRET_KEY` is set. Without it every team has full access and the web app hides the subscription page. + +Restart the backend after changing these: `docker compose up -d backend`. + ## Firewall Many cloud providers (Scaleway DEV, Hetzner Cloud, basic DigitalOcean droplets) do **not** apply a firewall by default — these ports will already be reachable. Skip this section unless your provider has a security group, network ACL, or you've enabled `ufw`/`firewalld` on the host. diff --git a/selfhost/.env.example b/selfhost/.env.example index 977db782..126d4cb4 100644 --- a/selfhost/.env.example +++ b/selfhost/.env.example @@ -50,6 +50,20 @@ RESEND_API_KEY= # Default "from" address for outgoing emails. RESEND_DEFAULT_SENDER= +# ------------------------------------------------------------ +# OPTIONAL — Access control +# ------------------------------------------------------------ + +# Set to "true" to stop strangers from creating accounts. New accounts then +# need a team invitation link. The very first account can still be created, +# so a fresh instance can be bootstrapped. +DISABLE_SIGNUP= + +# Set to "true" to turn off email/password login (sign-in, sign-up and password +# reset). Requires Google or GitHub login below, otherwise the backend refuses +# to start. +DISABLE_PASSWORD_LOGIN= + # ------------------------------------------------------------ # OPTIONAL — OAuth providers (auto-disabled when empty) # ------------------------------------------------------------ diff --git a/selfhost/compose.yml b/selfhost/compose.yml index fffdd416..bd9545f5 100644 --- a/selfhost/compose.yml +++ b/selfhost/compose.yml @@ -47,6 +47,8 @@ services: USE_TLS: "false" RESEND_API_KEY: ${RESEND_API_KEY:-} RESEND_DEFAULT_SENDER: ${RESEND_DEFAULT_SENDER:-noreply@${DOMAIN}} + DISABLE_SIGNUP: ${DISABLE_SIGNUP:-} + DISABLE_PASSWORD_LOGIN: ${DISABLE_PASSWORD_LOGIN:-} GOOGLE_KEY: ${GOOGLE_KEY:-} GOOGLE_SECRET: ${GOOGLE_SECRET:-} SLACK_KEY: ${SLACK_KEY:-} diff --git a/tauri/src/openapi.d.ts b/tauri/src/openapi.d.ts index c0b9b5b9..e4b26b52 100644 --- a/tauri/src/openapi.d.ts +++ b/tauri/src/openapi.d.ts @@ -74,6 +74,45 @@ export interface paths { patch?: never; trace?: never; }; + "/api/config": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Public instance configuration + * @description Unauthenticated. Tells clients which sign-up and login flows are enabled on this instance. + */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Instance configuration */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["InstanceConfig"]; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/auth/social/:provider": { parameters: { query?: never; @@ -2812,6 +2851,17 @@ export interface components { /** Format: date-time */ updated_at?: string; }; + /** @description Public configuration of this instance, used by clients to hide flows the backend would reject */ + InstanceConfig: { + /** @description False when DISABLE_SIGNUP is set and the instance already has users; new accounts then need a team invitation */ + signup_enabled: boolean; + /** @description False when DISABLE_PASSWORD_LOGIN is set; only social providers can be used */ + password_login_enabled: boolean; + /** @description True when Stripe billing is configured */ + billing_enabled: boolean; + /** @description Social login providers that are configured on this instance */ + auth_providers: ("google" | "slack" | "github")[]; + }; }; responses: never; parameters: never; diff --git a/web-app/src/components/sidebar.tsx b/web-app/src/components/sidebar.tsx index 3338e970..9c2f7ebf 100644 --- a/web-app/src/components/sidebar.tsx +++ b/web-app/src/components/sidebar.tsx @@ -90,10 +90,13 @@ export function HoppSidebar() { refetchInterval: 10_000, }); + // Self-hosted instances without Stripe have nothing to manage here + const { data: instanceConfig } = useQuery("get", "/api/config"); + // Add subscription item for admin users const navigationItems = [ ...items, - ...(user?.is_admin ? + ...(user?.is_admin && instanceConfig?.billing_enabled ? [ { title: "Subscription", diff --git a/web-app/src/openapi.d.ts b/web-app/src/openapi.d.ts index c0b9b5b9..e4b26b52 100644 --- a/web-app/src/openapi.d.ts +++ b/web-app/src/openapi.d.ts @@ -74,6 +74,45 @@ export interface paths { patch?: never; trace?: never; }; + "/api/config": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Public instance configuration + * @description Unauthenticated. Tells clients which sign-up and login flows are enabled on this instance. + */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Instance configuration */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["InstanceConfig"]; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/auth/social/:provider": { parameters: { query?: never; @@ -2812,6 +2851,17 @@ export interface components { /** Format: date-time */ updated_at?: string; }; + /** @description Public configuration of this instance, used by clients to hide flows the backend would reject */ + InstanceConfig: { + /** @description False when DISABLE_SIGNUP is set and the instance already has users; new accounts then need a team invitation */ + signup_enabled: boolean; + /** @description False when DISABLE_PASSWORD_LOGIN is set; only social providers can be used */ + password_login_enabled: boolean; + /** @description True when Stripe billing is configured */ + billing_enabled: boolean; + /** @description Social login providers that are configured on this instance */ + auth_providers: ("google" | "slack" | "github")[]; + }; }; responses: never; parameters: never; diff --git a/web-app/src/pages/Login.tsx b/web-app/src/pages/Login.tsx index 31e12d4c..7f67fef2 100644 --- a/web-app/src/pages/Login.tsx +++ b/web-app/src/pages/Login.tsx @@ -112,6 +112,22 @@ export function LoginForm({ className, isInvitation = false, ...props }: LoginFo }, ); + // Tells us which sign-up and login flows this instance allows, so we don't + // offer ones the backend would reject. + const { data: instanceConfig, isLoading: isLoadingConfig } = useQuery("get", "/api/config"); + const signupEnabled = instanceConfig?.signup_enabled ?? true; + const passwordLoginEnabled = instanceConfig?.password_login_enabled ?? true; + const authProviders = instanceConfig?.auth_providers ?? []; + const hasSocialLogin = authProviders.includes("google") || authProviders.includes("github"); + + useEffect(() => { + if (searchParams.get("error") === "signup_disabled") { + toast.error("Sign-up is disabled on this instance. Ask a team admin for an invitation link.", { + id: "signup-disabled", + }); + } + }, [searchParams]); + useEffect(() => { if (invitationError) { toast.error("Failed to fetch invitation details, contact your admin for a new invitation link"); @@ -253,12 +269,12 @@ export function LoginForm({ className, isInvitation = false, ...props }: LoginFo ); - if (isLoadingInvitation) { + if (isLoadingInvitation || isLoadingConfig) { return (
Loading invitation...
+{isLoadingInvitation ? "Loading invitation..." : "Loading..."}