Skip to content

Commit

Permalink
Populate ExistingUserLogin email if the user has proven knowledge
Browse files Browse the repository at this point in the history
When the user proves knowledge of their email during the login flow, we store the email entered in the session so that we can pre-fill it for them on the subsequent password form. Not all flows leading to this view involve the user proving their email however, so if the key is not present in the session or it doesn't match the user's public id then it is ignored.
  • Loading branch information
calummackervoy committed Jan 13, 2025
1 parent 2994889 commit b30a7a3
Show file tree
Hide file tree
Showing 6 changed files with 190 additions and 2 deletions.
1 change: 1 addition & 0 deletions itou/www/login/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ITOU_SESSION_JOB_SEEKER_LOGIN_EMAIL_KEY = "job_seeker_login_email"
13 changes: 11 additions & 2 deletions itou/www/login/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from itou.openid_connect.errors import format_error_modal_content
from itou.users.enums import IdentityProvider
from itou.users.models import User
from itou.www.login.constants import ITOU_SESSION_JOB_SEEKER_LOGIN_EMAIL_KEY


class FindExistingUserViaEmailForm(forms.Form):
Expand Down Expand Up @@ -49,6 +50,7 @@ def clean_email(self):
extra_tags="modal login_failure email_does_not_exist",
)
raise ValidationError("Cette adresse e-mail est inconnue. Veuillez en saisir une autre, ou vous inscrire.")
self.request.session[ITOU_SESSION_JOB_SEEKER_LOGIN_EMAIL_KEY] = email
return email


Expand All @@ -57,15 +59,22 @@ class ItouLoginForm(LoginForm):
demo_banner_account = forms.BooleanField(widget=forms.HiddenInput(), required=False)

def __init__(self, *args, **kwargs):
user_email = kwargs.pop("user_email", None)
super().__init__(*args, **kwargs)
self.fields["password"].widget.attrs["placeholder"] = "**********"
self.fields["password"].help_text = format_html(
'<a href="{}" class="btn-link fs-sm">Mot de passe oublié ?</a>',
reverse("account_reset_password"),
)
self.fields["login"].widget.attrs["placeholder"] = "[email protected]"
self.fields["login"].label = "Adresse e-mail"
self.fields["login"].widget.attrs["autofocus"] = True

if user_email:
self.fields["login"].initial = user_email
self.fields["login"].widget.attrs["disabled"] = True
self.data = self.data.dict() | {"login": user_email}
else:
self.fields["login"].widget.attrs["placeholder"] = "[email protected]"
self.fields["login"].widget.attrs["autofocus"] = True

def clean(self):
# Parent method performs authentication on form success.
Expand Down
14 changes: 14 additions & 0 deletions itou/www/login/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from itou.users.models import User
from itou.utils.auth import LoginNotRequiredMixin
from itou.utils.urls import add_url_params, get_safe_url, get_url_param_value
from itou.www.login.constants import ITOU_SESSION_JOB_SEEKER_LOGIN_EMAIL_KEY
from itou.www.login.forms import FindExistingUserViaEmailForm, ItouLoginForm


Expand Down Expand Up @@ -180,6 +181,19 @@ def setup(self, request, *args, **kwargs):
self.user = get_object_or_404(User, public_id=self.kwargs["user_public_id"])
self.user_kind = self.user.kind

def get_user_email(self):
"""
This template can optionally have the email of the user displayed, if it is safe to do so.
This is done using the session and comparing the stashed value to the user concerned by this view.
"""
stashed_email = self.request.session.get(ITOU_SESSION_JOB_SEEKER_LOGIN_EMAIL_KEY, None)
return stashed_email if stashed_email == self.user.email else None

def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs["user_email"] = self.get_user_email()
return kwargs

def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
extra_context = {
Expand Down
126 changes: 126 additions & 0 deletions tests/www/login/__snapshots__/tests.ambr
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,132 @@
</div>


</div>
'''
# ---
# name: TestExistingUserLogin.test_login_email_prefilled[login_not_prefilled]
'''
<div class="c-form mb-5">





<form class="js-prevent-multiple-submit" method="post">
<input name="csrfmiddlewaretoken" type="hidden" value="NORMALIZED_CSRF_TOKEN"/>
<fieldset>
<p class="h4">Se connecter avec vos identifiants</p>


<div class="form-group form-group-required"><label class="form-label" for="id_login">Adresse e-mail</label><input autocomplete="email" autofocus="" class="form-control" id="id_login" maxlength="320" name="login" placeholder="[email protected]" required="" type="email"/></div>
<div class="mb-3 form-group-required"><label class="form-label" for="id_password">Mot de passe</label><div class="input-group">
<input aria-describedby="id_password_helptext" autocomplete="current-password" class="form-control" id="id_password" name="password" placeholder="**********" required="" type="password"/>

<div class="input-group-text p-0">
<button class="btn btn-sm btn-link btn-ico" data-it-password="toggle" type="button">
<i aria-hidden="true" class="ri-eye-line"></i>
<span>Afficher</span>
</button>
</div>
</div><div class="form-text"><a class="btn-link fs-sm" href="/accounts/password/reset/">Mot de passe oublié ?</a></div>
</div>
</fieldset>



<div class="row">
<div class="col-12">
<hr class="mb-3"/>
<small class="d-inline-block mb-3">* champs obligatoires</small>
<div class="form-row align-items-center justify-content-end gx-3">
<div class="form-group col-12 col-lg order-3 order-lg-1">

<a aria-label="Annuler la saisie de ce formulaire" class="btn btn-link btn-ico ps-lg-0 w-100 w-lg-auto" href="/">
<i aria-hidden="true" class="ri-close-line ri-lg"></i>
<span>Annuler</span>
</a>

</div>

<div class="form-group col col-lg-auto order-2 order-lg-3">

<button aria-label="Passer à l’étape suivante" class="btn btn-block btn-primary" type="submit">
<span>Se connecter</span>
</button>

</div>
</div>
</div>
</div>



</form>


</div>
'''
# ---
# name: TestExistingUserLogin.test_login_email_prefilled[login_prefilled]
'''
<div class="c-form mb-5">





<form class="js-prevent-multiple-submit" method="post">
<input name="csrfmiddlewaretoken" type="hidden" value="NORMALIZED_CSRF_TOKEN"/>
<fieldset>
<p class="h4">Se connecter avec vos identifiants</p>


<div class="form-group form-group-required"><label class="form-label" for="id_login">Adresse e-mail</label><input autocomplete="email" class="form-control" disabled="" id="id_login" maxlength="320" name="login" placeholder="Adresse e-mail" required="" type="email" value="[email protected]"/></div>
<div class="mb-3 form-group-required"><label class="form-label" for="id_password">Mot de passe</label><div class="input-group">
<input aria-describedby="id_password_helptext" autocomplete="current-password" class="form-control" id="id_password" name="password" placeholder="**********" required="" type="password"/>

<div class="input-group-text p-0">
<button class="btn btn-sm btn-link btn-ico" data-it-password="toggle" type="button">
<i aria-hidden="true" class="ri-eye-line"></i>
<span>Afficher</span>
</button>
</div>
</div><div class="form-text"><a class="btn-link fs-sm" href="/accounts/password/reset/">Mot de passe oublié ?</a></div>
</div>
</fieldset>



<div class="row">
<div class="col-12">
<hr class="mb-3"/>
<small class="d-inline-block mb-3">* champs obligatoires</small>
<div class="form-row align-items-center justify-content-end gx-3">
<div class="form-group col-12 col-lg order-3 order-lg-1">

<a aria-label="Annuler la saisie de ce formulaire" class="btn btn-link btn-ico ps-lg-0 w-100 w-lg-auto" href="/">
<i aria-hidden="true" class="ri-close-line ri-lg"></i>
<span>Annuler</span>
</a>

</div>

<div class="form-group col col-lg-auto order-2 order-lg-3">

<button aria-label="Passer à l’étape suivante" class="btn btn-block btn-primary" type="submit">
<span>Se connecter</span>
</button>

</div>
</div>
</div>
</div>



</form>


</div>
'''
# ---
Expand Down
34 changes: 34 additions & 0 deletions tests/www/login/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from itou.users.enums import IdentityProvider, UserKind
from itou.utils import constants as global_constants
from itou.utils.urls import add_url_params
from itou.www.login.constants import ITOU_SESSION_JOB_SEEKER_LOGIN_EMAIL_KEY
from itou.www.login.forms import ItouLoginForm
from itou.www.login.views import ExistingUserLoginView
from tests.openid_connect.france_connect.tests import FC_USERINFO, mock_oauth_dance
Expand Down Expand Up @@ -225,6 +226,9 @@ def test_pre_login_redirects_to_existing_user(self, client):
response = client.post(url, data=form_data)
assertRedirects(response, f'{reverse("login:existing_user", args=(user.public_id,))}?back_url={url}')

# Email is populated in session. The utility of this is covered by the ExistingUserLoginView tests.
assert client.session[ITOU_SESSION_JOB_SEEKER_LOGIN_EMAIL_KEY] == user.email

def test_pre_login_email_unknown(self, client, snapshot):
url = reverse("login:job_seeker")
response = client.get(url)
Expand Down Expand Up @@ -320,6 +324,36 @@ def test_login_django(self, client):
response = client.post(url, data=form_data)
assertRedirects(response, reverse("account_email_verification_sent"))

def test_login_email_prefilled(self, client, snapshot):
# Login is not pre-filled just by visiting the page.
# The user must prove they know this information
user = JobSeekerFactory(identity_provider=IdentityProvider.DJANGO, for_snapshot=True)
url = reverse("login:existing_user", args=(user.public_id,))
response = client.get(url)
assert response.status_code == 200

assert response.context["form"]["login"].initial is None
assert str(parse_response_to_soup(response, selector=".c-form")) == snapshot(name="login_not_prefilled")

# If the email has been populated in the session, but the email populated does not match the user requested,
# then it is ignored.
session = client.session
session[ITOU_SESSION_JOB_SEEKER_LOGIN_EMAIL_KEY] = "[email protected]"
session.save()
response = client.get(url)
assert response.status_code == 200
assert response.context["form"]["login"].initial is None
assert str(parse_response_to_soup(response, selector=".c-form")) == snapshot(name="login_not_prefilled")

# If the login has been populated in the session with the correct email,
# then the user will not need to re-enter it a second time.
session[ITOU_SESSION_JOB_SEEKER_LOGIN_EMAIL_KEY] = user.email
session.save()
response = client.get(url)
assert response.status_code == 200
assert response.context["form"]["login"].initial == user.email
assert str(parse_response_to_soup(response, selector=".c-form")) == snapshot(name="login_prefilled")

@pytest.mark.parametrize(
"identity_provider",
[
Expand Down
4 changes: 4 additions & 0 deletions tests/www/signup/test_job_seeker.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from itou.users.models import User
from itou.utils import constants as global_constants
from itou.utils.widgets import DuetDatePickerWidget
from itou.www.login.constants import ITOU_SESSION_JOB_SEEKER_LOGIN_EMAIL_KEY
from itou.www.signup.forms import JobSeekerSituationForm
from tests.asp.factories import CountryEuropeFactory, CountryFranceFactory
from tests.cities.factories import create_city_geispolsheim, create_test_cities
Expand Down Expand Up @@ -618,6 +619,9 @@ def test_job_seeker_signup_with_conflicting_fields(self, erroneous_fields, snaps
)
assertContains(response, reverse("login:existing_user", args=(existing_user.public_id,)))

# Important that the email is not saved in the session, in the subsequent step the user must prove they know it
assert ITOU_SESSION_JOB_SEEKER_LOGIN_EMAIL_KEY not in client.session

# NOTE: error is rendered for the case that the user ignores the modal
if "email" in erroneous_fields:
assert response.context["form"].errors["email"] == [
Expand Down

0 comments on commit b30a7a3

Please sign in to comment.