From 63bc16577631aeaa2605b44df005f995bdf1f842 Mon Sep 17 00:00:00 2001 From: Raymond Penners Date: Thu, 15 Aug 2024 14:50:31 +0200 Subject: [PATCH] feat(account): Login timeout --- ChangeLog.rst | 7 +++++++ allauth/account/app_settings.py | 9 +++++++++ allauth/account/models.py | 6 ++++++ allauth/account/utils.py | 8 +++++++- allauth/mfa/totp/tests/test_views.py | 23 +++++++++++++++++++++++ docs/account/configuration.rst | 15 ++++++++++----- 6 files changed, 62 insertions(+), 6 deletions(-) diff --git a/ChangeLog.rst b/ChangeLog.rst index d70feb56ea..b9768447de 100644 --- a/ChangeLog.rst +++ b/ChangeLog.rst @@ -1,9 +1,16 @@ 64.1.0 (unreleased) ******************* +Note worthy changes +------------------- + - Headless: When trying to login while a user is already logged in, you now get a 409. +- Limited the maximum allowed time for a login to go through the various login + stages. This limits, for example, the time span that the 2FA stage remains + available. + Security notice --------------- diff --git a/allauth/account/app_settings.py b/allauth/account/app_settings.py index eedc6c4ceb..0db88074d3 100644 --- a/allauth/account/app_settings.py +++ b/allauth/account/app_settings.py @@ -429,6 +429,15 @@ def LOGIN_BY_CODE_MAX_ATTEMPTS(self): def LOGIN_BY_CODE_TIMEOUT(self): return self._setting("LOGIN_BY_CODE_TIMEOUT", 3 * 60) + @property + def LOGIN_TIMEOUT(self): + """ + The maximum allowed time (in seconds) for a login to go through the + various login stages. This limits, for example, the time span that the + 2FA stage remains available. + """ + return self._setting("LOGIN_TIMEOUT", 15 * 60) + _app_settings = AppSettings("ACCOUNT_") diff --git a/allauth/account/models.py b/allauth/account/models.py index fc5c81b0c6..041907e53a 100644 --- a/allauth/account/models.py +++ b/allauth/account/models.py @@ -1,4 +1,5 @@ import datetime +import time from typing import Dict, Optional from django.conf import settings @@ -239,6 +240,7 @@ class Login: signup: bool email: Optional[str] state: Dict + initiated_at: float def __init__( self, @@ -249,6 +251,7 @@ def __init__( signup: bool = False, email: Optional[str] = None, state: Optional[Dict] = None, + initiated_at: Optional[float] = None, ): self.user = user if not email_verification: @@ -259,6 +262,7 @@ def __init__( self.signup = signup self.email = email self.state = {} if state is None else state + self.initiated_at = initiated_at if initiated_at else time.time() def serialize(self): from allauth.account.utils import user_pk_to_url_str @@ -279,6 +283,7 @@ def serialize(self): "email": self.email, "signal_kwargs": signal_kwargs, "state": self.state, + "initiated_at": self.initiated_at, } return data @@ -311,6 +316,7 @@ def deserialize(cls, data): signup=data["signup"], signal_kwargs=signal_kwargs, state=data["state"], + initiated_at=data["initiated_at"], ) except KeyError: raise ValueError() diff --git a/allauth/account/utils.py b/allauth/account/utils.py index ea6ee33308..cc41a7449e 100644 --- a/allauth/account/utils.py +++ b/allauth/account/utils.py @@ -1,3 +1,4 @@ +import time import unicodedata from collections import OrderedDict from typing import Optional @@ -174,9 +175,14 @@ def unstash_login(request, peek=False): if isinstance(data, dict): try: login = Login.deserialize(data) - request._account_login_accessed = True except ValueError: pass + else: + if time.time() - login.initiated_at > app_settings.LOGIN_TIMEOUT: + login = None + request.session.pop(flows.login.LOGIN_SESSION_KEY, None) + else: + request._account_login_accessed = True return login diff --git a/allauth/mfa/totp/tests/test_views.py b/allauth/mfa/totp/tests/test_views.py index 90e5a7f83b..679e24006a 100644 --- a/allauth/mfa/totp/tests/test_views.py +++ b/allauth/mfa/totp/tests/test_views.py @@ -1,3 +1,4 @@ +import time from unittest.mock import ANY, patch from django.conf import settings @@ -5,6 +6,9 @@ from django.test import Client from django.urls import reverse +from pytest_django.asserts import assertTemplateUsed + +from allauth.account import app_settings from allauth.account.authentication import AUTHENTICATION_METHODS_SESSION_KEY from allauth.account.models import EmailAddress from allauth.mfa.adapter import get_adapter @@ -221,3 +225,22 @@ def test_totp_code_reuse( assert resp.context["form"].errors == { "code": [get_adapter().error_messages["incorrect_code"]] } + + +def test_totp_stage_expires(client, user_with_totp, user_password): + resp = client.post( + reverse("account_login"), + {"login": user_with_totp.username, "password": user_password}, + ) + assert resp.status_code == 302 + assert resp["location"] == reverse("mfa_authenticate") + resp = client.get(reverse("mfa_authenticate")) + assert resp.status_code == 200 + assertTemplateUsed(resp, "mfa/authenticate.html") + with patch( + "allauth.account.utils.time.time", + return_value=time.time() + 1.1 * app_settings.LOGIN_TIMEOUT, + ): + resp = client.get(reverse("mfa_authenticate")) + assert resp.status_code == 302 + assert resp["location"] == reverse("account_login") diff --git a/docs/account/configuration.rst b/docs/account/configuration.rst index fa650fcda4..f13cf99f22 100644 --- a/docs/account/configuration.rst +++ b/docs/account/configuration.rst @@ -147,6 +147,16 @@ Available settings: confirming the email address **immediately after signing up**, assuming users didn't close their browser or used some sort of private browsing mode. +``ACCOUNT_LOGIN_ON_PASSWORD_RESET`` (default: ``False``) + By changing this setting to ``True``, users will automatically be logged in + once they have reset their password. By default they are redirected to the + password reset done page. + +``ACCOUNT_LOGIN_TIMEOUT`` (default: ``900``) + The maximum allowed time (in seconds) for a login to go through the + various login stages. This limits, for example, the time span that the + 2FA stage remains available. + ``ACCOUNT_LOGOUT_ON_GET`` (default: ``False``) Determines whether or not the user is automatically logged out by a GET request. `GET is not designed to modify the server state `_, @@ -159,11 +169,6 @@ Available settings: changing or setting their password. See documentation for `Django's session invalidation on password change `_. -``ACCOUNT_LOGIN_ON_PASSWORD_RESET`` (default: ``False``) - By changing this setting to ``True``, users will automatically be logged in - once they have reset their password. By default they are redirected to the - password reset done page. - ``ACCOUNT_LOGOUT_REDIRECT_URL`` (default: ``settings.LOGOUT_REDIRECT_URL or "/"``) The URL (or URL name) to return to after the user logs out. Defaults to Django's ``LOGOUT_REDIRECT_URL``, unless that is empty, then ``"/"`` is used.