Skip to content

Commit 1e4d815

Browse files
author
Marc Schlaeppi
committed
Fix password MFA verification feedback
1 parent 99d1db0 commit 1e4d815

8 files changed

Lines changed: 146 additions & 25 deletions

File tree

source/app/blueprints/pages/login/login_routes.py

Lines changed: 31 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@
4646
from app.datamgmt.manage.manage_users_db import create_user
4747
from app.datamgmt.manage.manage_users_db import update_user_groups
4848
from app.datamgmt.manage.manage_users_db import get_user
49-
from app.forms import LoginForm, MFASetupForm
49+
from app.forms import LoginForm, MFASetupForm, MFAVerifyForm
5050
from app.blueprints.iris_user import iris_current_user
5151
from app.iris_engine.utils.tracker import track_activity
5252
from app.datamgmt.manage.manage_groups_db import get_groups_list
@@ -154,8 +154,7 @@ def login():
154154

155155
form = LoginForm(request.form)
156156

157-
# check if both http method is POST and form is valid on submit
158-
if not form.is_submitted() and not form.validate():
157+
if not form.validate_on_submit():
159158
return _render_template_login(form, None)
160159

161160
# assign form data to variables
@@ -406,20 +405,27 @@ def _clear_pre_mfa_state(preserve_lockout=False):
406405
session.pop("pending_mfa_secret", None)
407406
if not preserve_lockout:
408407
session.pop("mfa_fail_count", None)
408+
session.pop("mfa_fail_user_id", None)
409409
session.pop("mfa_lockout_until", None)
410410

411411

412-
def _mfa_is_locked_out():
412+
def _mfa_is_locked_out(user):
413+
if session.get("mfa_fail_user_id") != user.id:
414+
return False
413415
locked_until = session.get("mfa_lockout_until")
414416
if locked_until and locked_until > time.time():
415417
return True
416418
if locked_until and locked_until <= time.time():
417419
session.pop("mfa_lockout_until", None)
420+
session.pop("mfa_fail_user_id", None)
418421
session["mfa_fail_count"] = 0
419422
return False
420423

421424

422425
def _register_mfa_failure(user, reason):
426+
if session.get("mfa_fail_user_id") != user.id:
427+
session["mfa_fail_count"] = 0
428+
session["mfa_fail_user_id"] = user.id
423429
session["mfa_fail_count"] = session.get("mfa_fail_count", 0) + 1
424430
track_activity(
425431
f"Failed MFA {reason} for user {user.user} "
@@ -433,6 +439,8 @@ def _register_mfa_failure(user, reason):
433439
# the lockout timestamp so a fresh /login cannot wipe it.
434440
_clear_pre_mfa_state(preserve_lockout=True)
435441
session.pop("username", None)
442+
return True
443+
return False
436444

437445

438446
@app.route("/auth/mfa-setup", methods=["GET", "POST"])
@@ -449,13 +457,13 @@ def mfa_setup():
449457
if user.mfa_setup_complete and user.mfa_secrets:
450458
return redirect(url_for("mfa_verify"))
451459

452-
if _mfa_is_locked_out():
460+
if _mfa_is_locked_out(user):
453461
flash("Too many attempts. Please try again later.", "danger")
454462
return redirect(url_for("login.login"))
455463

456464
form = MFASetupForm()
457465

458-
if form.submit() and form.validate():
466+
if form.validate_on_submit():
459467

460468
token = form.token.data
461469
user_password = form.user_password.data
@@ -469,7 +477,7 @@ def mfa_setup():
469477

470478
totp = pyotp.TOTP(mfa_secret)
471479

472-
if totp.verify(token):
480+
if totp.verify(str(token).strip(), valid_window=1):
473481
has_valid_password = False
474482
if is_authentication_ldap() is True:
475483
if validate_ldap_login(
@@ -483,7 +491,10 @@ def mfa_setup():
483491
has_valid_password = True
484492

485493
if not has_valid_password:
486-
_register_mfa_failure(user, "setup (invalid password)")
494+
locked_out = _register_mfa_failure(user, "setup (invalid password)")
495+
if locked_out:
496+
flash("Too many attempts. Please try again later.", "danger")
497+
return redirect(url_for("login.login"))
487498
flash("Invalid password. Please try again.", "danger")
488499
return render_template("mfa_setup.html", form=form)
489500

@@ -501,7 +512,10 @@ def mfa_setup():
501512
session["mfa_verified_for_user_id"] = user.id
502513
_clear_pre_mfa_state()
503514
return wrap_login_user(user)
504-
_register_mfa_failure(user, "setup (invalid token)")
515+
locked_out = _register_mfa_failure(user, "setup (invalid token)")
516+
if locked_out:
517+
flash("Too many attempts. Please try again later.", "danger")
518+
return redirect(url_for("login.login"))
505519
flash("Invalid token or password. Please try again.", "danger")
506520

507521
# Generate a fresh secret on every GET and stash it in the session. The
@@ -536,21 +550,16 @@ def mfa_verify():
536550
)
537551
return redirect(url_for("mfa_setup"))
538552

539-
if _mfa_is_locked_out():
553+
if _mfa_is_locked_out(user):
540554
flash("Too many attempts. Please try again later.", "danger")
541555
return redirect(url_for("login.login"))
542556

543-
form = MFASetupForm()
544-
form.user_password.data = "not required for verification"
557+
form = MFAVerifyForm()
545558

546-
if form.submit() and form.validate():
559+
if form.validate_on_submit():
547560
token = form.token.data
548-
if not token:
549-
flash("Token is required.", "danger")
550-
return render_template("mfa_verify.html", form=form)
551-
552561
totp = pyotp.TOTP(user.mfa_secrets)
553-
if totp.verify(token):
562+
if totp.verify(str(token).strip(), valid_window=1):
554563
track_activity(
555564
f"MFA verification successful for user {user.user}",
556565
ctx_less=True, display_in_ui=False,
@@ -561,7 +570,10 @@ def mfa_verify():
561570
session["mfa_verified_for_user_id"] = user.id
562571
_clear_pre_mfa_state()
563572
return wrap_login_user(user)
564-
_register_mfa_failure(user, "verification (invalid token)")
573+
locked_out = _register_mfa_failure(user, "verification (invalid token)")
574+
if locked_out:
575+
flash("Too many attempts. Please try again later.", "danger")
576+
return redirect(url_for("login.login"))
565577
flash("Invalid token. Please try again.", "danger")
566578

567579
return render_template("mfa_verify.html", form=form)
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
{% with messages = get_flashed_messages(with_categories=true) %}
2+
{% for category, message in messages %}
3+
<div class="alert alert-{{ category if category in ['danger', 'warning', 'success', 'info'] else 'info' }}" role="alert">
4+
{{ message }}
5+
</div>
6+
{% endfor %}
7+
{% endwith %}
8+
9+
{% if form and form.errors %}
10+
<div class="alert alert-danger" role="alert">
11+
{% for field_errors in form.errors.values() %}
12+
{% for error in field_errors %}
13+
<div>{{ error }}</div>
14+
{% endfor %}
15+
{% endfor %}
16+
</div>
17+
{% endif %}

source/app/blueprints/pages/login/templates/login.html

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ <h3 class="text-white">{{ organisation_name }}</h3>
2222
<div class="login__right_part col-xs-12 col-md-4">
2323
<h3 class="login__form_title">Sign In</h3>
2424

25+
{% include "_auth_messages.html" %}
26+
2527
{% if auth_type == "oidc" %}
2628
<a href="{{ url_for('login.oidc_login') }}" class="btn btn-primary login__submit_button login__form_title">OIDC Sign In</a>
2729
{% else %}
@@ -63,4 +65,4 @@ <h3 class="login__form_title">Sign In</h3>
6365
<script type="module" src="/static/assets/js/iris/login.js"></script>
6466
</body>
6567

66-
{% endblock content %}
68+
{% endblock content %}

source/app/blueprints/pages/login/templates/mfa_setup.html

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ <h1 class="text-white mb-3">Setup MFA</h1>
2727
<div class="d-flex flex-column justify-content-center align-items-center" style="height: 100%;">
2828
<h3 class="login__form_title">Your organisation requires to setup MFA</h3>
2929
<p>Scan the QR code with your authenticator app and enter the token and your password.</p>
30+
{% include "_auth_messages.html" %}
3031
<form method="POST" action="">
3132
<div class="col-md-12 col-lg-12 col-sm-12">
3233
<div class="form-row ml-2">
@@ -54,4 +55,4 @@ <h3 class="login__form_title">Your organisation requires to setup MFA</h3>
5455

5556
{% block javascripts %}
5657

57-
{% endblock javascripts %}
58+
{% endblock javascripts %}

source/app/blueprints/pages/login/templates/mfa_verify.html

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
<div class="d-flex flex-column justify-content-center align-items-center" style="height: 100%;">
2626
<h3 class="login__form_title">Verify MFA</h3>
2727
<p>Enter the token displayed on your authentication app</p>
28+
{% include "_auth_messages.html" %}
2829
<form method="POST" action="">
2930
<div class="col-md-12 col-lg-12 col-sm-12">
3031
<div class="form-row ml-2">
@@ -49,4 +50,4 @@ <h3 class="login__form_title">Verify MFA</h3>
4950

5051
{% block javascripts %}
5152

52-
{% endblock javascripts %}
53+
{% endblock javascripts %}

source/app/business/auth.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -147,16 +147,19 @@ def wrap_login_user(user, is_oidc=False):
147147
# here — an attacker who re-POSTs /login mustn't be able to zero
148148
# out the fail counter and get a fresh burst of 5 tokens.
149149
locked_until = session.get('mfa_lockout_until')
150-
if locked_until and locked_until > time.time():
150+
locked_user_id = session.get('mfa_fail_user_id')
151+
if locked_user_id == user.id and locked_until and locked_until > time.time():
151152
flash('Too many attempts. Please try again later.', 'danger')
152153
return redirect(url_for('login.login'))
153154

154155
# Mark this browser session as the one that just passed password
155156
# auth for this user. mfa_setup / mfa_verify will refuse to run
156157
# for any other user id, preventing cross-user MFA handler abuse.
158+
same_failed_user = session.get('mfa_fail_user_id') == user.id
157159
session['pre_mfa_user_id'] = user.id
158-
session['mfa_fail_count'] = 0
159-
session.pop('mfa_lockout_until', None)
160+
if not same_failed_user:
161+
session['mfa_fail_count'] = 0
162+
session.pop('mfa_lockout_until', None)
160163
session.pop('pending_mfa_secret', None)
161164
return redirect(url_for('mfa_verify'))
162165

source/app/forms.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,11 @@ class MFASetupForm(FlaskForm):
4242
submit = SubmitField('Verify')
4343

4444

45+
class MFAVerifyForm(FlaskForm):
46+
token = StringField('Token', validators=[DataRequired()])
47+
submit = SubmitField('Verify')
48+
49+
4550
class RegisterForm(FlaskForm):
4651
name = StringField(u'Name', validators=[DataRequired()])
4752
username = StringField(u'Username', validators=[DataRequired()])

tests/tests_auth_mfa.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333

3434
from unittest import TestCase
3535
from uuid import uuid4
36+
from html.parser import HTMLParser
3637

3738
import pyotp
3839
import requests
@@ -45,6 +46,25 @@
4546
_PASSWORD = 'aA.1234567890'
4647

4748

49+
class _CsrfTokenParser(HTMLParser):
50+
def __init__(self):
51+
super().__init__()
52+
self.token = None
53+
54+
def handle_starttag(self, tag, attrs):
55+
attributes = dict(attrs)
56+
if tag == 'input' and attributes.get('name') == 'csrf_token':
57+
self.token = attributes.get('value')
58+
59+
60+
def _csrf_token(response):
61+
parser = _CsrfTokenParser()
62+
parser.feed(response.text)
63+
if not parser.token:
64+
raise AssertionError('CSRF token not found in authentication form')
65+
return parser.token
66+
67+
4868
def _login(username, password):
4969
"""POST /api/v2/auth/login and return the parsed top-level JSON body.
5070
@@ -251,3 +271,63 @@ def test_mfa_verify_should_lock_out_after_repeated_failures(self):
251271
# surfaces verbatim to the user.
252272
self.assertIn('Too many MFA attempts',
253273
locked.json().get('message', ''))
274+
275+
def test_legacy_mfa_failures_should_survive_password_reentry(self):
276+
user_name, _ = self._provision_mfa_user()
277+
browser = requests.Session()
278+
login_url = parse.urljoin(API_URL, '/login')
279+
280+
login_page = browser.get(login_url)
281+
password_response = browser.post(
282+
login_url,
283+
data={
284+
'csrf_token': _csrf_token(login_page),
285+
'username': user_name,
286+
'password': _PASSWORD,
287+
},
288+
allow_redirects=False,
289+
)
290+
self.assertEqual(302, password_response.status_code)
291+
self.assertIn('/auth/mfa-verify', password_response.headers['Location'])
292+
293+
challenge_url = parse.urljoin(API_URL, '/auth/mfa-verify')
294+
challenge_page = browser.get(challenge_url)
295+
for _ in range(3):
296+
challenge_page = browser.post(
297+
challenge_url,
298+
data={
299+
'csrf_token': _csrf_token(challenge_page),
300+
'token': '000000',
301+
},
302+
)
303+
self.assertIn('Invalid token', challenge_page.text)
304+
305+
login_page = browser.get(login_url)
306+
password_response = browser.post(
307+
login_url,
308+
data={
309+
'csrf_token': _csrf_token(login_page),
310+
'username': user_name,
311+
'password': _PASSWORD,
312+
},
313+
)
314+
self.assertIn('Verify MFA', password_response.text)
315+
316+
challenge_page = password_response
317+
challenge_page = browser.post(
318+
challenge_url,
319+
data={
320+
'csrf_token': _csrf_token(challenge_page),
321+
'token': '000000',
322+
},
323+
)
324+
self.assertIn('Invalid token', challenge_page.text)
325+
326+
locked_page = browser.post(
327+
challenge_url,
328+
data={
329+
'csrf_token': _csrf_token(challenge_page),
330+
'token': '000000',
331+
},
332+
)
333+
self.assertIn('Too many attempts. Please try again later.', locked_page.text)

0 commit comments

Comments
 (0)