From 6a10f9778d8706a6d570ebdf7dcf75cbc226b280 Mon Sep 17 00:00:00 2001 From: Ryan Westfall Date: Tue, 20 Oct 2026 06:39:52 -0500 Subject: [PATCH] Enable password self-registration and keep SSO off. Public sign-up was closed, and the live Google and Microsoft apps would have become sign-up buttons if the same flag turned both on. --- .env.example | 7 +++- .env.prod.example | 10 +++-- README.md | 3 +- llm_be/chat_backend/oauth.py | 4 +- llm_be/chat_backend/tests/test_oauth.py | 38 +++++++++++++++++++ llm_be/chat_backend/tests/test_views_users.py | 20 +++++++++- llm_be/chat_backend/views.py | 2 +- llm_be/chat_backend/views_oauth.py | 11 +++++- llm_be/llm_be/settings.py | 9 +++-- 9 files changed, 90 insertions(+), 14 deletions(-) diff --git a/.env.example b/.env.example index a139d5f..af0f1a8 100644 --- a/.env.example +++ b/.env.example @@ -39,8 +39,11 @@ EMAIL_USE_TLS=true # Captcha (optional local) CAPTCHA_SECRET_KEY= -# Self-serve sign-up (default false — set true to allow /user/create/) -ENABLE_ACCOUNT_REGISTRATION=false +# Password self-serve sign-up (default true). Set false to close /user/create/. +ENABLE_ACCOUNT_REGISTRATION=true +# Browser SSO. Leave false until Google / Microsoft sign-in should be public. +# Client ids below can still be set for Drive linking. +ENABLE_SSO=false # OAuth SSO — Google / Microsoft (#24). Leave blank to hide SSO buttons. # Redirect URIs (register in each IdP console): diff --git a/.env.prod.example b/.env.prod.example index bd508e1..85100c9 100644 --- a/.env.prod.example +++ b/.env.prod.example @@ -59,9 +59,13 @@ EMAIL_USE_TLS=true # Captcha CAPTCHA_SECRET_KEY=replace-with-captcha-secret -# Self-serve account registration (sign-up). Keep false until ready to open -# public registration; set true in chat_backend_prod.env / chat_backend_beta.env. -ENABLE_ACCOUNT_REGISTRATION=false +# Password self-serve sign-up. Default true. Set false in +# chat_backend_prod.env / chat_backend_beta.env to close public registration. +# An explicit false in that secret overrides the code default. +ENABLE_ACCOUNT_REGISTRATION=true +# Browser SSO stays off. OAuth client ids below are for Drive linking until +# this is set true. +ENABLE_SSO=false # OAuth SSO — Google / Microsoft (#24). Never commit real secrets. # Register redirect URIs: diff --git a/README.md b/README.md index 1adb52d..092982e 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,8 @@ with `COMPOSE_DATABASE_URL` if needed. | `SEARXNG_BASE_URL` | `http://127.0.0.1:8088` | yes if using SearxNG | Self-hosted SearxNG JSON API | | `EMAIL_HOST_*` | empty | yes (prod/beta) | SMTP2GO | | `CAPTCHA_SECRET_KEY` | empty | recommended | | -| `ENABLE_ACCOUNT_REGISTRATION` | `false` | optional | Self-serve sign-up; keep false until ready | +| `ENABLE_ACCOUNT_REGISTRATION` | `true` | optional | Password self-serve sign-up. Set `false` to close it | +| `ENABLE_SSO` | `false` | optional | Google / Microsoft sign-in and sign-up. Drive linking does not need this | | `STRIPE_SECRET_KEY` / `STRIPE_PUBLISHABLE_KEY` / `STRIPE_WEBHOOK_SECRET` | empty | yes for billing | Stripe API + webhook (`monetization` app) | | `APPLE_IAP_BUNDLE_ID` | `ai.hesychia.chat` | yes for iOS IAP | Must match the signed StoreKit transaction | | `GOOGLE_PLAY_PACKAGE_NAME` | `ai.hesychia.chat` | yes for Play IAP | Android application id | diff --git a/llm_be/chat_backend/oauth.py b/llm_be/chat_backend/oauth.py index 936606b..b011a9a 100644 --- a/llm_be/chat_backend/oauth.py +++ b/llm_be/chat_backend/oauth.py @@ -459,8 +459,8 @@ def resolve_user_from_profile( upsert_identity(email_user, profile) return email_user, False - # New account path - allow_create = settings.ENABLE_ACCOUNT_REGISTRATION + # New account path. Password registration does not enable SSO sign-up. + allow_create = settings.ENABLE_ACCOUNT_REGISTRATION and settings.ENABLE_SSO if intent == "signup" and not allow_create: raise OAuthError("registration_disabled", "Account registration is disabled.") if intent == "login" and not allow_create: diff --git a/llm_be/chat_backend/tests/test_oauth.py b/llm_be/chat_backend/tests/test_oauth.py index 0f8c255..3ded0bd 100644 --- a/llm_be/chat_backend/tests/test_oauth.py +++ b/llm_be/chat_backend/tests/test_oauth.py @@ -24,6 +24,7 @@ OAUTH_SETTINGS = { "FRONTEND_BASE_URL": "http://frontend.test", "OAUTH_CALLBACK_BASE_URL": "http://backend.test", "ENABLE_ACCOUNT_REGISTRATION": True, + "ENABLE_SSO": True, } @@ -53,6 +54,14 @@ class PublicSettingsOAuthTestCase(APITestCase): self.assertTrue(response.data["oauth"]["google"]) self.assertTrue(response.data["oauth"]["microsoft"]) + @override_settings(ENABLE_SSO=False) + def test_hides_oauth_when_sso_disabled(self): + response = self.client.get(reverse("public_settings")) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertTrue(response.data["enable_account_registration"]) + self.assertFalse(response.data["oauth"]["google"]) + self.assertFalse(response.data["oauth"]["microsoft"]) + @override_settings(GOOGLE_OAUTH_CLIENT_ID="", GOOGLE_OAUTH_CLIENT_SECRET="") def test_hides_unconfigured_provider(self): response = self.client.get(reverse("public_settings")) @@ -83,6 +92,15 @@ class OAuthStartTestCase(APITestCase): ) self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + @override_settings(ENABLE_SSO=False) + def test_login_and_signup_blocked_when_sso_disabled(self): + for intent in ("login", "signup"): + response = self.client.get( + reverse("oauth_start", kwargs={"provider": "google"}), + {"intent": intent}, + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + @override_settings(ENABLE_ACCOUNT_REGISTRATION=False) def test_signup_intent_blocked_when_registration_disabled(self): response = self.client.get( @@ -180,6 +198,17 @@ class OAuthCallbackTestCase(APITestCase): params = parse_qs(urlparse(response["Location"]).query) self.assertEqual(params["error"], ["email_missing"]) + @patch("chat_backend.views_oauth.exchange_code_for_profile") + @override_settings(ENABLE_SSO=False) + def test_callback_signup_rejected_when_sso_disabled(self, mock_exchange): + mock_exchange.return_value = _google_profile() + + response = self._callback(intent="signup") + params = parse_qs(urlparse(response["Location"]).query) + self.assertEqual(params["error"], ["sso_disabled"]) + self.assertEqual(CustomUser.objects.count(), 0) + mock_exchange.assert_not_called() + @patch("chat_backend.views_oauth.exchange_code_for_profile") @override_settings(ENABLE_ACCOUNT_REGISTRATION=False) def test_callback_login_without_account_when_registration_off(self, mock_exchange): @@ -256,6 +285,15 @@ class OAuthStartDriveLinkTestCase(APITestCase): ) self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + @override_settings(ENABLE_SSO=False) + def test_link_drive_still_starts_when_sso_disabled(self): + self.client.force_authenticate(user=self.user) + response = self.client.get( + reverse("oauth_start", kwargs={"provider": "google"}), + {"intent": "link_drive"}, + ) + self.assertEqual(response.status_code, status.HTTP_302_FOUND) + def test_link_drive_authenticated_redirects_with_drive_scope_and_state(self): self.client.force_authenticate(user=self.user) response = self.client.get( diff --git a/llm_be/chat_backend/tests/test_views_users.py b/llm_be/chat_backend/tests/test_views_users.py index 3748610..e515a0d 100644 --- a/llm_be/chat_backend/tests/test_views_users.py +++ b/llm_be/chat_backend/tests/test_views_users.py @@ -115,15 +115,33 @@ class PublicSettingsTestCase(APITestCase): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data["enable_account_registration"], False) + def test_registration_on_and_sso_hidden_by_default(self): + response = self.client.get(reverse("public_settings")) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["enable_account_registration"], True) + self.assertEqual(response.data["oauth"], {"google": False, "microsoft": False}) + class UserCreateTestCase(APITestCase): - def test_registration_disabled_by_default(self): + def test_registration_enabled_by_default(self): response = self.client.post( reverse("create_user"), {"email": "new@example.com", "password": "securepass1"}, format="json", ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertTrue(CustomUser.objects.filter(email="new@example.com").exists()) + + def test_registration_disabled_when_setting_off(self): + with self.settings(ENABLE_ACCOUNT_REGISTRATION=False): + response = self.client.post( + reverse("create_user"), + {"email": "new@example.com", "password": "securepass1"}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertEqual(CustomUser.objects.count(), 0) diff --git a/llm_be/chat_backend/views.py b/llm_be/chat_backend/views.py index 18cdaba..9fc21f2 100644 --- a/llm_be/chat_backend/views.py +++ b/llm_be/chat_backend/views.py @@ -178,7 +178,7 @@ class PublicWaitlistView(APIView): class CustomUserCreate(APIView): - """Self-serve registration. Gated by ENABLE_ACCOUNT_REGISTRATION (default off).""" + """Password self-serve registration. Gated by ENABLE_ACCOUNT_REGISTRATION.""" permission_classes = (permissions.AllowAny,) authentication_classes = () diff --git a/llm_be/chat_backend/views_oauth.py b/llm_be/chat_backend/views_oauth.py index f3028ee..d9a42c6 100644 --- a/llm_be/chat_backend/views_oauth.py +++ b/llm_be/chat_backend/views_oauth.py @@ -102,6 +102,11 @@ class OAuthStartView(APIView): }, status=status.HTTP_400_BAD_REQUEST, ) + if intent in {"login", "signup"} and not settings.ENABLE_SSO: + return Response( + {"detail": "Single sign-on is not enabled."}, + status=status.HTTP_403_FORBIDDEN, + ) if intent == "signup" and not settings.ENABLE_ACCOUNT_REGISTRATION: return Response( {"detail": "Account registration is disabled."}, @@ -184,6 +189,8 @@ class OAuthCallbackView(APIView): return self._handle_drive_link_callback( request, provider=provider, code=code, state_data=state_data ) + if not settings.ENABLE_SSO: + return _redirect_error("sso_disabled", "Single sign-on is not enabled.") try: redirect_uri = _callback_redirect_uri(request, provider) @@ -261,5 +268,7 @@ class OAuthCallbackView(APIView): def oauth_public_flags() -> dict: - """Feature flags for /public/settings/.""" + """Feature flags for /public/settings/. SSO buttons stay hidden until ENABLE_SSO.""" + if not settings.ENABLE_SSO: + return {"google": False, "microsoft": False} return configured_providers() diff --git a/llm_be/llm_be/settings.py b/llm_be/llm_be/settings.py index bcf1209..33ccab7 100644 --- a/llm_be/llm_be/settings.py +++ b/llm_be/llm_be/settings.py @@ -381,9 +381,12 @@ AGENT_FETCH_URL_MAX_BYTES = int( env("AGENT_FETCH_URL_MAX_BYTES", str(2 * 1024 * 1024)) or str(2 * 1024 * 1024) ) -# Self-serve account registration (sign-up page). Default off — enable via -# control-node secret (chat_backend_.env) when ready for public sign-up. -ENABLE_ACCOUNT_REGISTRATION = env_bool("ENABLE_ACCOUNT_REGISTRATION", False) +# Password self-serve sign-up. On by default (#80). Set false in the +# control-node secret (chat_backend_.env) to close public registration. +ENABLE_ACCOUNT_REGISTRATION = env_bool("ENABLE_ACCOUNT_REGISTRATION", True) +# Browser SSO (Google / Microsoft sign-in and sign-up). Off until explicitly +# enabled. OAuth client ids may still be set for Drive linking (#47). +ENABLE_SSO = env_bool("ENABLE_SSO", False) # --------------------------------------------------------------------------- # OAuth SSO (Google / Microsoft) — #24 -- 2.54.0