Skip to content

Commit

Permalink
fixed link expiry (signup and login) (#220)
Browse files Browse the repository at this point in the history
  • Loading branch information
bbonf authored Aug 5, 2024
1 parent fd22594 commit 42c917d
Show file tree
Hide file tree
Showing 10 changed files with 125 additions and 41 deletions.
2 changes: 1 addition & 1 deletion integration_tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ def _dj_autoclear_mailbox() -> None:
@pytest.fixture
def link_from_mail(mailbox):

def _delegate(email, subject=None):
def _delegate(email: str, subject=None):
for message in mailbox(email):
if subject is None or subject in message['subject']:
html = message.get_payload()[1].get_payload()
Expand Down
70 changes: 55 additions & 15 deletions integration_tests/test_parent.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

from playwright.sync_api import Page, expect


@pytest.fixture
def default_signup_fill_form(page: Page, apps):
"""opens the signup form and fills some default values. individual tests should overwrite as necessary"""
Expand Down Expand Up @@ -41,23 +42,26 @@ def default_signup_fill_form(page: Page, apps):

@pytest.fixture
def signup(page: Page, apps, default_signup_fill_form):
suffix = ''.join(random.choice(string.digits) for i in range(4))
email = f'parent{suffix}@localhost.local'
def delegate():
suffix = ''.join(random.choice(string.digits) for i in range(4))
email = f'parent{suffix}@localhost.local'

page.fill('#id_email', email)
page.fill('#id_email_again', email)
page.click('input[type="submit"]')
page.fill('#id_email', email)
page.fill('#id_email_again', email)
page.click('input[type="submit"]')

# check that the form was submitted
expect(page.locator('input[type="submit"]')).not_to_be_visible()
assert 'signup/done' in page.url
# check that the form was submitted
expect(page.locator('input[type="submit"]')).not_to_be_visible()
assert 'signup/done' in page.url

return email
return email
return delegate


def test_parent_login(page, apps, signup, as_admin, link_from_mail, login_as):
# confirm signup email
if link := link_from_mail(signup, 'Bevestiging'):
address = signup()
if link := link_from_mail(address, 'Bevestiging'):
page.goto(link)
else:
pytest.fail('could not find validation link')
Expand All @@ -69,21 +73,22 @@ def test_parent_login(page, apps, signup, as_admin, link_from_mail, login_as):
as_admin.locator("button").get_by_text("Approve").click()

# try to login via email
login_as(signup)
login_as(address)

# check that login worked
expect(page.get_by_text('Appointments')).to_be_visible()


def test_parent_login_unapproved(signup, apps, page, link_from_mail, login_as):
# confirm signup email
if link := link_from_mail(signup, 'Bevestiging'):
address = signup()
if link := link_from_mail(address, 'Bevestiging'):
page.goto(link)
else:
pytest.fail('could not find validation link')

# make sure that no login email has arrived
assert login_as(signup) is False
assert login_as(address) is False


def test_parent_self_deactivate(page, participant, login_as):
Expand Down Expand Up @@ -187,8 +192,6 @@ def test_signup_save_longer(page, apps, default_signup_fill_form):


def test_signup_unborn(page, apps, default_signup_fill_form):
Signup = apps.lab.get_model('signups', 'Signup')

now = datetime.datetime.now()
page.locator('#id_birth_date_year').select_option(str(now.year))
page.locator('#id_birth_date_month').select_option(now.strftime('%B'))
Expand All @@ -198,3 +201,40 @@ def test_signup_unborn(page, apps, default_signup_fill_form):

# check that the form was submitted
assert page.locator('select.is-invalid').count() == 3


def test_signup_confirmation_expired(page, apps, signup, as_admin, link_from_mail):
# confirm signup email
address = signup()

Signup = apps.lab.get_model('signups', 'Signup')
s = Signup.objects.last()
s.created = timezone.now() - datetime.timedelta(hours=25)
s.save()

if link := link_from_mail(address, 'Bevestiging'):
page.goto(link)
expect(page.get_by_text('niet meer geldig')).to_be_visible()
else:
pytest.fail('could not find validation link')

s = Signup.objects.last()
assert s.email_verified is None


def test_parent_login_expired(participant, apps, page, link_from_mail, login_as):
MailAuth = apps.lab.get_model('mailauth', 'MailAuth')

page.goto(apps.parent.url + 'auth/')
page.fill('input[name="email"]', participant.email)
page.locator('button').get_by_text('Send').click()

mauth = MailAuth.objects.last()
mauth.expiry = timezone.now() - datetime.timedelta(seconds=1)
mauth.save()

link = link_from_mail(participant.email, 'Link')
page.goto(link)
expect(page.get_by_text('verlopen')).to_be_visible()

expect(page.get_by_text('Appointments')).not_to_be_visible()
3 changes: 3 additions & 0 deletions lab/mailauth/locale/nl/LC_MESSAGES/django.po
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,6 @@ msgstr ""
#: mailauth/models.py:33
msgid "mailauth:send:subject"
msgstr "Babylab voor Taalonderzoek: Link voor Babylabportaal"

msgid "mailauth:error:expired"
msgstr "Helaas is deze link verlopen. U kunt opnieuw inloggen via BABYLABPORTAAL hieronder."
33 changes: 24 additions & 9 deletions lab/mailauth/models.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import dataclasses
from datetime import datetime
from enum import Enum, auto
from secrets import token_urlsafe
from typing import List, Optional, Tuple

import cdh.core.fields as e_fields
from cdh.mail.classes import TemplateEmail
from django.conf import settings
from django.db import models
from django.utils import translation
from django.utils import timezone, translation
from django.utils.translation import gettext_lazy as _

from participants.models import Participant
Expand Down Expand Up @@ -64,15 +66,28 @@ def create_mail_auth(
return MailAuth.objects.create(expiry=expiry, email=email, participant=participant)


def try_authenticate(token: str) -> Tuple[Optional[MailAuth], List[Participant]]:
class MailAuthReason(Enum):
SUCCESS = auto()
NOT_FOUND = auto()
EXPIRED = auto()


@dataclasses.dataclass
class MailAuthResult:
reason: MailAuthReason
mauth: Optional[MailAuth] = None
possible_pps: List[Participant] = dataclasses.field(default_factory=list)


def try_authenticate(token: str) -> MailAuthResult:
try:
mauth = MailAuth.objects.get(
link_token=token,
# link should not be too old
expiry__gte=datetime.now(),
)
mauth = MailAuth.objects.get(link_token=token)
except MailAuth.DoesNotExist:
return None, []
return MailAuthResult(reason=MailAuthReason.NOT_FOUND)

if mauth.expiry < timezone.now():
# link should not be too old
return MailAuthResult(reason=MailAuthReason.EXPIRED)

possible_pps = []
if not mauth.participant:
Expand All @@ -91,7 +106,7 @@ def try_authenticate(token: str) -> Tuple[Optional[MailAuth], List[Participant]]

mauth.session_token = token_urlsafe()
mauth.save()
return mauth, possible_pps
return MailAuthResult(mauth=mauth, possible_pps=possible_pps, reason=MailAuthReason.SUCCESS)


def lookup_session_token(token: str) -> Optional[Participant]:
Expand Down
26 changes: 19 additions & 7 deletions lab/mailauth/views.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
from datetime import datetime, timedelta
from datetime import timedelta

from django.utils import timezone, translation
from django.utils.translation import gettext as _
from rest_framework import exceptions, views
from rest_framework.response import Response

from participants.models import Participant
from participants.serializers import ParticipantSerializer

from .models import create_mail_auth, resolve_participant, try_authenticate
from .models import (
MailAuthReason,
create_mail_auth,
resolve_participant,
try_authenticate,
)


class MailAuthView(views.APIView):
Expand All @@ -15,15 +22,20 @@ def get(self, request, *args, **kwargs):
return Response(dict())

# look for valid auth object for token
mauth, possible_pps = try_authenticate(kwargs["token"])
if mauth is not None:
result = try_authenticate(kwargs["token"])
if result.mauth is not None:
# valid token
return Response(
dict(
session_token=mauth.session_token,
possible_pps=[ParticipantSerializer(pp).data for pp in possible_pps],
session_token=result.mauth.session_token,
possible_pps=[ParticipantSerializer(pp).data for pp in result.possible_pps],
)
)

if result.reason == MailAuthReason.EXPIRED:
with translation.override("nl"):
return Response(dict(reason=_("mailauth:error:expired")), status=410)

raise exceptions.AuthenticationFailed()

def post(self, request, *args, **kwargs):
Expand All @@ -35,7 +47,7 @@ def post(self, request, *args, **kwargs):
return Response(dict())

# email exists, generate token and send it
expiry = datetime.now() + timedelta(hours=24)
expiry = timezone.now() + timedelta(hours=24)
mauth = create_mail_auth(expiry, email)

# while there might be multiple participants matching the given email address,
Expand Down
8 changes: 7 additions & 1 deletion lab/signups/locale/nl/LC_MESSAGES/django.po
Original file line number Diff line number Diff line change
Expand Up @@ -88,4 +88,10 @@ msgid "signups:messages:rejected"
msgstr ""

msgid "signups:mail:validation:subject"
msgstr "Babylab voor Taalonderzoek: Bevestiging e-mailadres"
msgstr "Babylab voor Taalonderzoek: Bevestiging e-mailadres"

msgid "signups:verify:error:expired"
msgstr ""
"<p>Deze link is niet meer geldig. Uw gegevens hebben wij daarom helaas moeten verwijderen.</p>"
"<p>Wilt u uw kind toch graag aanmelden voor het Babylab voor Taalonderzoek, "
"dan verzoeken wij u vriendelijk om dit opnieuw te doen via AANMELDEN hieronder.</p>"
15 changes: 11 additions & 4 deletions lab/signups/views.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
from datetime import timedelta

import braces.views as braces
from django.contrib import messages
from django.http.response import HttpResponse
from django.shortcuts import redirect
from django.urls import reverse_lazy
from django.utils import timezone
from django.utils import timezone, translation
from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, ListView
from rest_framework import views
Expand Down Expand Up @@ -82,6 +84,11 @@ def reject_signup(signup: Signup):
class SignupVerifyView(views.APIView):
def get(self, request, *args, **kwargs):
signup = Signup.objects.get(link_token=kwargs["token"])
signup.email_verified = timezone.now()
signup.save()
return Response(dict())
if timezone.now() - signup.created > timedelta(days=1):
# expired
with translation.override("nl"):
return Response(dict(reason=_("signups:verify:error:expired")), status=410)
else:
signup.email_verified = timezone.now()
signup.save()
return Response(dict())
2 changes: 1 addition & 1 deletion parent/mailauth/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ def link_verify(request, token):

# upon successful authentication, the lab app should respond with a session token
if not ok or "session_token" not in response:
messages.error(request, _("parent:error:login_failed"))
messages.error(request, response.get("reason", _("parent:error:login_failed")))
return redirect("home")

request.session["token"] = response["session_token"]
Expand Down
4 changes: 2 additions & 2 deletions parent/parent/locale/nl/LC_MESSAGES/django.po
Original file line number Diff line number Diff line change
Expand Up @@ -396,8 +396,8 @@ msgstr ""
"Het is niet gelukt om u uit te schirjven. Neem alstublieft contact met ons "
"op via e-mail: [email protected]"

#~ msgid "parent:error:login_failed"
#~ msgstr "Inloggen is mislukt, probeer het later opnieuw"
msgid "parent:error:login_failed"
msgstr "Er is een fout opgetreden tijdens het inloggen"

msgid "parent:appointment:canceled"
msgstr "Geannuleerd"
3 changes: 2 additions & 1 deletion parent/parent/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from cdh.rest import client as rest
from django.contrib import messages
from django.http.response import JsonResponse
from django.utils.safestring import mark_safe
from django.shortcuts import redirect, render
from django.urls import reverse_lazy
from django.utils.translation import gettext_lazy as _
Expand Down Expand Up @@ -81,7 +82,7 @@ def signup_verify(request, token):
ok, result = gateway(request, f"/gateway/signup/verify/{token}")
if ok:
return render(request, "signup_confirmed.html")
messages.error(request, _("parent:error:signup_verify"))
messages.error(request, mark_safe(result.get("reason", _("parent:error:signup_verify"))))
return redirect("home")


Expand Down

0 comments on commit 42c917d

Please sign in to comment.