diff --git a/ChangeLog.rst b/ChangeLog.rst
index bad34f59d2..6e2062826c 100644
--- a/ChangeLog.rst
+++ b/ChangeLog.rst
@@ -4,6 +4,10 @@
- Verifying email addresses by means of a code (instead of a link) is now supported.
See ``settings.ACCOUNT_EMAIL_VERIFICATION_BY_CODE_ENABLED``.
+- Added support for requiring logging in by code, so that every user logging in
+ is required to input a login confirmation code sent by email. See
+ ``settings.ACCOUNT_LOGIN_BY_CODE_REQUIRED``.
+
64.1.0 (2024-08-15)
*******************
diff --git a/allauth/account/adapter.py b/allauth/account/adapter.py
index 0277e1bddc..54291267f3 100644
--- a/allauth/account/adapter.py
+++ b/allauth/account/adapter.py
@@ -782,6 +782,26 @@ def _generate_code(self):
allowed_chars = allowed_chars.replace(ch, "")
return get_random_string(length=6, allowed_chars=allowed_chars)
+ def is_login_by_code_required(self, login) -> bool:
+ """
+ Returns whether or not login-by-code is required for the given
+ login.
+ """
+ from allauth.account import authentication
+
+ method = None
+ records = authentication.get_authentication_records(self.request)
+ if records:
+ method = records[-1]["method"]
+ if method == "code":
+ return False
+ value = app_settings.LOGIN_BY_CODE_REQUIRED
+ if isinstance(value, bool):
+ return value
+ if not value:
+ return False
+ return method is None or method in value
+
def get_adapter(request=None):
return import_attribute(app_settings.ADAPTER)(request)
diff --git a/allauth/account/app_settings.py b/allauth/account/app_settings.py
index e4cbe6f4fe..a28bbd1549 100644
--- a/allauth/account/app_settings.py
+++ b/allauth/account/app_settings.py
@@ -1,5 +1,6 @@
import warnings
from enum import Enum
+from typing import Set, Union
from django.core.exceptions import ImproperlyConfigured
@@ -450,6 +451,20 @@ def LOGIN_TIMEOUT(self):
"""
return self._setting("LOGIN_TIMEOUT", 15 * 60)
+ @property
+ def LOGIN_BY_CODE_REQUIRED(self) -> Union[bool, Set[str]]:
+ """
+ When enabled (in case of ``True``), every user logging in is
+ required to input a login confirmation code sent by email.
+ Alternatively, you can specify a set of authentication methods
+ (``"password"``, ``"mfa"``, or ``"socialaccount"``) for which login
+ codes are required.
+ """
+ value = self._setting("LOGIN_BY_CODE_REQUIRED", False)
+ if isinstance(value, bool):
+ return value
+ return set(value)
+
_app_settings = AppSettings("ACCOUNT_")
diff --git a/allauth/account/authentication.py b/allauth/account/authentication.py
index 6fe6d4c43b..6d99bea07c 100644
--- a/allauth/account/authentication.py
+++ b/allauth/account/authentication.py
@@ -1,43 +1,6 @@
-import time
-
-
-AUTHENTICATION_METHODS_SESSION_KEY = "account_authentication_methods"
-
-
-def record_authentication(request, method, **extra_data):
- """Here we keep a log of all authentication methods used within the current
- session. Important to note is that having entries here does not imply that
- a user is fully signed in. For example, consider a case where a user
- authenticates using a password, but fails to complete the 2FA challenge.
- Or, a user successfully signs in into an inactive account or one that still
- needs verification. In such cases, ``request.user`` is still anonymous, yet,
- we do have an entry here.
-
- Example data::
-
- {'method': 'password',
- 'at': 1701423602.7184925,
- 'username': 'john.doe'}
-
- {'method': 'socialaccount',
- 'at': 1701423567.6368647,
- 'provider': 'amazon',
- 'uid': 'amzn1.account.K2LI23KL2LK2'}
-
- {'method': 'mfa',
- 'at': 1701423602.6392953,
- 'id': 1,
- 'type': 'totp'}
-
- """
- methods = request.session.get(AUTHENTICATION_METHODS_SESSION_KEY, [])
- data = {
- "method": method,
- "at": time.time(),
- **extra_data,
- }
- methods.append(data)
- request.session[AUTHENTICATION_METHODS_SESSION_KEY] = methods
+from allauth.account.internal.flows.login import (
+ AUTHENTICATION_METHODS_SESSION_KEY,
+)
def get_authentication_records(request):
diff --git a/allauth/account/internal/flows/email_verification.py b/allauth/account/internal/flows/email_verification.py
index 2eb0a3b28f..5282cc3b47 100644
--- a/allauth/account/internal/flows/email_verification.py
+++ b/allauth/account/internal/flows/email_verification.py
@@ -9,6 +9,17 @@
from allauth.utils import build_absolute_uri
+def verify_email_indirectly(request: HttpRequest, user, email: str) -> bool:
+ try:
+ email_address = EmailAddress.objects.get_for_user(user, email)
+ except EmailAddress.DoesNotExist:
+ return False
+ else:
+ if not email_address.verified:
+ return verify_email(request, email_address)
+ return True
+
+
def verify_email(request: HttpRequest, email_address: EmailAddress) -> bool:
"""
Marks the email address as confirmed on the db
diff --git a/allauth/account/internal/flows/login.py b/allauth/account/internal/flows/login.py
index cd9ba3608a..311340ad26 100644
--- a/allauth/account/internal/flows/login.py
+++ b/allauth/account/internal/flows/login.py
@@ -1,14 +1,51 @@
+import time
from typing import Any, Dict
from django.http import HttpRequest, HttpResponse
from allauth.account.adapter import get_adapter
-from allauth.account.authentication import record_authentication
from allauth.account.models import Login
from allauth.core.exceptions import ImmediateHttpResponse
LOGIN_SESSION_KEY = "account_login"
+AUTHENTICATION_METHODS_SESSION_KEY = "account_authentication_methods"
+
+
+def record_authentication(request, method, **extra_data):
+ """Here we keep a log of all authentication methods used within the current
+ session. Important to note is that having entries here does not imply that
+ a user is fully signed in. For example, consider a case where a user
+ authenticates using a password, but fails to complete the 2FA challenge.
+ Or, a user successfully signs in into an inactive account or one that still
+ needs verification. In such cases, ``request.user`` is still anonymous, yet,
+ we do have an entry here.
+
+ Example data::
+
+ {'method': 'password',
+ 'at': 1701423602.7184925,
+ 'username': 'john.doe'}
+
+ {'method': 'socialaccount',
+ 'at': 1701423567.6368647,
+ 'provider': 'amazon',
+ 'uid': 'amzn1.account.K2LI23KL2LK2'}
+
+ {'method': 'mfa',
+ 'at': 1701423602.6392953,
+ 'id': 1,
+ 'type': 'totp'}
+
+ """
+ methods = request.session.get(AUTHENTICATION_METHODS_SESSION_KEY, [])
+ data = {
+ "method": method,
+ "at": time.time(),
+ **extra_data,
+ }
+ methods.append(data)
+ request.session[AUTHENTICATION_METHODS_SESSION_KEY] = methods
def _get_login_hook_kwargs(login: Login) -> Dict[str, Any]:
diff --git a/allauth/account/internal/flows/login_by_code.py b/allauth/account/internal/flows/login_by_code.py
index a8d7e09797..ef3f59723f 100644
--- a/allauth/account/internal/flows/login_by_code.py
+++ b/allauth/account/internal/flows/login_by_code.py
@@ -8,8 +8,13 @@
from allauth.account import app_settings
from allauth.account.adapter import get_adapter
-from allauth.account.authentication import record_authentication
-from allauth.account.internal.flows.login import perform_login
+from allauth.account.internal.flows.email_verification import (
+ verify_email_indirectly,
+)
+from allauth.account.internal.flows.login import (
+ perform_login,
+ record_authentication,
+)
from allauth.account.internal.flows.signup import send_unknown_account_mail
from allauth.account.models import Login
@@ -17,15 +22,19 @@
LOGIN_CODE_STATE_KEY = "login_code"
-def request_login_code(request: HttpRequest, email: str) -> None:
+def request_login_code(
+ request: HttpRequest, email: str, login: Optional[Login] = None
+) -> None:
from allauth.account.utils import filter_users_by_email, stash_login
+ initiated_by_user = login is None
adapter = get_adapter()
users = filter_users_by_email(email, is_active=True, prefer_verified=True)
pending_login = {
"at": time.time(),
"email": email,
"failed_attempts": 0,
+ "initiated_by_user": initiated_by_user,
}
if not users:
user = None
@@ -41,16 +50,19 @@ def request_login_code(request: HttpRequest, email: str) -> None:
pending_login.update(
{"code": code, "user_id": user._meta.pk.value_to_string(user)}
)
- login = Login(user=user, email=email)
- login.state[LOGIN_CODE_STATE_KEY] = pending_login
- login.state["stages"] = {"current": "login_by_code"}
adapter.add_message(
request,
messages.SUCCESS,
"account/messages/login_code_sent.txt",
{"email": email},
)
- stash_login(request, login)
+ if initiated_by_user:
+ login = Login(user=user, email=email)
+ login.state["stages"] = {"current": "login_by_code"}
+ assert login
+ login.state[LOGIN_CODE_STATE_KEY] = pending_login
+ if initiated_by_user:
+ stash_login(request, login)
def get_pending_login(
@@ -94,18 +106,23 @@ def perform_login_by_code(
stage,
redirect_url: Optional[str],
):
- state = stage.login.state.pop(LOGIN_CODE_STATE_KEY)
+ state = stage.login.state[LOGIN_CODE_STATE_KEY]
email = state["email"]
+ user = stage.login.user
record_authentication(request, method="code", email=email)
- # Just requesting a login code does is not considered to be a real login,
- # yet, is needed in order to make the stage machinery work. Now that we've
- # completed the code, let's start a real login.
- login = Login(
- user=stage.login.user,
- redirect_url=redirect_url,
- email=email,
- )
- return perform_login(request, login)
+ verify_email_indirectly(request, user, email)
+ if state["initiated_by_user"]:
+ # Just requesting a login code does is not considered to be a real login,
+ # yet, is needed in order to make the stage machinery work. Now that we've
+ # completed the code, let's start a real login.
+ login = Login(
+ user=user,
+ redirect_url=redirect_url,
+ email=email,
+ )
+ return perform_login(request, login)
+ else:
+ return stage.exit()
def compare_code(*, actual, expected) -> bool:
diff --git a/allauth/account/internal/flows/reauthentication.py b/allauth/account/internal/flows/reauthentication.py
index 4c4e731d52..00cdcb84a0 100644
--- a/allauth/account/internal/flows/reauthentication.py
+++ b/allauth/account/internal/flows/reauthentication.py
@@ -8,10 +8,8 @@
from allauth import app_settings as allauth_settings
from allauth.account import app_settings
-from allauth.account.authentication import (
- get_authentication_records,
- record_authentication,
-)
+from allauth.account.authentication import get_authentication_records
+from allauth.account.internal.flows.login import record_authentication
from allauth.core.exceptions import ReauthenticationRequired
from allauth.core.internal.httpkit import (
deserialize_request,
diff --git a/allauth/account/managers.py b/allauth/account/managers.py
index e5bb863710..60d76ce791 100644
--- a/allauth/account/managers.py
+++ b/allauth/account/managers.py
@@ -1,4 +1,5 @@
from datetime import timedelta
+from typing import Optional
from django.db import models
from django.db.models import Q
@@ -69,7 +70,7 @@ def get_primary(self, user):
except self.model.DoesNotExist:
return None
- def get_primary_email(self, user):
+ def get_primary_email(self, user) -> Optional[str]:
from allauth.account.utils import user_email
primary = self.get_primary(user)
diff --git a/allauth/account/stages.py b/allauth/account/stages.py
index e5c7a1300c..dde54ad981 100644
--- a/allauth/account/stages.py
+++ b/allauth/account/stages.py
@@ -3,7 +3,9 @@
from allauth.account.adapter import get_adapter
from allauth.account.app_settings import EmailVerificationMethod
+from allauth.account.models import EmailAddress
from allauth.account.utils import resume_login, stash_login, unstash_login
+from allauth.core.internal.httpkit import headed_redirect_response
from allauth.utils import import_callable
@@ -141,8 +143,17 @@ def handle(self):
from allauth.account.internal.flows import login_by_code
user, data = login_by_code.get_pending_login(self.login, peek=True)
- if data is None:
+ login_by_code_required = get_adapter().is_login_by_code_required(self.login)
+ if data is None and not login_by_code_required:
# No pending login, just continue.
return None, True
- response = HttpResponseRedirect(reverse("account_confirm_login_code"))
+ elif data is None and login_by_code_required:
+ email = EmailAddress.objects.get_primary_email(self.login.user)
+ if not email:
+ # No way of contacting the user.. cannot meet the
+ # requirements. Abort.
+ return headed_redirect_response("account_login"), False
+ login_by_code.request_login_code(self.request, email, login=self.login)
+
+ response = headed_redirect_response("account_confirm_login_code")
return response, True
diff --git a/allauth/account/tests/test_login_by_code.py b/allauth/account/tests/test_login_by_code.py
index d4be1c1db7..1b51f93bee 100644
--- a/allauth/account/tests/test_login_by_code.py
+++ b/allauth/account/tests/test_login_by_code.py
@@ -7,6 +7,7 @@
from allauth.account.authentication import AUTHENTICATION_METHODS_SESSION_KEY
from allauth.account.internal.flows.login import LOGIN_SESSION_KEY
from allauth.account.internal.flows.login_by_code import LOGIN_CODE_STATE_KEY
+from allauth.account.models import EmailAddress
@pytest.fixture
@@ -73,3 +74,38 @@ def test_login_by_code_unknown_user(mailoutbox, client, db):
assert resp.status_code == 302
assert resp["location"] == reverse("account_confirm_login_code")
resp = client.post(reverse("account_confirm_login_code"), data={"code": "123456"})
+
+
+@pytest.mark.parametrize(
+ "setting,code_required",
+ [
+ (True, True),
+ ({"password"}, True),
+ ({"socialaccount"}, False),
+ ],
+)
+def test_login_by_code_required(
+ client, settings, user_factory, password_factory, setting, code_required
+):
+ password = password_factory()
+ user = user_factory(password=password, email_verified=False)
+ email_address = EmailAddress.objects.get(email=user.email)
+ assert not email_address.verified
+ settings.ACCOUNT_LOGIN_BY_CODE_REQUIRED = setting
+ resp = client.post(
+ reverse("account_login"),
+ data={"login": user.username, "password": password},
+ )
+ assert resp.status_code == 302
+ if code_required:
+ assert resp["location"] == reverse("account_confirm_login_code")
+ code = client.session[LOGIN_SESSION_KEY]["state"][LOGIN_CODE_STATE_KEY]["code"]
+ resp = client.get(
+ reverse("account_confirm_login_code"),
+ data={"login": user.username, "password": password},
+ )
+ assert resp.status_code == 200
+ resp = client.post(reverse("account_confirm_login_code"), data={"code": code})
+ email_address.refresh_from_db()
+ assert email_address.verified
+ assert resp["location"] == settings.LOGIN_REDIRECT_URL
diff --git a/allauth/account/urls.py b/allauth/account/urls.py
index 57d6b9b5c1..c83440bcb1 100644
--- a/allauth/account/urls.py
+++ b/allauth/account/urls.py
@@ -56,6 +56,11 @@
views.password_reset_from_key_done,
name="account_reset_password_from_key_done",
),
+ path(
+ "login/code/confirm/",
+ views.confirm_login_code,
+ name="account_confirm_login_code",
+ ),
]
)
@@ -67,10 +72,5 @@
views.request_login_code,
name="account_request_login_code",
),
- path(
- "login/code/confirm/",
- views.confirm_login_code,
- name="account_confirm_login_code",
- ),
]
)
diff --git a/allauth/account/views.py b/allauth/account/views.py
index cdbb115c5c..ac1a469d4b 100644
--- a/allauth/account/views.py
+++ b/allauth/account/views.py
@@ -997,7 +997,13 @@ def form_invalid(self, form):
messages.ERROR,
message=adapter.error_messages["too_many_login_attempts"],
)
- return HttpResponseRedirect(reverse("account_request_login_code"))
+ return HttpResponseRedirect(
+ reverse(
+ "account_request_login_code"
+ if self.pending_login["initiated_by_user"]
+ else "account_login"
+ )
+ )
def get_context_data(self, **kwargs):
ret = super().get_context_data(**kwargs)
diff --git a/allauth/headless/account/tests/test_login_by_code.py b/allauth/headless/account/tests/test_login_by_code.py
index d2f8e6d7e7..a19232ccbd 100644
--- a/allauth/headless/account/tests/test_login_by_code.py
+++ b/allauth/headless/account/tests/test_login_by_code.py
@@ -1,3 +1,4 @@
+from allauth.account.models import EmailAddress
from allauth.headless.constants import Flow
@@ -77,3 +78,37 @@ def test_login_by_code_max_attemps(headless_reverse, user, client, settings):
else:
assert resp.status_code == 400
assert len(pending_flows) == 1
+
+
+def test_login_by_code_required(
+ client, settings, user_factory, password_factory, headless_reverse, mailoutbox
+):
+ settings.ACCOUNT_LOGIN_BY_CODE_REQUIRED = True
+ password = password_factory()
+ user = user_factory(password=password, email_verified=False)
+ email_address = EmailAddress.objects.get(email=user.email)
+ assert not email_address.verified
+ resp = client.post(
+ headless_reverse("headless:account:login"),
+ data={
+ "username": user.username,
+ "password": password,
+ },
+ content_type="application/json",
+ )
+ assert resp.status_code == 401
+ pending_flow = [f for f in resp.json()["data"]["flows"] if f.get("is_pending")][0][
+ "id"
+ ]
+ assert pending_flow == Flow.LOGIN_BY_CODE
+ code = [line for line in mailoutbox[0].body.splitlines() if len(line) == 6][0]
+ resp = client.post(
+ headless_reverse("headless:account:confirm_login_code"),
+ data={"code": code},
+ content_type="application/json",
+ )
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["meta"]["is_authenticated"]
+ email_address.refresh_from_db()
+ assert email_address.verified
diff --git a/allauth/headless/account/urls.py b/allauth/headless/account/urls.py
index 6b1dbf5f27..6179208a03 100644
--- a/allauth/headless/account/urls.py
+++ b/allauth/headless/account/urls.py
@@ -18,6 +18,11 @@ def build_urlpatterns(client):
views.ReauthenticateView.as_api_view(client=client),
name="reauthenticate",
),
+ path(
+ "code/confirm",
+ views.ConfirmLoginCodeView.as_api_view(client=client),
+ name="confirm_login_code",
+ ),
]
if not allauth_settings.SOCIALACCOUNT_ONLY:
account_patterns.extend(
@@ -80,11 +85,6 @@ def build_urlpatterns(client):
views.RequestLoginCodeView.as_api_view(client=client),
name="request_login_code",
),
- path(
- "code/confirm",
- views.ConfirmLoginCodeView.as_api_view(client=client),
- name="confirm_login_code",
- ),
]
)
return [
diff --git a/allauth/headless/base/response.py b/allauth/headless/base/response.py
index 88baf0650b..50144184cd 100644
--- a/allauth/headless/base/response.py
+++ b/allauth/headless/base/response.py
@@ -120,6 +120,7 @@ def get_config_data(request):
"authentication_method": account_settings.AUTHENTICATION_METHOD,
"is_open_for_signup": get_account_adapter().is_open_for_signup(request),
"email_verification_by_code_enabled": account_settings.EMAIL_VERIFICATION_BY_CODE_ENABLED,
+ "login_by_code_enabled": account_settings.LOGIN_BY_CODE_ENABLED,
}
return {"account": data}
diff --git a/allauth/mfa/base/internal/flows.py b/allauth/mfa/base/internal/flows.py
index 31ebfb775e..29e3c27dca 100644
--- a/allauth/mfa/base/internal/flows.py
+++ b/allauth/mfa/base/internal/flows.py
@@ -1,7 +1,7 @@
from typing import Callable, Optional
from allauth.account.adapter import get_adapter as get_account_adapter
-from allauth.account.authentication import record_authentication
+from allauth.account.internal.flows.login import record_authentication
from allauth.core import context, ratelimit
from allauth.mfa import signals
from allauth.mfa.models import Authenticator
diff --git a/allauth/socialaccount/internal/flows/login.py b/allauth/socialaccount/internal/flows/login.py
index 213e9d46d4..25a09be94a 100644
--- a/allauth/socialaccount/internal/flows/login.py
+++ b/allauth/socialaccount/internal/flows/login.py
@@ -1,7 +1,7 @@
from django.http import HttpResponseRedirect
from django.shortcuts import render
-from allauth.account import app_settings as account_settings, authentication
+from allauth.account import app_settings as account_settings
from allauth.account.adapter import get_adapter as get_account_adapter
from allauth.account.utils import perform_login
from allauth.core.exceptions import (
@@ -85,7 +85,9 @@ def _authenticate(request, sociallogin):
def record_authentication(request, sociallogin):
- authentication.record_authentication(
+ from allauth.account.internal.flows.login import record_authentication
+
+ record_authentication(
request,
"socialaccount",
**{
diff --git a/allauth/socialaccount/tests/test_signup.py b/allauth/socialaccount/tests/test_signup.py
index 80b63f625b..56ce2d9aa8 100644
--- a/allauth/socialaccount/tests/test_signup.py
+++ b/allauth/socialaccount/tests/test_signup.py
@@ -22,12 +22,13 @@ def f(client, sociallogin):
request = request_factory.get("/")
request.user = AnonymousUser()
- resp = complete_social_login(request, sociallogin)
- session = client.session
- for k, v in request.session.items():
- session[k] = v
- session.save()
- return resp
+ with context.request_context(request):
+ resp = complete_social_login(request, sociallogin)
+ session = client.session
+ for k, v in request.session.items():
+ session[k] = v
+ session.save()
+ return resp
return f
diff --git a/docs/account/configuration.rst b/docs/account/configuration.rst
index f8016ed45e..40f11d6d23 100644
--- a/docs/account/configuration.rst
+++ b/docs/account/configuration.rst
@@ -146,6 +146,12 @@ Available settings:
This setting controls the maximum number of attempts the user has at inputting
a valid code.
+``ACCOUNT_LOGIN_BY_CODE_REQUIRED`` (default: ``False``)
+ When enabled (in case of ``True``), every user logging in is required to input
+ a login confirmation code sent by email. Alternatively, you can specify a set
+ of authentication methods (``"password"``, ``"mfa"``, or ``"socialaccount"``)
+ for which login codes are required.
+
``ACCOUNT_LOGIN_BY_CODE_TIMEOUT`` (default: ``180``)
The code that is emailed has a limited life span. It expires this many seconds after
which it was sent.
diff --git a/docs/headless/openapi-specification/openapi.yaml b/docs/headless/openapi-specification/openapi.yaml
index 4cfdf28003..05e9881631 100644
--- a/docs/headless/openapi-specification/openapi.yaml
+++ b/docs/headless/openapi-specification/openapi.yaml
@@ -1707,10 +1707,13 @@ components:
type: boolean
email_verification_by_code_enabled:
type: boolean
+ login_by_code_enabled:
+ type: boolean
required:
- authentication_method
- - is_open_for_signup
- email_verification_by_code_enabled
+ - is_open_for_signup
+ - login_by_code_enabled
AuthenticationResponse:
type: object
description: |
diff --git a/examples/react-spa/frontend/src/account/Login.js b/examples/react-spa/frontend/src/account/Login.js
index 98529b9033..d4dcfa2171 100644
--- a/examples/react-spa/frontend/src/account/Login.js
+++ b/examples/react-spa/frontend/src/account/Login.js
@@ -42,8 +42,9 @@ export default function Login () {
-
- Mail me a sign-in code
+ {config.data.account.login_by_code_enabled
+ ? Mail me a sign-in code
+ : null}
Sign in with a passkey
{hasProviders
? <>