diff --git a/ChangeLog.rst b/ChangeLog.rst index eb68c9d33e..a676cc14e5 100644 --- a/ChangeLog.rst +++ b/ChangeLog.rst @@ -31,6 +31,9 @@ Note worthy changes ``/accounts/3rdparty/``. The old endpoints still work as redirects are in place. +- Added a new setting, ``SOCIALACCOUNT_ONLY``, which when set to ``True``, + disables all functionality with respect to local accounts. + 0.61.1 (2024-02-09) ******************* diff --git a/allauth/account/apps.py b/allauth/account/apps.py index 934c2c946d..4d5c70d9a8 100644 --- a/allauth/account/apps.py +++ b/allauth/account/apps.py @@ -12,6 +12,8 @@ class AccountConfig(AppConfig): default_auto_field = app_settings.DEFAULT_AUTO_FIELD or "django.db.models.AutoField" def ready(self): + from allauth.account import checks # noqa + required_mw = "allauth.account.middleware.AccountMiddleware" if required_mw not in settings.MIDDLEWARE: raise ImproperlyConfigured( diff --git a/allauth/account/checks.py b/allauth/account/checks.py new file mode 100644 index 0000000000..ca5e9d1796 --- /dev/null +++ b/allauth/account/checks.py @@ -0,0 +1,27 @@ +from django.core.checks import Critical, register + + +@register() +def settings_check(app_configs, **kwargs): + from allauth import app_settings as allauth_app_settings + from allauth.account import app_settings + + ret = [] + if allauth_app_settings.SOCIALACCOUNT_ONLY: + if app_settings.LOGIN_BY_CODE_ENABLED: + ret.append( + Critical( + msg="SOCIALACCOUNT_ONLY does not work with ACCOUNT_LOGIN_BY_CODE_ENABLED" + ) + ) + if allauth_app_settings.MFA_ENABLED: + ret.append( + Critical(msg="SOCIALACCOUNT_ONLY does not work with 'allauth.mfa'") + ) + if app_settings.EMAIL_VERIFICATION != app_settings.EmailVerificationMethod.NONE: + ret.append( + Critical( + msg="SOCIALACCOUNT_ONLY requires ACCOUNT_EMAIL_VERIFICATION_METHOD = 'none'" + ) + ) + return ret diff --git a/allauth/account/urls.py b/allauth/account/urls.py index ae1c23baaf..57d6b9b5c1 100644 --- a/allauth/account/urls.py +++ b/allauth/account/urls.py @@ -1,53 +1,64 @@ from django.urls import path, re_path +from allauth import app_settings as allauth_app_settings from allauth.account import app_settings from . import views urlpatterns = [ - path("signup/", views.signup, name="account_signup"), path("login/", views.login, name="account_login"), path("logout/", views.logout, name="account_logout"), - path("reauthenticate/", views.reauthenticate, name="account_reauthenticate"), - path( - "password/change/", - views.password_change, - name="account_change_password", - ), - path("password/set/", views.password_set, name="account_set_password"), path("inactive/", views.account_inactive, name="account_inactive"), - # Email - path("email/", views.email, name="account_email"), - path( - "confirm-email/", - views.email_verification_sent, - name="account_email_verification_sent", - ), - re_path( - r"^confirm-email/(?P[-:\w]+)/$", - views.confirm_email, - name="account_confirm_email", - ), - # password reset - path("password/reset/", views.password_reset, name="account_reset_password"), - path( - "password/reset/done/", - views.password_reset_done, - name="account_reset_password_done", - ), - re_path( - r"^password/reset/key/(?P[0-9A-Za-z]+)-(?P.+)/$", - views.password_reset_from_key, - name="account_reset_password_from_key", - ), - path( - "password/reset/key/done/", - views.password_reset_from_key_done, - name="account_reset_password_from_key_done", - ), ] +if not allauth_app_settings.SOCIALACCOUNT_ONLY: + urlpatterns.extend( + [ + path("signup/", views.signup, name="account_signup"), + path( + "reauthenticate/", views.reauthenticate, name="account_reauthenticate" + ), + # Email + path("email/", views.email, name="account_email"), + path( + "confirm-email/", + views.email_verification_sent, + name="account_email_verification_sent", + ), + re_path( + r"^confirm-email/(?P[-:\w]+)/$", + views.confirm_email, + name="account_confirm_email", + ), + path( + "password/change/", + views.password_change, + name="account_change_password", + ), + path("password/set/", views.password_set, name="account_set_password"), + # password reset + path( + "password/reset/", views.password_reset, name="account_reset_password" + ), + path( + "password/reset/done/", + views.password_reset_done, + name="account_reset_password_done", + ), + re_path( + r"^password/reset/key/(?P[0-9A-Za-z]+)-(?P.+)/$", + views.password_reset_from_key, + name="account_reset_password_from_key", + ), + path( + "password/reset/key/done/", + views.password_reset_from_key_done, + name="account_reset_password_from_key_done", + ), + ] + ) + if app_settings.LOGIN_BY_CODE_ENABLED: urlpatterns.extend( [ diff --git a/allauth/account/views.py b/allauth/account/views.py index d6f67260ee..635419e39d 100644 --- a/allauth/account/views.py +++ b/allauth/account/views.py @@ -79,6 +79,8 @@ class LoginView( @sensitive_post_parameters_m @method_decorator(never_cache) def dispatch(self, request, *args, **kwargs): + if allauth_app_settings.SOCIALACCOUNT_ONLY and request.method != "GET": + raise PermissionDenied() return super().dispatch(request, *args, **kwargs) def get_form_kwargs(self): @@ -98,7 +100,9 @@ def form_valid(self, form): def get_context_data(self, **kwargs): ret = super().get_context_data(**kwargs) - signup_url = self.passthrough_next_url(reverse("account_signup")) + signup_url = None + if not allauth_app_settings.SOCIALACCOUNT_ONLY: + signup_url = self.passthrough_next_url(reverse("account_signup")) site = get_current_site(self.request) ret.update( @@ -106,6 +110,7 @@ def get_context_data(self, **kwargs): "signup_url": signup_url, "site": site, "SOCIALACCOUNT_ENABLED": allauth_app_settings.SOCIALACCOUNT_ENABLED, + "SOCIALACCOUNT_ONLY": allauth_app_settings.SOCIALACCOUNT_ONLY, "LOGIN_BY_CODE_ENABLED": app_settings.LOGIN_BY_CODE_ENABLED, } ) @@ -171,6 +176,7 @@ def get_context_data(self, **kwargs): "login_url": login_url, "site": site, "SOCIALACCOUNT_ENABLED": allauth_app_settings.SOCIALACCOUNT_ENABLED, + "SOCIALACCOUNT_ONLY": allauth_app_settings.SOCIALACCOUNT_ONLY, } ) return ret diff --git a/allauth/app_settings.py b/allauth/app_settings.py index 27dfdbc92a..937a49b492 100644 --- a/allauth/app_settings.py +++ b/allauth/app_settings.py @@ -18,6 +18,12 @@ def SITES_ENABLED(self): def SOCIALACCOUNT_ENABLED(self): return apps.is_installed("allauth.socialaccount") + @property + def SOCIALACCOUNT_ONLY(self) -> bool: + from allauth.utils import get_setting + + return get_setting("SOCIALACCOUNT_ONLY", False) + @property def MFA_ENABLED(self): return apps.is_installed("allauth.mfa") diff --git a/allauth/socialaccount/adapter.py b/allauth/socialaccount/adapter.py index f696201ac3..4b536748ff 100644 --- a/allauth/socialaccount/adapter.py +++ b/allauth/socialaccount/adapter.py @@ -10,18 +10,16 @@ from django.utils.crypto import get_random_string from django.utils.translation import gettext_lazy as _ +from allauth.account.adapter import get_adapter as get_account_adapter +from allauth.account.utils import user_email, user_field, user_username from allauth.core.internal.adapter import BaseAdapter - -from ..account.adapter import get_adapter as get_account_adapter -from ..account.app_settings import EmailVerificationMethod -from ..account.models import EmailAddress -from ..account.utils import user_email, user_field, user_username -from ..utils import ( +from allauth.utils import ( deserialize_instance, import_attribute, serialize_instance, valid_email_or_none, ) + from . import app_settings @@ -42,6 +40,9 @@ class DefaultSocialAccountAdapter(BaseAdapter): "invalid_token": _("Invalid token."), "no_password": _("Your account has no password set up."), "no_verified_email": _("Your account has no verified email address."), + "disconnect_last": _( + "You cannot disconnect your last remaining third-party account." + ), } def pre_social_login(self, request, sociallogin): @@ -142,21 +143,12 @@ def get_connect_redirect_url(self, request, socialaccount): url = reverse("socialaccount_connections") return url - def validate_disconnect(self, account, accounts): + def validate_disconnect(self, account, accounts) -> None: """ Validate whether or not the socialaccount account can be safely disconnected. """ - if len(accounts) == 1: - # No usable password would render the local account unusable - if not account.user.has_usable_password(): - raise self.validation_error("no_password") - # No email address, no password reset - if app_settings.EMAIL_VERIFICATION == EmailVerificationMethod.MANDATORY: - if not EmailAddress.objects.filter( - user=account.user, verified=True - ).exists(): - raise self.validation_error("no_verified_email") + pass def is_auto_signup_allowed(self, request, sociallogin): # If email is specified, check for duplicate and if so, no auto signup. diff --git a/allauth/socialaccount/forms.py b/allauth/socialaccount/forms.py index 2f24bcd249..a63346188f 100644 --- a/allauth/socialaccount/forms.py +++ b/allauth/socialaccount/forms.py @@ -54,7 +54,7 @@ def clean(self): cleaned_data = super(DisconnectForm, self).clean() account = cleaned_data.get("account") if account: - get_adapter(self.request).validate_disconnect(account, self.accounts) + flows.connect.validate_disconnect(self.request, account) return cleaned_data def save(self): diff --git a/allauth/socialaccount/internal/flows/connect.py b/allauth/socialaccount/internal/flows/connect.py index ba82b93268..3ee8dd09f0 100644 --- a/allauth/socialaccount/internal/flows/connect.py +++ b/allauth/socialaccount/internal/flows/connect.py @@ -1,8 +1,10 @@ from django.contrib import messages from django.http import HttpResponseRedirect +from allauth import app_settings as allauth_settings from allauth.account import app_settings as account_settings from allauth.account.adapter import get_adapter as get_account_adapter +from allauth.account.models import EmailAddress from allauth.account.reauthentication import ( raise_if_reauthentication_required, reauthenticate_then_callback, @@ -12,6 +14,32 @@ from allauth.socialaccount.models import SocialAccount, SocialLogin +def validate_disconnect(request, account): + """ + Validate whether or not the socialaccount account can be + safely disconnected. + """ + accounts = SocialAccount.objects.filter(user_id=account.user_id) + is_last = not accounts.exclude(pk=account.pk).exists() + if is_last: + adapter = get_adapter() + if allauth_settings.SOCIALACCOUNT_ONLY: + raise adapter.validation_error("disconnect_last") + # No usable password would render the local account unusable + if not account.user.has_usable_password(): + raise adapter.validation_error("no_password") + # No email address, no password reset + if ( + account_settings.EMAIL_VERIFICATION + == account_settings.EmailVerificationMethod.MANDATORY + ): + if not EmailAddress.objects.filter( + user=account.user, verified=True + ).exists(): + raise adapter.validation_error("no_verified_email") + adapter.validate_disconnect(account, accounts) + + def disconnect(request, account): if account_settings.REAUTHENTICATION_REQUIRED: raise_if_reauthentication_required(request) diff --git a/allauth/templates/account/login.html b/allauth/templates/account/login.html index 82dbf2c93f..0358b7087e 100644 --- a/allauth/templates/account/login.html +++ b/allauth/templates/account/login.html @@ -8,24 +8,31 @@ {% element h1 %} {% trans "Sign In" %} {% endelement %} -

- {% blocktrans %}If you have not created an account yet, then please - sign up first.{% endblocktrans %} -

- {% url 'account_login' as login_url %} - {% element form form=form method="post" action=login_url tags="entrance,login" %} - {% slot body %} - {% csrf_token %} - {% element fields form=form unlabeled=True %} - {% endelement %} - {{ redirect_field }} - {% endslot %} - {% slot actions %} - {% element button type="submit" tags="prominent,login" %} - {% trans "Sign In" %} - {% endelement %} - {% endslot %} - {% endelement %} + {% if not SOCIALACCOUNT_ONLY %} + {% setvar link %} + + {% endsetvar %} + {% setvar end_link %} + + {% endsetvar %} +

+ {% blocktranslate %}If you have not created an account yet, then please {{ link }}sign up{{ end_link }} first.{% endblocktranslate %} +

+ {% url 'account_login' as login_url %} + {% element form form=form method="post" action=login_url tags="entrance,login" %} + {% slot body %} + {% csrf_token %} + {% element fields form=form unlabeled=True %} + {% endelement %} + {{ redirect_field }} + {% endslot %} + {% slot actions %} + {% element button type="submit" tags="prominent,login" %} + {% trans "Sign In" %} + {% endelement %} + {% endslot %} + {% endelement %} + {% endif %} {% if LOGIN_BY_CODE_ENABLED %} {% element hr %} {% endelement %} diff --git a/allauth/templates/account/signup.html b/allauth/templates/account/signup.html index d59c2553b3..e3f1986855 100644 --- a/allauth/templates/account/signup.html +++ b/allauth/templates/account/signup.html @@ -7,23 +7,29 @@ {% element h1 %} {% trans "Sign Up" %} {% endelement %} -

- {% blocktrans %}Already have an account? Then please sign in.{% endblocktrans %} -

- {% url 'account_signup' as action_url %} - {% element form form=form method="post" action=action_url tags="entrance,signup" %} - {% slot body %} - {% csrf_token %} - {% element fields form=form unlabeled=True %} - {% endelement %} - {{ redirect_field }} - {% endslot %} - {% slot actions %} - {% element button tags="prominent,signup" type="submit" %} - {% trans "Sign Up" %} - {% endelement %} - {% endslot %} - {% endelement %} + {% setvar link %} + + {% endsetvar %} + {% setvar end_link %} + + {% endsetvar %} +

{% blocktranslate %}Already have an account? Then please {{ link }}sign in{{ end_link }}.{% endblocktranslate %}

+ {% if not SOCIALACCOUNT_ONLY %} + {% url 'account_signup' as action_url %} + {% element form form=form method="post" action=action_url tags="entrance,signup" %} + {% slot body %} + {% csrf_token %} + {% element fields form=form unlabeled=True %} + {% endelement %} + {{ redirect_field }} + {% endslot %} + {% slot actions %} + {% element button tags="prominent,signup" type="submit" %} + {% trans "Sign Up" %} + {% endelement %} + {% endslot %} + {% endelement %} + {% endif %} {% if SOCIALACCOUNT_ENABLED %} {% include "socialaccount/snippets/login.html" with page_layout="entrance" %} {% endif %} diff --git a/allauth/templates/socialaccount/snippets/login.html b/allauth/templates/socialaccount/snippets/login.html index 9ec904e8a2..cc26953261 100644 --- a/allauth/templates/socialaccount/snippets/login.html +++ b/allauth/templates/socialaccount/snippets/login.html @@ -3,11 +3,13 @@ {% load socialaccount %} {% get_providers as socialaccount_providers %} {% if socialaccount_providers %} - {% element hr %} - {% endelement %} - {% element h2 %} - {% translate "Or use a third-party" %} - {% endelement %} + {% if not SOCIALACCOUNT_ONLY %} + {% element hr %} + {% endelement %} + {% element h2 %} + {% translate "Or use a third-party" %} + {% endelement %} + {% endif %} {% include "socialaccount/snippets/provider_list.html" with process="login" %} {% include "socialaccount/snippets/login_extra.html" %} {% endif %} diff --git a/docs/socialaccount/configuration.rst b/docs/socialaccount/configuration.rst index da9e06e01c..69194d24fd 100644 --- a/docs/socialaccount/configuration.rst +++ b/docs/socialaccount/configuration.rst @@ -85,6 +85,11 @@ Available settings: ``SOCIALACCOUNT_STORE_TOKENS`` (default: ``False``) Indicates whether or not the access tokens are stored in the database. +``SOCIALACCOUNT_ONLY`` (default: ``False``) + When enabled (``True``), all functionality with regard to local accounts is + disabled, and users will only be able to authenticate using third-party + providers. + ``SOCIALACCOUNT_OPENID_CONNECT_URL_PREFIX`` (default: ``"oidc"``) The URL path prefix that is used for all OpenID Connect providers. By default, it is set to ``"oidc"``, meaning, an OpenID Connect provider with provider ID diff --git a/examples/regular-django/example/templates/allauth/layouts/base.html b/examples/regular-django/example/templates/allauth/layouts/base.html index 6cd3ee9697..a77734f585 100644 --- a/examples/regular-django/example/templates/allauth/layouts/base.html +++ b/examples/regular-django/example/templates/allauth/layouts/base.html @@ -40,11 +40,17 @@ {% if user.is_authenticated %} - Manage Account + {% url 'account_email' as email_url %} + {% url 'socialaccount_connections' as socialaccount_url %} + {% if email_url or socialaccount_url %} + Manage Account + {% endif %} Sign Out {% else %} Sign In - Sign Up + {% url 'account_signup' as signup_url %} + {% if signup_url %}Sign Up{% endif %} {% endif %} diff --git a/examples/regular-django/example/templates/allauth/layouts/manage.html b/examples/regular-django/example/templates/allauth/layouts/manage.html index e1db897405..5439b3c464 100644 --- a/examples/regular-django/example/templates/allauth/layouts/manage.html +++ b/examples/regular-django/example/templates/allauth/layouts/manage.html @@ -3,14 +3,20 @@