Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(socialaccount): SOCIALACCOUNT_ONLY #3739

Merged
merged 1 commit into from
Apr 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions ChangeLog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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)
*******************
Expand Down
2 changes: 2 additions & 0 deletions allauth/account/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
27 changes: 27 additions & 0 deletions allauth/account/checks.py
Original file line number Diff line number Diff line change
@@ -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
85 changes: 48 additions & 37 deletions allauth/account/urls.py
Original file line number Diff line number Diff line change
@@ -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<key>[-:\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<uidb36>[0-9A-Za-z]+)-(?P<key>.+)/$",
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<key>[-:\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<uidb36>[0-9A-Za-z]+)-(?P<key>.+)/$",
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(
[
Expand Down
8 changes: 7 additions & 1 deletion allauth/account/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -98,14 +100,17 @@ 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(
{
"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,
}
)
Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions allauth/app_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
26 changes: 9 additions & 17 deletions allauth/socialaccount/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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):
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion allauth/socialaccount/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
28 changes: 28 additions & 0 deletions allauth/socialaccount/internal/flows/connect.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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)
Expand Down
43 changes: 25 additions & 18 deletions allauth/templates/account/login.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,24 +8,31 @@
{% element h1 %}
{% trans "Sign In" %}
{% endelement %}
<p>
{% blocktrans %}If you have not created an account yet, then please
<a href="{{ signup_url }}">sign up</a> first.{% endblocktrans %}
</p>
{% 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 %}
<a href="{{ signup_url }}">
{% endsetvar %}
{% setvar end_link %}
</a>
{% endsetvar %}
<p>
{% blocktranslate %}If you have not created an account yet, then please {{ link }}sign up{{ end_link }} first.{% endblocktranslate %}
</p>
{% 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 %}
Expand Down
Loading