Skip to content

Commit

Permalink
feat(account): Redirect to stages
Browse files Browse the repository at this point in the history
  • Loading branch information
pennersr committed Sep 20, 2024
1 parent d52f8d7 commit 8d09548
Show file tree
Hide file tree
Showing 18 changed files with 188 additions and 45 deletions.
16 changes: 15 additions & 1 deletion ChangeLog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,24 @@
- Added transparent support for Django's ``LoginRequiredMiddleware`` (new since
Django 5.1).

- The ``userserssions`` app now emits signals when either the IP address or user
- The ``usersessions`` app now emits signals when either the IP address or user
agent for a session changes.


Backwards incompatible changes
------------------------------

- When the user is partially logged in (e.g. pending 2FA, or login by code),
accessing the login/signup page now redirects to the pending login stage. This
is similar to the redirect that was already in place when the user was fully
authenticated while accessing the login/signup page. As a result, cancelling
(logging out of) the pending stage requires an actual logout POST instead of
merely linking back to e.g. the login page. The builtin templates handle this
change transparently, but if you copied any of the templates involving the
login stages you will have to adjust the cancel link into a logout POST.



64.2.1 (2024-09-05)
*******************

Expand Down
18 changes: 11 additions & 7 deletions allauth/account/internal/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@
from django.urls import reverse

from allauth.account import app_settings
from allauth.account.internal.stagekit import (
get_pending_stage,
redirect_to_pending_stage,
)
from allauth.account.stages import LoginStageController
from allauth.account.utils import get_login_redirect_url

Expand Down Expand Up @@ -41,13 +45,13 @@ def decorator(view_func):
@login_not_required
@wraps(view_func)
def _wrapper_view(request, *args, **kwargs):
if (
request.user.is_authenticated
# ACCOUNT_AUTHENTICATED_LOGIN_REDIRECTS = False is going to
# be deprecated.
and app_settings.AUTHENTICATED_LOGIN_REDIRECTS
):
return HttpResponseRedirect(get_login_redirect_url(request))
if app_settings.AUTHENTICATED_LOGIN_REDIRECTS:
if request.user.is_authenticated:
return HttpResponseRedirect(get_login_redirect_url(request))
else:
stage = get_pending_stage(request)
if stage and stage.is_resumable(request):
return redirect_to_pending_stage(request, stage)
return view_func(request, *args, **kwargs)

return _wrapper_view
Expand Down
11 changes: 9 additions & 2 deletions allauth/account/internal/flows/email_verification_by_code.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from allauth.account import app_settings
from allauth.account.adapter import get_adapter
from allauth.account.internal.flows.login_by_code import compare_code
from allauth.account.internal.stagekit import clear_login
from allauth.account.models import EmailAddress, EmailConfirmationMixin
from allauth.core import context

Expand Down Expand Up @@ -38,6 +39,11 @@ def key_expired(self):
return False


def clear_state(request):
request.session.pop(EMAIL_VERIFICATION_CODE_SESSION_KEY, None)
clear_login(request)


def request_email_verification_code(
request: HttpRequest,
user,
Expand Down Expand Up @@ -73,9 +79,10 @@ def get_pending_verification(
else:
data = request.session.pop(EMAIL_VERIFICATION_CODE_SESSION_KEY, None)
if not data:
clear_state(request)
return None, None
if time.time() - data["at"] >= app_settings.EMAIL_VERIFICATION_BY_CODE_TIMEOUT:
request.session.pop(EMAIL_VERIFICATION_CODE_SESSION_KEY, None)
clear_state(request)
return None, None
if user_id_str := data.get("user_id"):
user_id = get_user_model()._meta.pk.to_python(user_id_str) # type: ignore[union-attr]
Expand All @@ -98,7 +105,7 @@ def record_invalid_attempt(
n += 1
pending_verification["failed_attempts"] = n
if n >= app_settings.EMAIL_VERIFICATION_BY_CODE_MAX_ATTEMPTS:
request.session.pop(EMAIL_VERIFICATION_CODE_SESSION_KEY, None)
clear_state(request)
return False
else:
request.session[EMAIL_VERIFICATION_CODE_SESSION_KEY] = pending_verification
Expand Down
4 changes: 3 additions & 1 deletion allauth/account/internal/flows/login_by_code.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
record_authentication,
)
from allauth.account.internal.flows.signup import send_unknown_account_mail
from allauth.account.internal.stagekit import clear_login
from allauth.account.models import Login


Expand Down Expand Up @@ -66,7 +67,7 @@ def request_login_code(


def get_pending_login(
login: Login, peek: bool = False
request, login: Login, peek: bool = False
) -> Tuple[Optional[AbstractBaseUser], Optional[Dict[str, Any]]]:
if peek:
data = login.state.get(LOGIN_CODE_STATE_KEY)
Expand All @@ -76,6 +77,7 @@ def get_pending_login(
return None, None
if time.time() - data["at"] >= app_settings.LOGIN_BY_CODE_TIMEOUT:
login.state.pop(LOGIN_CODE_STATE_KEY, None)
clear_login(request)
return None, None
user_id_str = data.get("user_id")
user = None
Expand Down
12 changes: 9 additions & 3 deletions allauth/account/internal/flows/logout.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
from django.contrib import messages
from django.http import HttpRequest

from allauth.account.internal.stagekit import clear_login


def logout(request: HttpRequest) -> None:
from allauth.account.adapter import get_adapter

adapter = get_adapter()
adapter.add_message(request, messages.SUCCESS, "account/messages/logged_out.txt")
adapter.logout(request)
if request.user.is_authenticated:
adapter = get_adapter()
adapter.add_message(
request, messages.SUCCESS, "account/messages/logged_out.txt"
)
adapter.logout(request)
clear_login(request)
31 changes: 31 additions & 0 deletions allauth/account/internal/stagekit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
from typing import Optional

from django.http import HttpResponseRedirect
from django.urls import reverse

from allauth.account.stages import LoginStage, LoginStageController


def get_pending_stage(request) -> Optional[LoginStage]:
from allauth.account.utils import unstash_login

stage = None
if not request.user.is_authenticated:
login = unstash_login(request, peek=True)
if login:
ctrl = LoginStageController(request, login)
stage = ctrl.get_pending_stage()
return stage


def redirect_to_pending_stage(request, stage: LoginStage):
if stage.urlname:
return HttpResponseRedirect(reverse(stage.urlname))
clear_login(request)
return HttpResponseRedirect(reverse("account_login"))


def clear_login(request):
from allauth.account.internal.flows.login import LOGIN_SESSION_KEY

request.session.pop(LOGIN_SESSION_KEY, None)
34 changes: 28 additions & 6 deletions allauth/account/stages.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
from typing import Optional

from django.http import HttpResponseRedirect
from django.urls import reverse

from allauth.account import app_settings
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


class LoginStage:
key: str # Set in subclasses
urlname: Optional[str] = None

def __init__(self, controller, request, login):
if not self.key:
Expand All @@ -28,13 +31,20 @@ def handle(self):
return None, True

def exit(self):
from allauth.account.utils import resume_login

self.controller.set_handled(self.key)
return resume_login(self.request, self.login)

def abort(self):
unstash_login(self.request)
from allauth.account.internal.stagekit import clear_login

clear_login(self.request)
return HttpResponseRedirect(reverse("account_login"))

def is_resumable(self, request):
return True


class LoginStageController:
def __init__(self, request, login):
Expand All @@ -44,6 +54,8 @@ def __init__(self, request, login):

@classmethod
def enter(cls, request, stage_key):
from allauth.account.utils import unstash_login

login = unstash_login(request, peek=True)
if not login:
return None
Expand All @@ -66,7 +78,7 @@ def set_handled(self, stage_key):
stage_state = self.state.setdefault(stage_key, {})
stage_state["handled"] = True

def get_pending_stage(self):
def get_pending_stage(self) -> Optional[LoginStage]:
ret = None
stages = self.get_stages()
for stage in stages:
Expand All @@ -87,6 +99,9 @@ def get_stages(self):
return stages

def handle(self):
from allauth.account.internal.stagekit import clear_login
from allauth.account.utils import stash_login

stages = self.get_stages()
for stage in stages:
if self.is_handled(stage.key):
Expand All @@ -97,16 +112,20 @@ def handle(self):
if cont:
stash_login(self.request, self.login)
else:
unstash_login(self.request)
clear_login(self.request)
return response
else:
assert cont
self.set_handled(stage.key)
unstash_login(self.request)
clear_login(self.request)


class EmailVerificationStage(LoginStage):
key = "verify_email"
urlname = "account_email_verification_sent"

def is_resumable(self, request):
return app_settings.EMAIL_VERIFICATION_BY_CODE_ENABLED

def handle(self):
from allauth.account.utils import (
Expand Down Expand Up @@ -138,11 +157,14 @@ def handle(self):

class LoginByCodeStage(LoginStage):
key = "login_by_code"
urlname = "account_confirm_login_code"

def handle(self):
from allauth.account.internal.flows import login_by_code

user, data = login_by_code.get_pending_login(self.login, peek=True)
user, data = login_by_code.get_pending_login(
self.request, self.login, peek=True
)
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.
Expand Down
21 changes: 21 additions & 0 deletions allauth/account/tests/test_email_verification.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,9 @@ def test_email_verification_mandatory(settings, db, client, mailoutbox, enable_c
)
# Wait for cooldown -- wipe cache (incl. rate limits)
cache.clear()
# if we don't wipe the session, login will redirect to pending stage...
client.logout()

# Verify, and re-attempt to login.
confirmation = EmailConfirmation.objects.filter(
email_address__user__username="johndoe"
Expand Down Expand Up @@ -317,3 +320,21 @@ def test_verify_logs_out_user(auth_client, settings, user, user_factory):
)
)
assert not auth_client.session.get(SESSION_KEY)


def test_email_verification_login_redirect(client, db, settings, password_factory):
settings.ACCOUNT_EMAIL_VERIFICATION = app_settings.EmailVerificationMethod.MANDATORY
password = password_factory()
resp = client.post(
reverse("account_signup"),
{
"username": "johndoe",
"email": "user@email.org",
"password1": password,
"password2": password,
},
)
assert resp.status_code == 302
assert resp["location"] == reverse("account_email_verification_sent")
resp = client.get(reverse("account_login"))
assert resp.status_code == 200
19 changes: 19 additions & 0 deletions allauth/account/tests/test_email_verification_by_code.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,3 +116,22 @@ def test_add_or_change_email(auth_client, user, get_last_code, change_email, set
assert resp["location"] == reverse("account_email")
assert EmailAddress.objects.filter(email=email, verified=True).count() == 1
assert EmailAddress.objects.filter(user=user).count() == (1 if change_email else 2)


def test_email_verification_login_redirect(
client, db, settings, password_factory, email_verification_settings
):
password = password_factory()
resp = client.post(
reverse("account_signup"),
{
"username": "johndoe",
"email": "user@email.org",
"password1": password,
"password2": password,
},
)
assert resp.status_code == 302
assert resp["location"] == reverse("account_email_verification_sent")
resp = client.get(reverse("account_login"))
assert resp["location"] == reverse("account_email_verification_sent")
6 changes: 6 additions & 0 deletions allauth/account/tests/test_login_by_code.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,3 +109,9 @@ def test_login_by_code_required(
email_address.refresh_from_db()
assert email_address.verified
assert resp["location"] == settings.LOGIN_REDIRECT_URL


def test_login_by_code_redirect(client, user, request_login_by_code):
request_login_by_code(client, user.email)
resp = client.get(reverse("account_login"))
assert resp["location"] == reverse("account_confirm_login_code")
4 changes: 2 additions & 2 deletions allauth/account/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

from allauth.account import app_settings, signals
from allauth.account.adapter import get_adapter
from allauth.account.internal import flows
from allauth.account.internal import flows, stagekit
from allauth.account.models import Login
from allauth.core.internal import httpkit
from allauth.utils import (
Expand Down Expand Up @@ -180,7 +180,7 @@ def unstash_login(request, peek=False):
else:
if time.time() - login.initiated_at > app_settings.LOGIN_TIMEOUT:
login = None
request.session.pop(flows.login.LOGIN_SESSION_KEY, None)
stagekit.clear_login(request)
else:
request._account_login_accessed = True
return login
Expand Down
9 changes: 3 additions & 6 deletions allauth/account/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -696,8 +696,7 @@ def get(self, *args, **kwargs):

def post(self, *args, **kwargs):
url = self.get_redirect_url()
if self.request.user.is_authenticated:
self.logout()
self.logout()
response = redirect(url)
return _ajax_response(self.request, response)

Expand Down Expand Up @@ -772,9 +771,7 @@ def get_form_kwargs(self):
def get_context_data(self, **kwargs):
ret = super().get_context_data(**kwargs)
ret["email"] = self.pending_verification["email"]
ret["cancel_url"] = (
reverse("account_login") if self.stage else reverse("account_email")
)
ret["cancel_url"] = None if self.stage else reverse("account_email")
return ret

def form_valid(self, form):
Expand Down Expand Up @@ -936,7 +933,7 @@ class ConfirmLoginCodeView(RedirectAuthenticatedUserMixin, NextRedirectMixin, Fo
def dispatch(self, request, *args, **kwargs):
self.stage = request._login_stage
self.user, self.pending_login = flows.login_by_code.get_pending_login(
self.stage.login, peek=True
self.request, self.stage.login, peek=True
)
if not self.pending_login:
return HttpResponseRedirect(reverse("account_request_login_code"))
Expand Down
Loading

0 comments on commit 8d09548

Please sign in to comment.