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 ? <>