Skip to content

Commit 70aacc6

Browse files
authored
Task/verify 2fa code (#2221)
* WIP - Add 2FA verification for mobile phone number - Add verify-mobile-number.html page - Add new route `verify_mobile_number` - Add `validate_2fa_method` to the `user_api_client` which calls the api to verify a 2fa code without disturbing the users browser session * Gate `validate_2fa_method` behind a FF check * Merge verify-mobile-number.html and confirm.html - Removing an existing phone number sets the verified_phonenumber to false in the db. - Utilize the new verify phone number designs when changing or adding a phone number via the profile page - Added proper back_link handling on the confirmation page - Ensure that when a user clicks the back button after resending a verification code from the update or add phone number view that they are redirected to the correct page * Remove temporary dev link to verify SMS on profile * Impl. add & verify phone # from 2fa flow - Add a flow for when the user tries wants to add SMS 2fa auth have not added a phone number to their profile yet - Select SMS auth -> add their phone number -> enter verify code -> auth is changed to SMS * Fix tests * Add / correct translations * Decouple verification from add phone number flow - Adding a phone number to your profile no longer requires verification - Fixed a bug where incorrect verify codes were not being handled correctly and errors not properly displayed to the user.
1 parent b2e064e commit 70aacc6

9 files changed

Lines changed: 145 additions & 116 deletions

File tree

app/main/views/two_factor.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ def two_factor_email_sent():
2323
# Check if a FIDO2 key exists, if yes, return template
2424
user = User.from_id(user_id)
2525

26+
# AUTHV2_Note: security_key - Tagging this for future authv2 dev reference
2627
if len(user.security_keys):
2728
return render_template("views/two-factor-fido.html")
2829

@@ -54,6 +55,7 @@ def two_factor_sms_sent():
5455
# Check if a FIDO2 key exists, if yes, return template
5556
user = User.from_id(user_id)
5657

58+
# AUTHV2_Note: security_key - Tagging this for future authv2 dev reference
5759
if len(user.security_keys):
5860
return render_template("views/two-factor-fido.html")
5961

app/main/views/user_profile.py

Lines changed: 57 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from flask_babel import _
1515
from flask_login import current_user
1616
from notifications_python_client.errors import HTTPError
17+
from notifications_utils.decorators import requires_feature
1718
from notifications_utils.url_safe_token import check_token
1819

1920
from app import user_api_client
@@ -157,8 +158,10 @@ def user_profile_mobile_number():
157158
return redirect(url_for(".user_profile"))
158159

159160
elif form.validate_on_submit():
160-
session[NEW_MOBILE] = form.mobile_number.data
161-
return redirect(url_for(".user_profile_mobile_number_authenticate"))
161+
current_user.update(mobile_number=form.mobile_number.data)
162+
flash(_("Mobile number {} saved to your profile").format(form.mobile_number.data), "default_with_tick")
163+
return redirect(url_for(".user_profile"))
164+
162165
else:
163166
return render_template(
164167
"views/user-profile/change.html",
@@ -190,14 +193,14 @@ def _check_password(pwd):
190193
return user_api_client.verify_password(current_user.id, pwd)
191194

192195
form = ConfirmPasswordForm(_check_password)
193-
194196
if NEW_MOBILE not in session:
195197
return redirect(url_for(".user_profile_mobile_number"))
196198

197199
if form.validate_on_submit():
198-
# if they are removing their phone number, skip the verification, set auth type to email
200+
# if they are removing their phone number, skip verification, set auth type to email, and remove verified phone number flag
199201
if not session[NEW_MOBILE]:
200202
current_user.update(mobile_number=None, auth_type="email_auth")
203+
current_user.update(verified_phonenumber=False)
201204

202205
flash(_("Mobile number removed from your profile"), "default_with_tick")
203206
return redirect(url_for(".user_profile"))
@@ -219,8 +222,8 @@ def _check_password(pwd):
219222
@user_is_logged_in
220223
def user_profile_mobile_number_confirm():
221224
# Validate verify code for form
222-
def _check_code(cde):
223-
return user_api_client.check_verify_code(current_user.id, cde, "sms")
225+
def _check_code(code):
226+
return user_api_client.validate_2fa_method(current_user.id, code, "sms")
224227

225228
if NEW_MOBILE_PASSWORD_CONFIRMED not in session:
226229
return redirect(url_for(".user_profile_mobile_number"))
@@ -233,6 +236,7 @@ def _check_code(cde):
233236
del session[NEW_MOBILE]
234237
del session[NEW_MOBILE_PASSWORD_CONFIRMED]
235238
current_user.update(mobile_number=mobile_number)
239+
current_user.update(verified_phonenumber=True)
236240

237241
flash(_("Mobile number {} saved to your profile").format(mobile_number), "default_with_tick")
238242

@@ -254,19 +258,15 @@ def _check_code(cde):
254258
# Default redirect to user profile
255259
return redirect(url_for(".user_profile"))
256260

257-
return render_template(
258-
"views/user-profile/confirm.html",
259-
form_field=form.two_factor_code,
260-
thing=_("mobile number"),
261-
)
261+
return render_template("views/user-profile/confirm.html", form=form, back_link=url_for(".user_profile_mobile_number"))
262262

263263

264264
@main.route("/user-profile/mobile-number/resend", methods=["GET", "POST"])
265265
@user_is_logged_in
266266
def sms_not_received():
267267
current_user.send_verify_code(to=session[NEW_MOBILE])
268268
flash(_("Verification code re-sent"), "default_with_tick")
269-
return redirect(url_for(".user_profile_mobile_number_confirm"))
269+
return redirect(url_for(".verify_mobile_number"))
270270

271271

272272
@main.route("/user-profile/password", methods=["GET", "POST"])
@@ -388,9 +388,46 @@ def user_profile_disable_platform_admin_view():
388388
return render_template("views/user-profile/disable-platform-admin-view.html", form=form)
389389

390390

391+
@main.route("/user-profile/verify-mobile-number", methods=["GET", "POST"])
392+
@user_is_logged_in
393+
@requires_feature("FF_AUTH_V2")
394+
def verify_mobile_number():
395+
"""
396+
This route is used to verify the user's mobile number.
397+
It sends a verification code to the user's mobile number and
398+
allows them to confirm it.
399+
400+
"""
401+
if not current_user.mobile_number and NEW_MOBILE_PASSWORD_CONFIRMED not in session:
402+
return redirect(url_for(".user_profile_2fa"))
403+
404+
# Validate password for form
405+
def _check_code(code):
406+
return user_api_client.validate_2fa_method(current_user.id, code, "sms")
407+
408+
form = TwoFactorForm(_check_code)
409+
410+
if form.validate_on_submit():
411+
current_user.update(verified_phonenumber=True)
412+
current_user.update(auth_type="sms_auth")
413+
flash(_("Two-step verification method updated"), "default_with_tick")
414+
return redirect(url_for(".user_profile_2fa"))
415+
else:
416+
current_user.send_verify_code(to=current_user.mobile_number)
417+
418+
return render_template(
419+
"views/user-profile/confirm.html",
420+
form=form,
421+
back_link=url_for(".user_profile_2fa")
422+
if NEW_MOBILE_PASSWORD_CONFIRMED not in session
423+
else url_for(".user_profile_mobile_number"),
424+
)
425+
426+
391427
@main.route("/user-profile/2fa", methods=["GET", "POST"])
392428
@user_is_logged_in
393429
def user_profile_2fa():
430+
# TODO: This should be gated behind a new route that confirms the users password before allowing them to make changes
394431
if current_app.config["FF_AUTH_V2"]:
395432
# IF they have not authenticated yet, do it now
396433
if not session.get(HAS_AUTHENTICATED):
@@ -436,6 +473,12 @@ def user_profile_2fa():
436473
auth_type = "email_auth"
437474
elif new_auth_type == "sms":
438475
auth_type = "sms_auth"
476+
477+
if not current_user.mobile_number:
478+
session["from_send_page"] = "user_profile_2fa"
479+
return redirect(url_for(".user_profile_mobile_number"))
480+
elif not current_user.verified_phonenumber:
481+
return redirect(url_for(".verify_mobile_number"))
439482
elif new_auth_type == "new_key":
440483
# Redirect to add a new security key
441484
return redirect(url_for(".user_profile_add_security_keys"))
@@ -444,17 +487,10 @@ def user_profile_2fa():
444487
# Default to email auth if something unexpected is selected
445488
auth_type = "email_auth"
446489

447-
# If the user is switching to SMS, ensure they have a mobile number
448-
if auth_type == "sms_auth" and not current_user.mobile_number:
449-
flash(_("You must add a mobile number to your profile to use this option."), "error")
450-
session["from_send_page"] = "user_profile_2fa"
451-
return redirect(url_for(".user_profile_mobile_number"))
452-
453-
# Update the user's authentication type
454-
current_user.update(auth_type=auth_type)
455-
456490
# Flash a success message
457491
flash(_("Two-step verification method updated"), "default_with_tick")
492+
# Update the user's auth type
493+
current_user.update(auth_type=auth_type)
458494

459495
# Redirect back to user profile and revoke their additional authentication
460496
session.pop(HAS_AUTHENTICATED, None)

app/notify_client/user_api_client.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from typing import Dict
55

66
from notifications_python_client.errors import HTTPError
7+
from notifications_utils.decorators import requires_feature
78

89
from app.extensions import redis_client
910
from app.models.roles_and_permissions import (
@@ -171,6 +172,18 @@ def check_verify_code(self, user_id, code, code_type):
171172
return False, e.message
172173
raise e
173174

175+
@requires_feature("FF_AUTH_V2")
176+
def validate_2fa_method(self, user_id, code, code_type):
177+
data = {"code_type": code_type, "code": code}
178+
endpoint = "/user/{}/verify-2fa".format(user_id)
179+
try:
180+
self.post(endpoint, data=data)
181+
return True, ""
182+
except HTTPError as e:
183+
if e.status_code == 400 or e.status_code == 404:
184+
return False, e.message
185+
raise e
186+
174187
def get_users_for_service(self, service_id):
175188
endpoint = "/service/{}/users".format(service_id)
176189
return self.get(endpoint)["data"]

app/templates/components/profile-item.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{% from "components/table.html" import edit_field, text_field %}
22

3-
{% macro profile_item(label, value, action=None, link_text=None) %}
3+
{% macro profile_item(label, value, action=None, link_text=None, verified=None) %}
44
<div class="flex flex-wrap md:flex-nowrap items-end border-b-1 border-gray-400 py-5">
55
<div class="w-full sm:w-3/4" tabindex="0">
66
<div class="text-gray-600">{{ text_field(label) }}</div>

app/templates/views/user-profile.html

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ <h1 class="heading-large">{{ _('Your profile') }}</h1>
1515

1616
{% if config["FF_AUTH_V2"] %}
1717
<div class="pb-8">
18-
{{ profile_item(_('Name'), text_field(current_user.name), '.user_profile_name') }}
18+
{{ profile_item(_('Name'), text_field(current_user.name), '.user_profile_name') }}
1919
{{ profile_item(_('Email address'), text_field(current_user.email_address), '.user_profile_email' if can_see_edit else None) }}
2020
{{ profile_item(_('Contact number'), optional_text_field(current_user.mobile_number), '.user_profile_mobile_number', None if current_user.mobile_number else _('Add number')) }}
2121
</div>
@@ -34,18 +34,18 @@ <h2 class="heading-large">{{ _("Security") }}</h2>
3434

3535
{{ profile_item(_('Security keys'), text_field(_("{} security key").format(num_keys) if num_keys == 1 else _("{} security keys").format(num_keys)), '.user_profile_security_keys') }}
3636
{% if current_user.platform_admin or session.get('disable_platform_admin_view') %}
37-
{{ profile_item(_('Use platform admin view'), text_field(_('Yes') if not session.get('disable_platform_admin_view') else _('No')), '.user_profile_disable_platform_admin_view') }}
37+
{{ profile_item(_('Use platform admin view'), text_field(_('Yes') if not session.get('disable_platform_admin_view') else _('No')), '.user_profile_disable_platform_admin_view') }}
3838
{% endif %}
3939
</div>
4040
{% else %}
41-
{{ profile_item(_('Name'), text_field(current_user.name), '.user_profile_name') }}
41+
{{ profile_item(_('Name'), text_field(current_user.name), '.user_profile_name') }}
4242
{{ profile_item(_('Email address'), text_field(current_user.email_address), '.user_profile_email' if can_see_edit else None) }}
4343
{{ profile_item(_('Mobile number'), optional_text_field(current_user.mobile_number), '.user_profile_mobile_number', None if current_user.mobile_number else _('Add number')) }}
4444
{{ profile_item(_('Password'), text_field(_('Last changed ') + " " + current_user.password_changed_at|format_delta ), '.user_profile_password') }}
4545
{{ profile_item(_('Security keys'), text_field(num_keys), '.user_profile_security_keys') }}
4646

4747
{% if current_user.platform_admin or session.get('disable_platform_admin_view') %}
48-
{{ profile_item(_('Use platform admin view'), text_field(_('Yes') if not session.get('disable_platform_admin_view') else _('No')), '.user_profile_disable_platform_admin_view') }}
48+
{{ profile_item(_('Use platform admin view'), text_field(_('Yes') if not session.get('disable_platform_admin_view') else _('No')), '.user_profile_disable_platform_admin_view') }}
4949
{% endif %}
5050
{% endif %}
5151
{% endblock %}
Lines changed: 28 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
{% extends "admin_template.html" %}
1+
{% extends "admin_template.html" %}
22
{% from "components/textbox.html" import textbox %}
33
{% from "components/page-header.html" import page_header %}
4+
{% from "components/radios.html" import radios %}
45
{% from "components/page-footer.html" import page_footer %}
56
{% from "components/form.html" import form_wrapper %}
67

@@ -14,28 +15,40 @@
1415

1516
{{ page_header(
1617
title,
17-
back_link=url_for('.user_profile')
18+
back_link=back_link
1819
) }}
1920

2021
<div class="grid-row contain-floats">
21-
<div class="md:w-3/4 float-left py-0 px-0 px-gutterHalf box-border">
22-
<p>
23-
{{ _('We’ve sent you a security code by {}').format(thing) }}
24-
</p>
25-
{% call form_wrapper() %}
22+
<div class="md:w-2/3 float-left py-0 px-0 px-gutterHalf box-border">
23+
{% set verify = _('Verify') %}
24+
{% set cancel = _('Cancel') %}
25+
{% set link_txt = _("Didn’t get a text message?") %}
26+
<p>{{ _('We’ve sent you a text message with a verification code.') }}</p>
27+
28+
{% call form_wrapper(class="extra-tracking") %}
2629
{{ textbox(
27-
form_field,
30+
form.two_factor_code,
2831
width='form-control-5em',
2932
autofocus=True
3033
) }}
31-
{{ page_footer(_('Confirm'), cancel_button_link=url_for('.user_profile')) }}
32-
<p class="mt-16">
33-
{{ _("Didn’t get a text message?") }}
34-
<br />
35-
<a href="{{ url_for('main.sms_not_received') }}">{{ _('Re-send security code') }}</a>
36-
</p>
34+
{{ page_footer(
35+
button_text=verify,
36+
cancel_button_link=back_link,
37+
) }}
38+
39+
<details>
40+
<summary>
41+
{{ link_txt }}
42+
</summary>
43+
<div class="details-body">
44+
<p>{{ _("To receive text messages you need a North American mobile phone number.") }}</p>
45+
<p>{{ _("MS Teams, landlines, and some international numbers cannot receive texts.") }}</p>
46+
<a href={{url_for('.verify_mobile_number')}} class="button button-secondary text-base outline-none focus:shadow-outline">{{ _('Resend verification code') }}</a>
47+
</div>
48+
</details>
3749
{% endcall %}
50+
3851
</div>
3952
</div>
4053

41-
{% endblock %}
54+
{% endblock %}

app/translations/csv/fr.csv

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2236,4 +2236,9 @@
22362236
"Use '{}' key","Utiliser la clé '{}'"
22372237
"Verified","Vérifié"
22382238
"Not verified","Non vérifié"
2239-
"Enter password to change profile settings","Entrez votre mot de passe pour modifier votre profil"
2239+
"Enter password to change profile settings","Entrez votre mot de passe pour modifier votre profil"
2240+
"We’ve sent you a text message with a verification code.","Nous avons envoyé un code de vérification par message texte."
2241+
"Verify","Vérifier"
2242+
"Resend verification code","Renvoyer le code de vérification"
2243+
"MS Teams, landlines, and some international numbers cannot receive texts.","Les numéros MS Teams, les lignes fixes, et certains numéros internationaux ne peuvent pas recevoir de messages texte."
2244+
"To receive text messages you need a North American mobile phone number.","Pour recevoir le message texte, vous devez utiliser un numéro de téléphone nord-américain."

0 commit comments

Comments
 (0)