Skip to content

Commit

Permalink
feat(account): Login timeout
Browse files Browse the repository at this point in the history
  • Loading branch information
pennersr committed Aug 15, 2024
1 parent 4303cbe commit 63bc165
Show file tree
Hide file tree
Showing 6 changed files with 62 additions and 6 deletions.
7 changes: 7 additions & 0 deletions ChangeLog.rst
Original file line number Diff line number Diff line change
@@ -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
---------------
Expand Down
9 changes: 9 additions & 0 deletions allauth/account/app_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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_")

Expand Down
6 changes: 6 additions & 0 deletions allauth/account/models.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import datetime
import time
from typing import Dict, Optional

from django.conf import settings
Expand Down Expand Up @@ -239,6 +240,7 @@ class Login:
signup: bool
email: Optional[str]
state: Dict
initiated_at: float

def __init__(
self,
Expand All @@ -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:
Expand All @@ -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
Expand All @@ -279,6 +283,7 @@ def serialize(self):
"email": self.email,
"signal_kwargs": signal_kwargs,
"state": self.state,
"initiated_at": self.initiated_at,
}
return data

Expand Down Expand Up @@ -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()
Expand Down
8 changes: 7 additions & 1 deletion allauth/account/utils.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import time
import unicodedata
from collections import OrderedDict
from typing import Optional
Expand Down Expand Up @@ -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


Expand Down
23 changes: 23 additions & 0 deletions allauth/mfa/totp/tests/test_views.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import time
from unittest.mock import ANY, patch

from django.conf import settings
from django.core.cache import cache
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
Expand Down Expand Up @@ -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")
15 changes: 10 additions & 5 deletions docs/account/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 <http://programmers.stackexchange.com/questions/188860/>`_,
Expand All @@ -159,11 +169,6 @@ Available settings:
changing or setting their password. See documentation for
`Django's session invalidation on password change <https://docs.djangoproject.com/en/stable/topics/auth/default/#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.
Expand Down

0 comments on commit 63bc165

Please sign in to comment.