Skip to content

Commit

Permalink
refactor(account): Login methods
Browse files Browse the repository at this point in the history
pennersr committed Dec 26, 2024

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature. The key has expired.
1 parent 9ed67bc commit f25b22e
Showing 26 changed files with 179 additions and 149 deletions.
9 changes: 8 additions & 1 deletion ChangeLog.rst
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
65.4.0 (unreleased)
*******************

- ...
Note worthy changes
-------------------

- The setting ``ACCOUNT_AUTHENTICATION_METHOD: str`` (with values
``"username"``, ``"username_email"``, ``"email"``) has been replaced by
``ACCOUNT_LOGIN_METHODS: set[str]``. which is a set of values including
``"username"`` or ``"email"``. This is change is performed in a backwards
compatible manner.


65.3.1 (2025-12-25)
5 changes: 1 addition & 4 deletions allauth/account/adapter.py
Original file line number Diff line number Diff line change
@@ -112,10 +112,7 @@ def can_delete_email(self, email_address) -> bool:
.exclude(pk=email_address.pk)
.exists()
)
login_by_email = (
app_settings.AUTHENTICATION_METHOD
== app_settings.AuthenticationMethod.EMAIL
)
login_by_email = app_settings.LOGIN_METHODS == {app_settings.LoginMethod.EMAIL}
if email_address.primary:
if has_other:
# Don't allow, let the user mark one of the others as primary
20 changes: 16 additions & 4 deletions allauth/account/app_settings.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from enum import Enum
from typing import Set, Union
from typing import FrozenSet, Set, Union


class AppSettings:
@@ -8,6 +8,10 @@ class AuthenticationMethod(str, Enum):
EMAIL = "email"
USERNAME_EMAIL = "username_email"

class LoginMethod(str, Enum):
USERNAME = "username"
EMAIL = "email"

class EmailVerificationMethod(str, Enum):
# After signing up, keep the user account inactive until the email
# address is verified
@@ -108,9 +112,17 @@ def CHANGE_EMAIL(self):
return self._setting("CHANGE_EMAIL", False)

@property
def AUTHENTICATION_METHOD(self):
ret = self._setting("AUTHENTICATION_METHOD", self.AuthenticationMethod.USERNAME)
return self.AuthenticationMethod(ret)
def LOGIN_METHODS(self) -> FrozenSet[LoginMethod]:
methods = self._setting("LOGIN_METHODS", None)
if methods is None:
auth_method = self._setting(
"AUTHENTICATION_METHOD", self.AuthenticationMethod.USERNAME
)
if auth_method == self.AuthenticationMethod.USERNAME_EMAIL:
methods = {self.LoginMethod.EMAIL, self.LoginMethod.USERNAME}
else:
methods = {self.LoginMethod(auth_method)}
return frozenset([self.LoginMethod(m) for m in methods])

@property
def EMAIL_MAX_LENGTH(self):
7 changes: 4 additions & 3 deletions allauth/account/auth_backends.py
Original file line number Diff line number Diff line change
@@ -3,8 +3,9 @@
from django.contrib.auth import get_user_model
from django.contrib.auth.backends import ModelBackend

from allauth.account.app_settings import LoginMethod

from . import app_settings
from .app_settings import AuthenticationMethod
from .utils import filter_users_by_email, filter_users_by_username


@@ -14,9 +15,9 @@
class AuthenticationBackend(ModelBackend):
def authenticate(self, request, **credentials):
ret = None
if app_settings.AUTHENTICATION_METHOD == AuthenticationMethod.EMAIL:
if app_settings.LOGIN_METHODS == {LoginMethod.EMAIL}:
ret = self._authenticate_by_email(**credentials)
elif app_settings.AUTHENTICATION_METHOD == AuthenticationMethod.USERNAME_EMAIL:
elif app_settings.LOGIN_METHODS == {LoginMethod.USERNAME, LoginMethod.EMAIL}:
ret = self._authenticate_by_email(
**credentials, time_attack_mitigation=False
)
26 changes: 14 additions & 12 deletions allauth/account/checks.py
Original file line number Diff line number Diff line change
@@ -53,24 +53,22 @@ def settings_check(app_configs, **kwargs):
)
# If login is by email, email must be required
if (
app_settings.AUTHENTICATION_METHOD == app_settings.AuthenticationMethod.EMAIL
app_settings.LOGIN_METHODS == {app_settings.LoginMethod.EMAIL}
and not app_settings.EMAIL_REQUIRED
):
ret.append(
Critical(
msg="ACCOUNT_AUTHENTICATION_METHOD = 'email' requires ACCOUNT_EMAIL_REQUIRED = True"
msg="ACCOUNT_LOGIN_METHODS = {'email'} requires ACCOUNT_EMAIL_REQUIRED = True"
)
)

# If login includes email, login must be unique
# If login includes email, email must be unique
if (
app_settings.AUTHENTICATION_METHOD != app_settings.AuthenticationMethod.USERNAME
app_settings.LoginMethod.EMAIL in app_settings.LOGIN_METHODS
and not app_settings.UNIQUE_EMAIL
):
ret.append(
Critical(
msg="If ACCOUNT_AUTHENTICATION_METHOD is email based, ACCOUNT_UNIQUE_EMAIL = True is required"
)
Critical(msg="Using email as a login method requires ACCOUNT_UNIQUE_EMAIL")
)

# Mandatory email verification requires email
@@ -93,13 +91,10 @@ def settings_check(app_configs, **kwargs):
)
)

if app_settings.AUTHENTICATION_METHOD in (
app_settings.AuthenticationMethod.USERNAME,
app_settings.AuthenticationMethod.USERNAME_EMAIL,
):
if app_settings.LoginMethod.USERNAME in app_settings.LOGIN_METHODS:
ret.append(
Critical(
msg="No ACCOUNT_USER_MODEL_USERNAME_FIELD, yet, ACCOUNT_AUTHENTICATION_METHOD requires it"
msg="No ACCOUNT_USER_MODEL_USERNAME_FIELD, yet, ACCOUNT_LOGIN_METHODS requires it"
)
)

@@ -135,4 +130,11 @@ def settings_check(app_configs, **kwargs):
)
)

if hasattr(settings, "ACCOUNT_AUTHENTICATION_METHOD"):
converted = set(settings.ACCOUNT_AUTHENTICATION_METHOD.split("_"))
ret.append(
Warning(
f"settings.ACCOUNT_AUTHENTICATION_METHOD is deprecated, use: settings.ACCOUNT_LOGIN_METHODS = {repr(converted)}"
)
)
return ret
45 changes: 16 additions & 29 deletions allauth/account/forms.py
Original file line number Diff line number Diff line change
@@ -8,6 +8,7 @@
from django.utils.safestring import mark_safe
from django.utils.translation import gettext, gettext_lazy as _, pgettext

from allauth.account.app_settings import LoginMethod
from allauth.account.internal import flows
from allauth.account.internal.stagekit import LOGIN_SESSION_KEY
from allauth.account.stages import EmailVerificationStage
@@ -16,7 +17,6 @@

from . import app_settings
from .adapter import DefaultAccountAdapter, get_adapter
from .app_settings import AuthenticationMethod
from .models import EmailAddress, Login
from .utils import (
filter_users_by_email,
@@ -96,15 +96,15 @@ class LoginForm(forms.Form):
def __init__(self, *args, **kwargs):
self.request = kwargs.pop("request", None)
super(LoginForm, self).__init__(*args, **kwargs)
if app_settings.AUTHENTICATION_METHOD == AuthenticationMethod.EMAIL:
if app_settings.LOGIN_METHODS == {LoginMethod.EMAIL}:
login_widget = forms.EmailInput(
attrs={
"placeholder": _("Email address"),
"autocomplete": "email",
}
)
login_field = forms.EmailField(label=_("Email"), widget=login_widget)
elif app_settings.AUTHENTICATION_METHOD == AuthenticationMethod.USERNAME:
elif app_settings.LOGIN_METHODS == {LoginMethod.USERNAME}:
login_widget = forms.TextInput(
attrs={"placeholder": _("Username"), "autocomplete": "username"}
)
@@ -114,10 +114,10 @@ def __init__(self, *args, **kwargs):
max_length=get_username_max_length(),
)
else:
assert (
app_settings.AUTHENTICATION_METHOD
== AuthenticationMethod.USERNAME_EMAIL
) # nosec
assert app_settings.LOGIN_METHODS == {
LoginMethod.USERNAME,
LoginMethod.EMAIL,
} # nosec
login_widget = forms.TextInput(
attrs={"placeholder": _("Username or email"), "autocomplete": "email"}
)
@@ -145,29 +145,20 @@ def user_credentials(self):
"""
credentials = {}
login = self.cleaned_data["login"]
if app_settings.AUTHENTICATION_METHOD == AuthenticationMethod.EMAIL:
method = flows.login.derive_login_method(login)
if method == LoginMethod.EMAIL:
credentials["email"] = login
elif app_settings.AUTHENTICATION_METHOD == AuthenticationMethod.USERNAME:
elif method == LoginMethod.USERNAME:
credentials["username"] = login
else:
if self._is_login_email(login):
credentials["email"] = login
credentials["username"] = login
raise NotImplementedError()
credentials["password"] = self.cleaned_data["password"]
return credentials

def clean_login(self):
login = self.cleaned_data["login"]
return login.strip()

def _is_login_email(self, login):
try:
validators.validate_email(login)
ret = True
except exceptions.ValidationError:
ret = False
return ret

def clean(self):
super(LoginForm, self).clean()
if self._errors:
@@ -182,14 +173,10 @@ def clean(self):
self._login = login
self.user = user
else:
auth_method = app_settings.AUTHENTICATION_METHOD
if auth_method == app_settings.AuthenticationMethod.USERNAME_EMAIL:
login = self.cleaned_data["login"]
if self._is_login_email(login):
auth_method = app_settings.AuthenticationMethod.EMAIL
else:
auth_method = app_settings.AuthenticationMethod.USERNAME
raise adapter.validation_error("%s_password_mismatch" % auth_method.value)
login_method = flows.login.derive_login_method(
login=self.cleaned_data["login"]
)
raise adapter.validation_error("%s_password_mismatch" % login_method.value)
return self.cleaned_data

def login(self, request, redirect_url=None):
@@ -628,7 +615,7 @@ def save(self, request, **kwargs) -> str:
"request": request,
}

if app_settings.AUTHENTICATION_METHOD != AuthenticationMethod.EMAIL:
if LoginMethod.USERNAME in app_settings.LOGIN_METHODS:
context["username"] = user_username(user)
adapter.send_password_reset_mail(user, email, context)
return email
15 changes: 15 additions & 0 deletions allauth/account/internal/flows/login.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import time
from typing import Any, Dict

from django.core import exceptions, validators
from django.http import HttpRequest, HttpResponse

from allauth import app_settings as allauth_settings
from allauth.account import app_settings
from allauth.account.adapter import get_adapter
from allauth.account.app_settings import LoginMethod
from allauth.account.models import Login
from allauth.core.exceptions import ImmediateHttpResponse

@@ -115,3 +118,15 @@ def is_login_rate_limited(request, login: Login) -> bool:
if is_verification_rate_limited(request, login):
return True
return False


def derive_login_method(login: str) -> LoginMethod:
if len(app_settings.LOGIN_METHODS) == 1:
return next(iter(app_settings.LOGIN_METHODS))
if LoginMethod.EMAIL in app_settings.LOGIN_METHODS:
try:
validators.validate_email(login)
return LoginMethod.EMAIL
except exceptions.ValidationError:
pass
return LoginMethod.USERNAME
23 changes: 12 additions & 11 deletions allauth/account/tests/test_auth_backends.py
Original file line number Diff line number Diff line change
@@ -20,7 +20,7 @@ def setUp(self):
self.user = user

@override_settings(
ACCOUNT_AUTHENTICATION_METHOD=app_settings.AuthenticationMethod.USERNAME
ACCOUNT_LOGIN_METHODS={app_settings.LoginMethod.USERNAME}
) # noqa
def test_auth_by_username(self):
user = self.user
@@ -38,9 +38,7 @@ def test_auth_by_username(self):
None,
)

@override_settings(
ACCOUNT_AUTHENTICATION_METHOD=app_settings.AuthenticationMethod.EMAIL
) # noqa
@override_settings(ACCOUNT_LOGIN_METHODS={app_settings.LoginMethod.EMAIL}) # noqa
def test_auth_by_email(self):
user = self.user
backend = AuthenticationBackend()
@@ -58,7 +56,10 @@ def test_auth_by_email(self):
)

@override_settings(
ACCOUNT_AUTHENTICATION_METHOD=app_settings.AuthenticationMethod.USERNAME_EMAIL
ACCOUNT_LOGIN_METHODS={
app_settings.LoginMethod.EMAIL,
app_settings.LoginMethod.USERNAME,
}
) # noqa
def test_auth_by_username_or_email(self):
user = self.user
@@ -78,15 +79,15 @@ def test_auth_by_username_or_email(self):


@pytest.mark.parametrize(
"auth_method",
"login_methods",
[
app_settings.AuthenticationMethod.EMAIL,
app_settings.AuthenticationMethod.USERNAME,
app_settings.AuthenticationMethod.USERNAME_EMAIL,
{app_settings.LoginMethod.EMAIL},
{app_settings.LoginMethod.USERNAME},
{app_settings.LoginMethod.USERNAME, app_settings.LoginMethod.EMAIL},
],
)
def test_account_enumeration_timing_attack(user, db, rf, settings, auth_method):
settings.ACCOUNT_AUTHENTICATION_METHOD = auth_method
def test_account_enumeration_timing_attack(user, db, rf, settings, login_methods):
settings.ACCOUNT_LOGIN_METHODS = login_methods
with patch("django.contrib.auth.models.User.set_password") as set_password_mock:
with patch(
"django.contrib.auth.models.User.check_password", new=set_password_mock
Loading

0 comments on commit f25b22e

Please sign in to comment.