diff --git a/.env.sample b/.env.sample index 3727b7078f2..fda1781b6ec 100644 --- a/.env.sample +++ b/.env.sample @@ -137,11 +137,11 @@ GEOSERVER_CORS_ALLOWED_HEADERS=* # Users Registration ACCOUNT_OPEN_SIGNUP=True -ACCOUNT_EMAIL_REQUIRED=True +ACCOUNT_SIGNUP_FIELDS="['email*', 'username*', 'password1*', 'password2*']" ACCOUNT_APPROVAL_REQUIRED=False ACCOUNT_CONFIRM_EMAIL_ON_GET=False ACCOUNT_EMAIL_VERIFICATION=none -ACCOUNT_AUTHENTICATION_METHOD=username_email +ACCOUNT_LOGIN_METHODS="{'email', 'username'}" AUTO_ASSIGN_REGISTERED_MEMBERS_TO_REGISTERED_MEMBERS_GROUP_NAME=True AUTO_ASSIGN_REGISTERED_MEMBERS_TO_CONTRIBUTORS=True diff --git a/.env_dev b/.env_dev index f4d32a94fa3..fc25dad82fe 100644 --- a/.env_dev +++ b/.env_dev @@ -139,11 +139,11 @@ GEOSERVER_CORS_ALLOWED_HEADERS=* # Users Registration ACCOUNT_OPEN_SIGNUP=True -ACCOUNT_EMAIL_REQUIRED=True +ACCOUNT_SIGNUP_FIELDS="['email*', 'username*', 'password1*', 'password2*']" ACCOUNT_APPROVAL_REQUIRED=False ACCOUNT_CONFIRM_EMAIL_ON_GET=False ACCOUNT_EMAIL_VERIFICATION=none -ACCOUNT_AUTHENTICATION_METHOD=username_email +ACCOUNT_LOGIN_METHODS="{'email', 'username'}" AUTO_ASSIGN_REGISTERED_MEMBERS_TO_REGISTERED_MEMBERS_GROUP_NAME=True AUTO_ASSIGN_REGISTERED_MEMBERS_TO_CONTRIBUTORS=True diff --git a/.env_local b/.env_local index bc9a975fe1a..b5d52ac6254 100644 --- a/.env_local +++ b/.env_local @@ -139,11 +139,11 @@ GEOSERVER_CORS_ALLOWED_HEADERS=* # Users Registration ACCOUNT_OPEN_SIGNUP=True -ACCOUNT_EMAIL_REQUIRED=True +ACCOUNT_SIGNUP_FIELDS="['email*', 'username*', 'password1*', 'password2*']" ACCOUNT_APPROVAL_REQUIRED=False ACCOUNT_CONFIRM_EMAIL_ON_GET=False ACCOUNT_EMAIL_VERIFICATION=none -ACCOUNT_AUTHENTICATION_METHOD=username_email +ACCOUNT_LOGIN_METHODS="{'email', 'username'}" AUTO_ASSIGN_REGISTERED_MEMBERS_TO_REGISTERED_MEMBERS_GROUP_NAME=True AUTO_ASSIGN_REGISTERED_MEMBERS_TO_CONTRIBUTORS=True diff --git a/.env_test b/.env_test index 04e8407217a..949ee8ac63d 100644 --- a/.env_test +++ b/.env_test @@ -145,11 +145,11 @@ GEOSERVER_CORS_ALLOWED_HEADERS=* # Users Registration ACCOUNT_OPEN_SIGNUP=True -ACCOUNT_EMAIL_REQUIRED=True +ACCOUNT_SIGNUP_FIELDS="['email*', 'username*', 'password1*', 'password2*']" ACCOUNT_APPROVAL_REQUIRED=False ACCOUNT_CONFIRM_EMAIL_ON_GET=False ACCOUNT_EMAIL_VERIFICATION=none -ACCOUNT_AUTHENTICATION_METHOD=username_email +ACCOUNT_LOGIN_METHODS="{'email', 'username'}" AUTO_ASSIGN_REGISTERED_MEMBERS_TO_REGISTERED_MEMBERS_GROUP_NAME=True AUTO_ASSIGN_REGISTERED_MEMBERS_TO_CONTRIBUTORS=True diff --git a/docs/src/setup/configuration/settings.md b/docs/src/setup/configuration/settings.md index e7224777959..14bbfc05092 100644 --- a/docs/src/setup/configuration/settings.md +++ b/docs/src/setup/configuration/settings.md @@ -43,8 +43,9 @@ It allows specifying the HTTP method used when confirming e-mail addresses. : - Default ``True`` -This is a [django-allauth setting](https://django-allauth.readthedocs.io/en/latest/configuration.html#configuration) -which controls whether the user is required to provide an e-mail address upon registration. +This is deprecated. +Use ``ACCOUNT_SIGNUP_FIELDS`` instead. +It controls whether the user is required to provide an e-mail address upon registration. **ACCOUNT_EMAIL_VERIFICATION** @@ -52,6 +53,14 @@ which controls whether the user is required to provide an e-mail address upon re This is a [django-allauth setting](https://django-allauth.readthedocs.io/en/latest/configuration.html#configuration) +**ACCOUNT_LOGIN_METHODS** + +: - Default ``{'email', 'username'}`` + - Env: ``ACCOUNT_LOGIN_METHODS`` + +This is a [django-allauth setting](https://docs.allauth.org/en/dev/account/configuration.html) +which controls which identifiers users can use to log in. + **ACCOUNT_LOGIN_REDIRECT_URL** @@ -87,6 +96,14 @@ This is a [django-user-accounts setting](https://django-user-accounts.readthedoc This is a [django-user-accounts setting](https://django-user-accounts.readthedocs.io/en/latest/settings.html) Whether or not people are allowed to self-register to GeoNode or not. +**ACCOUNT_SIGNUP_FIELDS** + +: - Default ``['email*', 'username*', 'password1*', 'password2*']`` + - Env: ``ACCOUNT_SIGNUP_FIELDS`` + +This is a [django-allauth setting](https://docs.allauth.org/en/dev/account/configuration.html) +which controls which fields are shown during signup. Fields marked with ``*`` are required. + **ACCOUNT_SIGNUP_FORM_CLASS** diff --git a/geonode/people/adapters.py b/geonode/people/adapters.py index 31ae2557f67..fbb7f0f17b9 100644 --- a/geonode/people/adapters.py +++ b/geonode/people/adapters.py @@ -326,7 +326,7 @@ class GenericOpenIDConnectAdapter(OAuth2Adapter, SocialAccountAdapter): profile_url = PROFILE_URL id_token_issuer = ID_TOKEN_ISSUER - def get_provider(self, request=None, provider=None): + def get_provider(self, request=None, provider=None, client_id=None): """Looks up a `provider`, supporting subproviders by looking up by `provider_id`. """ @@ -336,7 +336,7 @@ def get_provider(self, request=None, provider=None): provider = provider or self.provider_id provider_class = registry.get_class(provider) if provider_class is None or provider_class.uses_apps: - app = self.get_app(request, provider=provider) + app = self.get_app(request, provider=provider, client_id=client_id) if not provider_class: # In this case, the `provider` argument passed was a # `provider_id`. diff --git a/geonode/people/api/serializers.py b/geonode/people/api/serializers.py index 141f14b8e42..80711e67533 100644 --- a/geonode/people/api/serializers.py +++ b/geonode/people/api/serializers.py @@ -54,7 +54,7 @@ def validate(self, data): raise serializers.ValidationError(detail="username cannot be updated") email = data.get("email") # Email is required on post - if request.method in ("POST") and settings.ACCOUNT_EMAIL_REQUIRED and not email: + if request.method in ("POST") and "email*" in getattr(settings, "ACCOUNT_SIGNUP_FIELDS", []) and not email: raise serializers.ValidationError(detail="email missing from payload") # email should be unique if get_user_model().objects.filter(email=email).exists(): diff --git a/geonode/people/socialaccount/providers/geonode_openid_connect/allauth_test_utils.py b/geonode/people/socialaccount/providers/geonode_openid_connect/allauth_test_utils.py new file mode 100644 index 00000000000..5cd03776f20 --- /dev/null +++ b/geonode/people/socialaccount/providers/geonode_openid_connect/allauth_test_utils.py @@ -0,0 +1,328 @@ +""" +This file includes code originally from The django-allauth +(https://github.com/pennersr/django-allauth/), which is licensed under the MIT License (MIT). + +Copyright (c) 2010-2026 Raymond Penners and contributors. + + +This file is distributed as part of a larger work licensed under the +GNU General Public License version 3 (GPLv3). Use of this file must +comply with both the above MIT license for the portions derived from django-allauth +and the GPLv3 license for the combined work. +""" + +import json +import requests +from http import HTTPStatus +from unittest.mock import Mock +import base64 +import hashlib +import uuid +import warnings +from urllib.parse import parse_qs, urlparse + +from django.conf import settings +from django.contrib.auth import get_user_model +from django.test import RequestFactory +from django.test.utils import override_settings +from django.urls import reverse + +import allauth.app_settings +from allauth.account.models import EmailAddress +from allauth.account.utils import user_email, user_username +from allauth.socialaccount import app_settings +from allauth.socialaccount.adapter import get_adapter +from allauth.socialaccount.models import SocialAccount, SocialApp + + +class MockedResponse: + def __init__(self, status_code, content, headers=None): + if headers is None: + headers = {} + + self.status_code = status_code + if isinstance(content, dict): + content = json.dumps(content) + headers["content-type"] = "application/json" + self.content = content.encode("utf8") + self.headers = headers + + def json(self): + return json.loads(self.text) + + def raise_for_status(self): + pass + + @property + def ok(self): + return self.status_code // 100 == 2 + + @property + def text(self): + return self.content.decode("utf8") + + +class mocked_response: + def __init__(self, *responses, callback=None): + self.callback = callback + self.responses = list(responses) + + def __enter__(self): + self.orig_get = requests.Session.get + self.orig_post = requests.Session.post + self.orig_request = requests.Session.request + + def mockable_request(f): + def new_f(*args, **kwargs): + if self.callback: + response = self.callback(*args, **kwargs) + if response is not None: + return response + if self.responses: + resp = self.responses.pop(0) + if isinstance(resp, dict): + resp = MockedResponse(HTTPStatus.OK, resp) + return resp + return f(*args, **kwargs) + + return Mock(side_effect=new_f) + + requests.Session.get = mockable_request(requests.Session.get) + requests.Session.post = mockable_request(requests.Session.post) + requests.Session.request = mockable_request(requests.Session.request) + + def __exit__(self, type, value, traceback): + requests.Session.get = self.orig_get + requests.Session.post = self.orig_post + requests.Session.request = self.orig_request + + +def setup_app(provider_id): + request = RequestFactory().get("/") + apps = get_adapter().list_apps(request, provider_id) + if apps: + return apps[0] + + app = SocialApp.objects.create( + provider=provider_id, + name=provider_id, + client_id="app123id", + key=provider_id, + secret="dummy", + ) + if allauth.app_settings.SITES_ENABLED: + from django.contrib.sites.models import Site + + app.sites.add(Site.objects.get_current()) + return app + + +class OAuth2TestsMixin: + provider_id: str + + def get_mocked_response(self): + pass + + def get_expected_to_str(self): + raise NotImplementedError + + def get_access_token(self) -> str: + return "testac" + + def get_refresh_token(self) -> str: + return "testrf" + + def get_login_response_json(self, with_refresh_token=True): + response = { + "uid": uuid.uuid4().hex, + "access_token": self.get_access_token(), + } + if with_refresh_token: + response["refresh_token"] = self.get_refresh_token() + return json.dumps(response) + + def mocked_response(self, *responses): + return mocked_response(*responses) + + def setUp(self): + super().setUp() + self.setup_provider() + + def setup_provider(self): + self.app = setup_app(self.provider_id) + self.request = RequestFactory().get("/") + self.provider = self.app.get_provider(self.request) + + def test_provider_has_no_pkce_params(self): + provider_settings = app_settings.PROVIDERS.get(self.app.provider, {}) + provider_settings_with_pkce_set = provider_settings.copy() + provider_settings_with_pkce_set["OAUTH_PKCE_ENABLED"] = False + + with self.settings(SOCIALACCOUNT_PROVIDERS={self.app.provider: provider_settings_with_pkce_set}): + self.assertEqual(self.provider.get_pkce_params(), {}) + + def test_provider_has_pkce_params(self): + provider_settings = app_settings.PROVIDERS.get(self.app.provider, {}) + provider_settings_with_pkce_set = provider_settings.copy() + provider_settings_with_pkce_set["OAUTH_PKCE_ENABLED"] = True + + with self.settings(SOCIALACCOUNT_PROVIDERS={self.app.provider: provider_settings_with_pkce_set}): + pkce_params = self.provider.get_pkce_params() + self.assertEqual( + set(pkce_params.keys()), + {"code_challenge", "code_challenge_method", "code_verifier"}, + ) + hashed_verifier = hashlib.sha256(pkce_params["code_verifier"].encode("ascii")) + code_challenge = base64.urlsafe_b64encode(hashed_verifier.digest()) + code_challenge_without_padding = code_challenge.rstrip(b"=") + assert pkce_params["code_challenge"] == code_challenge_without_padding + + @override_settings(SOCIALACCOUNT_AUTO_SIGNUP=False) + def test_login(self): + resp_mock = self.get_mocked_response() + if not resp_mock: + warnings.warn(f"Cannot test provider {self.provider.id}, no oauth mock") + return + resp = self.login( + resp_mock, + ) + self.assertRedirects(resp, reverse("socialaccount_signup")) + + @override_settings(SOCIALACCOUNT_AUTO_SIGNUP=False) + def test_login_with_pkce_disabled(self): + provider_settings = app_settings.PROVIDERS.get(self.app.provider, {}) + provider_settings_with_pkce_disabled = provider_settings.copy() + provider_settings_with_pkce_disabled["OAUTH_PKCE_ENABLED"] = False + + with self.settings(SOCIALACCOUNT_PROVIDERS={self.app.provider: provider_settings_with_pkce_disabled}): + resp_mock = self.get_mocked_response() + if not resp_mock: + warnings.warn(f"Cannot test provider {self.provider.id}, no oauth mock") + return + resp = self.login( + resp_mock, + ) + self.assertRedirects(resp, reverse("socialaccount_signup")) + + @override_settings(SOCIALACCOUNT_AUTO_SIGNUP=False) + def test_login_with_pkce_enabled(self): + provider_settings = app_settings.PROVIDERS.get(self.app.provider, {}) + provider_settings_with_pkce_enabled = provider_settings.copy() + provider_settings_with_pkce_enabled["OAUTH_PKCE_ENABLED"] = True + with self.settings(SOCIALACCOUNT_PROVIDERS={self.app.provider: provider_settings_with_pkce_enabled}): + resp_mock = self.get_mocked_response() + if not resp_mock: + warnings.warn(f"Cannot test provider {self.provider.id}, no oauth mock") + return + + resp = self.login( + resp_mock, + ) + self.assertRedirects(resp, reverse("socialaccount_signup")) + + @override_settings(SOCIALACCOUNT_STORE_TOKENS=True) + def test_account_tokens(self, multiple_login=False): + email = "user@example.com" + user = get_user_model()(is_active=True) + user_email(user, email) + user_username(user, "user") + user.set_password("test") + user.save() + EmailAddress.objects.create(user=user, email=email, primary=True, verified=True) + self.client.login(username=user.username, password="test") + self.login(self.get_mocked_response(), process="connect") + if multiple_login: + self.login( + self.get_mocked_response(), + with_refresh_token=False, + process="connect", + ) + # get account + sa = SocialAccount.objects.filter(user=user, provider=self.provider.app.provider_id or self.provider.id).get() + provider_account = sa.get_provider_account() + self.assertEqual(provider_account.to_str(), self.get_expected_to_str()) + # The following lines don't actually test that much, but at least + # we make sure that the code is hit. + provider_account.get_avatar_url() + provider_account.get_profile_url() + provider_account.get_brand() + # get token + if self.app: + t = sa.socialtoken_set.get() + # verify access_token and refresh_token + self.assertEqual(self.get_access_token(), t.token) + resp = json.loads(self.get_login_response_json(with_refresh_token=True)) + if "refresh_token" in resp: + refresh_token = resp.get("refresh_token") + elif "refreshToken" in resp: + refresh_token = resp.get("refreshToken") + else: + refresh_token = "" + self.assertEqual(t.token_secret, refresh_token) + + @override_settings(SOCIALACCOUNT_STORE_TOKENS=True) + def test_account_refresh_token_saved_next_login(self): + """ + fails if a login missing a refresh token, deletes the previously + saved refresh token. Systems such as google's oauth only send + a refresh token on first login. + """ + self.test_account_tokens(multiple_login=True) + + def login(self, resp_mock=None, process="login", with_refresh_token=True): + with self.mocked_response(): + resp = self.client.post(self.provider.get_login_url(self.request, process=process)) + p = urlparse(resp["location"]) + q = parse_qs(p.query) + + pkce_enabled = app_settings.PROVIDERS.get(self.app.provider, {}).get( + "OAUTH_PKCE_ENABLED", self.provider.pkce_enabled_default + ) + + self.assertEqual("code_challenge" in q, pkce_enabled) + self.assertEqual("code_challenge_method" in q, pkce_enabled) + if pkce_enabled: + code_challenge = q["code_challenge"][0] + self.assertEqual(q["code_challenge_method"][0], "S256") + + complete_url = self.provider.get_callback_url() + self.assertGreater(q["redirect_uri"][0].find(complete_url), 0) + response_json = self.get_login_response_json(with_refresh_token=with_refresh_token) + + if isinstance(resp_mock, list): + resp_mocks = resp_mock + elif resp_mock is None: + resp_mocks = [] + else: + resp_mocks = [resp_mock] + + with self.mocked_response( + MockedResponse(HTTPStatus.OK, response_json, {"content-type": "application/json"}), + *resp_mocks, + ): + resp = self.client.get(complete_url, self.get_complete_parameters(q)) + + # Find the access token POST request, and assert that it contains + # the correct code_verifier if and only if PKCE is enabled + request_calls = requests.Session.request.call_args_list + for args, kwargs in request_calls: + data = kwargs.get("data", {}) + if args[0] == "POST" and isinstance(data, dict) and data.get("redirect_uri", "").endswith(complete_url): + self.assertEqual("code_verifier" in data, pkce_enabled) + + if pkce_enabled: + hashed_code_verifier = hashlib.sha256(data["code_verifier"].encode("ascii")) + expected_code_challenge = ( + base64.urlsafe_b64encode(hashed_code_verifier.digest()).rstrip(b"=").decode() + ) + self.assertEqual(code_challenge, expected_code_challenge) + + return resp + + def get_complete_parameters(self, q): + return {"code": "test", "state": q["state"][0]} + + def test_authentication_error(self): + resp = self.client.get(self.provider.get_callback_url()) + template_ext = getattr(settings, "ACCOUNT_TEMPLATE_EXTENSION", "html") + self.assertTemplateUsed(resp, f"socialaccount/authentication_error.{template_ext}") diff --git a/geonode/people/socialaccount/providers/geonode_openid_connect/tests.py b/geonode/people/socialaccount/providers/geonode_openid_connect/tests.py index 3baa76fb317..c980afeb4b2 100644 --- a/geonode/people/socialaccount/providers/geonode_openid_connect/tests.py +++ b/geonode/people/socialaccount/providers/geonode_openid_connect/tests.py @@ -39,8 +39,9 @@ # from allauth.socialaccount.models import SocialAccount from allauth.socialaccount.providers.apple.client import jwt_encode -from allauth.socialaccount.tests import OAuth2TestsMixin -from allauth.tests import TestCase, mocked_response +from django.test import TestCase + +from .allauth_test_utils import OAuth2TestsMixin, mocked_response @pytest.fixture diff --git a/geonode/people/tests.py b/geonode/people/tests.py index 8bea76afbfc..355db24a1f1 100644 --- a/geonode/people/tests.py +++ b/geonode/people/tests.py @@ -610,10 +610,10 @@ def test_users_api_patch_others_from_admin(self): self.assertEqual(response.json()["user"]["first_name"], "Robert Baratheon") - @override_settings(ACCOUNT_EMAIL_REQUIRED=True) + @override_settings(ACCOUNT_SIGNUP_FIELDS=["email*", "username*", "password1*", "password2*"]) def test_users_api_empty_email(self): """ - If the environment variable ACCOUNT_EMAIL_REQUIRED is set to True, + If the email field is required in ACCOUNT_SIGNUP_FIELDS, the email will be mandatory in the payload. """ data = { diff --git a/geonode/settings.py b/geonode/settings.py index 62d8750d83a..548c470a69c 100644 --- a/geonode/settings.py +++ b/geonode/settings.py @@ -1922,11 +1922,48 @@ def get_geonode_catalogue_service(): ACCOUNT_OPEN_SOCIALSIGNUP = ast.literal_eval(os.environ.get("ACCOUNT_OPEN_SOCIALSIGNUP", "True")) ACCOUNT_APPROVAL_REQUIRED = ast.literal_eval(os.getenv("ACCOUNT_APPROVAL_REQUIRED", "False")) ACCOUNT_ADAPTER = "geonode.people.adapters.LocalAccountAdapter" -ACCOUNT_AUTHENTICATION_METHOD = os.environ.get("ACCOUNT_AUTHENTICATION_METHOD", "username_email") ACCOUNT_CONFIRM_EMAIL_ON_GET = ast.literal_eval(os.environ.get("ACCOUNT_CONFIRM_EMAIL_ON_GET", "True")) -ACCOUNT_EMAIL_REQUIRED = ast.literal_eval(os.environ.get("ACCOUNT_EMAIL_REQUIRED", "True")) ACCOUNT_EMAIL_VERIFICATION = os.environ.get("ACCOUNT_EMAIL_VERIFICATION", "none") +# Deprecated django-allauth settings for backward compatibility. +# New deployments should use ACCOUNT_LOGIN_METHODS and ACCOUNT_SIGNUP_FIELDS instead. +_account_authentication_method = os.environ.get("ACCOUNT_AUTHENTICATION_METHOD") +_account_email_required = os.environ.get("ACCOUNT_EMAIL_REQUIRED") +if _account_authentication_method is not None: + logger.warning( + "settings.ACCOUNT_AUTHENTICATION_METHOD is deprecated, use: " + "settings.ACCOUNT_LOGIN_METHODS = {'email', 'username'}" + ) +else: + _account_authentication_method = "username_email" + +_default_login_methods = { + "username": {"username"}, + "email": {"email"}, + "username_email": {"username", "email"}, +}.get(_account_authentication_method, {"username", "email"}) + +ACCOUNT_LOGIN_METHODS = ast.literal_eval(os.environ.get("ACCOUNT_LOGIN_METHODS", repr(_default_login_methods))) + +if _account_email_required is not None: + logger.warning( + "settings.ACCOUNT_EMAIL_REQUIRED is deprecated, use: " + "settings.ACCOUNT_SIGNUP_FIELDS = ['email*', 'username*', 'password1*', 'password2*']" + ) + + _account_email_required = ast.literal_eval(_account_email_required) +else: + _account_email_required = True + +_default_signup_fields = [ + "email*" if _account_email_required else "email", + "username*", + "password1*", + "password2*", +] + +ACCOUNT_SIGNUP_FIELDS = ast.literal_eval(os.environ.get("ACCOUNT_SIGNUP_FIELDS", repr(_default_signup_fields))) + # Invitation Adapter INVITATIONS_ADAPTER = ACCOUNT_ADAPTER INVITATIONS_CONFIRMATION_URL_NAME = "geonode.invitations:accept-invite" diff --git a/geonode/views.py b/geonode/views.py index fbc6936bfb1..171993e19f0 100644 --- a/geonode/views.py +++ b/geonode/views.py @@ -162,7 +162,7 @@ def moderator_contacted(request, inactive_user=None): def moderator_needed(request): - """Used when a user signs in with an invalid or empty email if ACCOUNT_EMAIL_REQUIRED is True.""" + """Used when a user signs in with an invalid or empty required email.""" return TemplateResponse(request, template="account/admin_approval_needed.html") diff --git a/pyproject.toml b/pyproject.toml index 9fb297a4990..6058e27d317 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,7 +48,7 @@ dependencies = [ "zipstream-ng==1.8.0", # Django Apps - "django-allauth==0.63.6", + "django-allauth==65.16.0", "django-appconf==1.0.6", "django-filter==24.2", "django-imagekit==5.0.0",