This is an automated email from the ASF dual-hosted git repository.
brondsem pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/allura.git
The following commit(s) were added to refs/heads/master by this push:
new b0f98d300 Use email authentication code for foreign logins without MFA
b0f98d300 is described below
commit b0f98d300c73ece34db75fb282fcc632f71d2ca2
Author: Carlos Cruz <[email protected]>
AuthorDate: Thu Jan 23 17:26:27 2025 +0000
Use email authentication code for foreign logins without MFA
---
Allura/allura/controllers/auth.py | 26 ++++-
Allura/allura/lib/exceptions.py | 4 +
Allura/allura/lib/multifactor.py | 25 ++++-
Allura/allura/lib/plugin.py | 20 +++-
Allura/allura/model/auth.py | 12 ++
Allura/allura/templates/login_multifactor.html | 80 ++++++++-----
.../allura/templates/mail/authentication_code.txt | 26 +++++
Allura/allura/tests/functional/test_auth.py | 124 +++++++++++++++++++++
Allura/development.ini | 3 +
Allura/test.ini | 4 +
10 files changed, 292 insertions(+), 32 deletions(-)
diff --git a/Allura/allura/controllers/auth.py
b/Allura/allura/controllers/auth.py
index a40e434c3..b985cc5ec 100644
--- a/Allura/allura/controllers/auth.py
+++ b/Allura/allura/controllers/auth.py
@@ -22,7 +22,7 @@
from base64 import b32encode
from datetime import datetime
import re
-from urllib.parse import urlparse, urljoin
+from urllib.parse import urlparse, urljoin, urlunparse
import bson
import formencode as fe
@@ -43,7 +43,7 @@
from allura.lib import plugin
from allura.lib import validators as V
from allura.lib.decorators import require_post, reconfirm_auth
-from allura.lib.exceptions import InvalidRecoveryCode,
MultifactorRateLimitError
+from allura.lib.exceptions import InvalidRecoveryCode,
MultifactorRateLimitError, InvalidEmailAuthCode
from allura.lib.repository import RepositoryApp
from allura.lib.security import HIBPClient, HIBPCompromisedCredentials,
HIBPClientError
from allura.lib.widgets import (
@@ -56,7 +56,7 @@
DisableAccountForm)
from allura.lib.widgets import forms, form_fields as ffw
from allura.lib import mail_util
-from allura.lib.multifactor import TotpService, RecoveryCodeService
+from allura.lib.multifactor import TotpService, RecoveryCodeService,
EmailCodeAuthenticationService
from allura.lib import utils
from allura.controllers import BaseController
from allura.tasks.mail_tasks import send_system_mail_to_user
@@ -150,7 +150,13 @@ def index(self, *args, **kwargs):
if request.referer is not None and
six.ensure_text(request.referer).split('/')[-1] == 'neighborhood':
return_to = '/'
elif request.referer:
- return_to = six.ensure_text(request.referer)
+ parsed_referer = urlparse(request.referer)
+ # Fix return_to url path upon clicking Cancel in multifactor
authentication
+ if parsed_referer.path in
plugin.AuthenticationProvider.multifactor_allowed_urls:
+ new_url = parsed_referer._replace(path='/')
+ return_to = six.ensure_text(urlunparse(new_url))
+ else:
+ return_to = six.ensure_text(request.referer)
else:
return_to = None
@@ -393,7 +399,6 @@ def _verify_return_to(return_to):
@utils.AntiSpam.validate('Spambot protection engaged')
def do_login(self, return_to=None, **kw):
location = '/'
-
if session.get('multifactor-username'):
location = tg.url('/auth/multifactor', dict(return_to=return_to))
elif session.get('expired-username'):
@@ -414,6 +419,9 @@ def multifactor(self, return_to='', mode='totp', **kwargs):
if not c.user.is_anonymous():
# already logged in, no need to do this form
redirect(self._verify_return_to(return_to))
+ # currently only email_code mode is set via session
+ if session.get('mode') == 'email_code':
+ mode = 'email_code'
return dict(
return_to=return_to,
@@ -441,6 +449,10 @@ def do_multifactor(self, code, mode, **kwargs):
recovery = RecoveryCodeService.get()
recovery.verify_and_remove_code(user, code)
h.auditlog_user('Logged in using a multifactor recovery code',
user=user)
+ elif mode == 'email_code':
+ email_code_service = EmailCodeAuthenticationService()
+ email_code_service.validate_code(user, code)
+ h.auditlog_user('Logged in using email authentication code',
user=user)
except (InvalidToken, InvalidRecoveryCode):
request.validation.errors['code'] = 'Invalid code, please try
again.'
h.auditlog_user('Multifactor login - invalid code', user=user)
@@ -449,6 +461,10 @@ def do_multifactor(self, code, mode, **kwargs):
request.validation.errors['code'] = 'Multifactor rate limit
exceeded, slow down and try again later.'
h.auditlog_user('Multifactor login - rate limit', user=user)
return self.multifactor(mode=mode, **kwargs)
+ except InvalidEmailAuthCode:
+ request.validation.errors['code'] = 'Invalid code, please try
again.'
+ h.auditlog_user('Email code login - invalid code', user=user)
+ return self.multifactor(mode=mode, **kwargs)
else:
plugin.AuthenticationProvider.get(request).login(user=user,
multifactor_success=True)
return_to = self._verify_return_to(kwargs.get('return_to'))
diff --git a/Allura/allura/lib/exceptions.py b/Allura/allura/lib/exceptions.py
index 47a8cdb98..8b2d39d7a 100644
--- a/Allura/allura/lib/exceptions.py
+++ b/Allura/allura/lib/exceptions.py
@@ -92,6 +92,10 @@ class InvalidRecoveryCode(ForgeError):
pass
+class InvalidEmailAuthCode(ForgeError):
+ pass
+
+
class CompoundError(ForgeError):
def __repr__(self):
diff --git a/Allura/allura/lib/multifactor.py b/Allura/allura/lib/multifactor.py
index 8368497b6..1f0045afc 100644
--- a/Allura/allura/lib/multifactor.py
+++ b/Allura/allura/lib/multifactor.py
@@ -18,6 +18,7 @@
import os
import logging
import random
+import secrets
import string
import tempfile
from collections import OrderedDict
@@ -26,7 +27,7 @@
import errno
import bson
-from allura.lib.exceptions import InvalidRecoveryCode,
MultifactorRateLimitError
+from allura.lib.exceptions import InvalidRecoveryCode,
MultifactorRateLimitError, InvalidEmailAuthCode
from tg import config
from tg import app_globals as g
from paste.deploy.converters import asint
@@ -455,3 +456,25 @@ def verify_and_remove_code(self, user, code):
# write both rate limit & recovery code changes
self.write_file(user, gaf)
raise InvalidRecoveryCode
+
+
+class EmailCodeAuthenticationService(MongodbMultifactorCommon):
+ def generate_code(self, user):
+ code_length = 6
+ code_expiration_time = 300 # seconds
+
+ code = ''.join(secrets.choice(string.digits) for i in
range(code_length))
+ code_expiration_timestamp = int(time() + code_expiration_time)
+ user.set_tool_data('AuthEmailCode', code=code,
code_expiry=code_expiration_timestamp)
+ return code
+
+ def validate_code(self, user, code):
+ user_code = user.get_tool_data('AuthEmailCode', 'code')
+ user_code_expiry = user.get_tool_data('AuthEmailCode', 'code_expiry')
+ if user_code:
+ self.enforce_rate_limit(user)
+ if user_code == code and int(time()) < user_code_expiry :
+ # remove the AuthEmailCode tool data from the user
+ user.set_tool_data('AuthEmailCode', code="", code_expiry=0)
+ return True
+ raise InvalidEmailAuthCode
\ No newline at end of file
diff --git a/Allura/allura/lib/plugin.py b/Allura/allura/lib/plugin.py
index dba9bc438..036afa276 100644
--- a/Allura/allura/lib/plugin.py
+++ b/Allura/allura/lib/plugin.py
@@ -198,6 +198,22 @@ def login(self, user: M.User = None, multifactor_success:
bool = False) -> M.Use
login_details = self.get_login_detail(self.request, user)
+ # check if the user doesn't have mfa enabled but is logging in from an
unknown location
+ # they'll get an authentication code via email
+ skip_after_login = False
+ if asbool(config.get('auth.email_auth_code.enabled', False)) and not
user.get_pref('multifactor') and not self.trusted_login_source(user,
login_details) and not multifactor_success:
+ h.auditlog_user('User without MFA attempted to login from
untrusted location', user=user)
+ self.session['multifactor-username'] = user.username
+ self.session['mode'] = 'email_code'
+ self.session.save()
+ user.send_email_auth_code()
+ return None
+ else:
+ # Validate if we used an auth code to skip the `after_login` which
sends a foreign login email
+ skip_after_login = self.session.get('mode') == 'email_code'
+ self.session.pop('multifactor-username', None)
+ self.session.pop('mode', None)
+
expire_reason = None
if self.is_password_expired(user):
h.auditlog_user('Successful login; Password expired', user=user)
@@ -212,7 +228,9 @@ def login(self, user: M.User = None, multifactor_success:
bool = False) -> M.Use
else:
self.session['username'] = user.username
h.auditlog_user('Successful login', user=user)
- self.after_login(user, self.request)
+
+ if not skip_after_login:
+ self.after_login(user, self.request)
if 'rememberme' in self.request.params:
remember_for = int(config.get('auth.remember_for', 365))
diff --git a/Allura/allura/model/auth.py b/Allura/allura/model/auth.py
index a14cbf2ad..40742060e 100644
--- a/Allura/allura/model/auth.py
+++ b/Allura/allura/model/auth.py
@@ -47,6 +47,7 @@
from allura.lib import plugin
from allura.lib import utils
from allura.lib.decorators import memoize
+from allura.lib.multifactor import EmailCodeAuthenticationService
from allura.lib.search import SearchIndexable
from .session import main_orm_session, main_explicitflush_orm_session
from .timeline import ActivityNode, ActivityObject
@@ -429,6 +430,17 @@ def make_password_reset_url(self):
reset_url = h.absurl(f'/auth/forgotten_password/{hash}')
return reset_url
+ def send_email_auth_code(self, subject_tmpl='{site_name} Authentication
Code'):
+ email_address = self.get_pref('email_address')
+ email_auth_code = EmailCodeAuthenticationService().generate_code(self)
+ subject = subject_tmpl.format(site_name=config['site_name'])
+ text =
g.jinja2_env.get_template('allura:templates/mail/authentication_code.txt').render(dict(
+ user=self,
+ config=config,
+ email_auth_code=email_auth_code,
+ ))
+ allura.tasks.mail_tasks.send_system_mail_to_user(email_address,
subject, text)
+
def can_send_user_message(self):
"""Return true if User is permitted to send a mesage to another user.
diff --git a/Allura/allura/templates/login_multifactor.html
b/Allura/allura/templates/login_multifactor.html
index 061f87469..cf05f0064 100644
--- a/Allura/allura/templates/login_multifactor.html
+++ b/Allura/allura/templates/login_multifactor.html
@@ -19,36 +19,57 @@
{% set hide_left_bar = True %}
{% extends g.theme.master %}
-{% block title %}{{ config['site_name'] }} Multifactor Login{% endblock %}
+{% block title %}{{ config['site_name'] }} Verify Your Account{% endblock %}
-{% block header %}Multifactor Login{% endblock %}
+{% block header %}Verify Your Account{% endblock %}
{% block content %}
<form method="post" action="/auth/do_multifactor">
- <h2>Enter your Multifactor Authentication Code</h2>
- <p>
- <span class="totp">Please enter the {{
config['auth.multifactor.totp.length'] }}-digit code from your authenticator
app:</span>
- <span class="recovery">Please enter a recovery code:</span>
- <br>
- {% if request.validation.errors['code'] %}
- <span class="fielderror">{{ request.validation.errors['code']
}}</span><br>
- {% endif %}
- <input type="text" name="code" autofocus autocomplete="off"/>
- <input type="hidden" name="return_to" value="{{ return_to }}"/>
- <br>
- <input type="submit" value="Log In">
- <span class="alternate-links">
- <span class="totp">
- or <a href="#" class="show-recovery">use a recovery code</a>
- </span>
- <span class="recovery">
- or <a href="#" class="show-totp">use an authenticator app code</a>
+ {% if mode == "email_code" %}
+ <h2>E-mail Code Authentication</h2>
+ <p>
+ <span>Please enter the 6-digit code sent to your email:</span>
+ <br>
+ {% if request.validation.errors['code'] %}
+ <span class="fielderror">{{ request.validation.errors['code']
}}</span><br>
+ {% endif %}
+ <input id="auth_code" type="text" name="code" autofocus
autocomplete="off"/>
+ <input type="hidden" name="return_to" value="{{ return_to }}"/>
+ <br>
+ <input type="submit" value="Log In">
+ <a href="/auth/logout?return_to=/auth/{{ '?return_to=' + return_to |
urlencode }}">
+ <input type="button" value="Cancel">
+ </a>
+ <input type="hidden" name="mode" value="{{ mode }}"/>
+ {{ lib.csrf_token() }}
+ </p>
+ {% else %}
+ <h2>Enter your Multifactor Authentication Code</h2>
+ <p>
+ <span class="totp">Please enter the {{
config['auth.multifactor.totp.length'] }}-digit code from your authenticator
app:</span>
+ <span class="recovery">Please enter a recovery code:</span>
+ <br>
+ {% if request.validation.errors['code'] %}
+ <span class="fielderror">{{ request.validation.errors['code']
}}</span><br>
+ {% endif %}
+ <input type="text" name="code" autofocus autocomplete="off"/>
+ <input type="hidden" name="return_to" value="{{ return_to }}"/>
+ <br>
+ <input type="submit" value="Log In">
+ <span class="alternate-links">
+ <span class="totp">
+ or <a href="#" class="show-recovery">use a recovery code</a>
+ </span>
+ <span class="recovery">
+ or <a href="#" class="show-totp">use an authenticator app
code</a>
+ </span>
</span>
- </span>
- or <a href="/auth/logout?return_to=/auth/">Cancel</a>
- <input type="hidden" name="mode" value="{{ mode }}"/>
- {{ lib.csrf_token() }}
- </p>
+ or <a href="/auth/logout?return_to=/auth/{{ '?return_to=' +
return_to | urlencode }}">Cancel</a>
+ <input type="hidden" name="mode" value="{{ mode }}"/>
+ {{ lib.csrf_token() }}
+ </p>
+ {% endif %}
+
</form>
{% endblock %}
@@ -63,6 +84,15 @@
display: inline-block;
margin: 6px 10px;
}
+
+ input[type="button"] {
+ padding: 1.07143rem;
+ outline: 0;
+ line-height: 1em;
+ min-height: 14px;
+ box-shadow: none;
+ text-align: center;
+ }
</style>
{% endblock %}
diff --git a/Allura/allura/templates/mail/authentication_code.txt
b/Allura/allura/templates/mail/authentication_code.txt
new file mode 100644
index 000000000..9f4637116
--- /dev/null
+++ b/Allura/allura/templates/mail/authentication_code.txt
@@ -0,0 +1,26 @@
+{#
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements. See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership. The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing,
+ software distributed under the License is distributed on an
+ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ KIND, either express or implied. See the License for the
+ specific language governing permissions and limitations
+ under the License.
+-#}
+
+An attempt to sign-in to your {{ config['site_name'] }} account was detected.
+
+If this was you, use the following verification code:
+
+{{ email_auth_code }}
+
+If you didn't request it, click <a href="/auth/forgotten_password">here</a> to
reset your password immediately
diff --git a/Allura/allura/tests/functional/test_auth.py
b/Allura/allura/tests/functional/test_auth.py
index f5a88ce1f..8ead0e334 100644
--- a/Allura/allura/tests/functional/test_auth.py
+++ b/Allura/allura/tests/functional/test_auth.py
@@ -3333,3 +3333,127 @@ def test_send_links(self):
assert tasks[0].kwargs['subject'] == 'Two-Factor Authentication Apps'
assert 'itunes.apple.com' in tasks[0].kwargs['text']
assert 'play.google.com' in tasks[0].kwargs['text']
+
+
+class TestEmailAuthCode(TestController):
+
+ @mock.patch.dict(config, {'auth.email_auth_code.enabled': True})
+ @patch('allura.tasks.mail_tasks.send_system_mail_to_user')
+ def test_email_auth_code_with_mfa(self, send_system_mail_to_user):
+ self.app.get('/auth/preferences/') # establish session_id cookie
+ user = M.User.by_username('test-user')
+ user.set_pref('multifactor', True)
+
+ with
patch('allura.lib.plugin.AuthenticationProvider.trusted_login_source',
return_value=True):
+ extra = {'username': '*anonymous'}
+ r = self.app.get('/auth/', extra_environ=extra)
+
+ f = r.forms[0]
+ encoded = self.app.antispam_field_names(f)
+ f[encoded['username']] = 'test-user'
+ f[encoded['password']] = 'foo'
+ r = f.submit(status=302)
+ assert send_system_mail_to_user.call_count == 0
+ assert r.session.get('mode') is None
+
+ @mock.patch.dict(config, {'auth.email_auth_code.enabled': True})
+ @patch('allura.tasks.mail_tasks.send_system_mail_to_user')
+ def test_email_auth_code_no_mfa(self, send_system_mail_to_user):
+ self.app.get('/auth/preferences/') # establish session_id cookie
+ self.app.extra_environ = {'username': '*anonymous'}
+ user = M.User.by_username('test-user')
+ user.set_pref('multifactor', False)
+
+ with
patch('allura.lib.plugin.AuthenticationProvider.trusted_login_source',
return_value=False):
+ r = self.app.get('/auth/')
+ f = r.forms[0]
+ encoded = self.app.antispam_field_names(f)
+ f[encoded['username']] = 'test-user'
+ f[encoded['password']] = 'foo'
+ r = f.submit(status=302)
+ r = r.follow()
+
+ # Validate the email with the auth code was sent
+ args, kwargs = send_system_mail_to_user.call_args
+ assert r.session.get('mode') == 'email_code'
+ assert args[1] == f"{config['site_name']} Authentication Code"
+ assert send_system_mail_to_user.call_count == 1
+
+ # Validate that the user is directed to the authentication code
page
+ assert 'Please enter the 6-digit code sent to your email' in r.text
+
+ @mock.patch.dict(config, {'auth.email_auth_code.enabled': True})
+ def test_input_correct_auth_code(self):
+ self.app.get('/auth/preferences/')
+ self.app.extra_environ = {'username': '*anonymous'}
+ user = M.User.by_username('test-user')
+ user.set_pref('multifactor', False)
+
+ with
patch('allura.lib.plugin.AuthenticationProvider.trusted_login_source',
return_value=False):
+ r = self.app.get('/auth/')
+ f = r.forms[0]
+ encoded = self.app.antispam_field_names(f)
+ f[encoded['username']] = 'test-user'
+ f[encoded['password']] = 'foo'
+ r = f.submit(status=302)
+ r = r.follow()
+
+ user = M.User.by_username('test-user')
+ user_code = user.get_tool_data('AuthEmailCode', 'code')
+ user_expiry_ts = user.get_tool_data('AuthEmailCode', 'code_expiry')
+ # Make sure a 6-digit code was generated
+ assert len(str(user_code)) == 6
+
+ # Validate the auth code is still valid
+ assert int(time_time()) < int(user_expiry_ts)
+
+ # Submit the code and validate the user is logged in
+ r.form['code'] = user_code
+ r = r.form.submit(status=302)
+ assert r.location == 'http://localhost/'
+
+ @mock.patch.dict(config, {'auth.email_auth_code.enabled': True})
+ def test_input_incorrect_auth_code(self):
+ self.app.get('/auth/preferences/')
+ self.app.extra_environ = {'username': '*anonymous'}
+ user = M.User.by_username('test-user')
+ user.set_pref('multifactor', False)
+
+ with
patch('allura.lib.plugin.AuthenticationProvider.trusted_login_source',
return_value=False):
+ r = self.app.get('/auth/')
+ f = r.forms[0]
+ encoded = self.app.antispam_field_names(f)
+ f[encoded['username']] = 'test-user'
+ f[encoded['password']] = 'foo'
+ r = f.submit(status=302)
+ r = r.follow()
+
+ # Submit an incorrect code
+ r.form['code'] = '1234567'
+ r = r.form.submit()
+ assert 'Invalid code' in r.text
+
+ @mock.patch.dict(config, {'auth.email_auth_code.enabled': True})
+ def test_input_expired_auth_code(self):
+ self.app.get('/auth/preferences/')
+ self.app.extra_environ = {'username': '*anonymous'}
+ user = M.User.by_username('test-user')
+ user.set_pref('multifactor', False)
+
+ with
patch('allura.lib.plugin.AuthenticationProvider.trusted_login_source',
return_value=False):
+ r = self.app.get('/auth/')
+ f = r.forms[0]
+ encoded = self.app.antispam_field_names(f)
+ f[encoded['username']] = 'test-user'
+ f[encoded['password']] = 'foo'
+ r = f.submit(status=302)
+ r = r.follow()
+
+ # Set the code expiration to 5 minutes ago
+ user = M.User.by_username('test-user')
+ user.set_tool_data('AuthEmailCode', code_expiry=int(time_time()) -
300)
+ user_code = user.get_tool_data('AuthEmailCode', 'code')
+ r.form['code'] = user_code
+ r = r.form.submit()
+
+ assert 'Invalid code' in r.text
diff --git a/Allura/development.ini b/Allura/development.ini
index 3336afe83..92914e8ef 100644
--- a/Allura/development.ini
+++ b/Allura/development.ini
@@ -267,6 +267,9 @@ auth.auth.trust_ip_3_octets_match = true
; Enable OAuth2 support
auth.oauth2.enabled = true
+; Enable login by email authenticatin code
+auth.email_auth_code.enabled = true
+
user_prefs_storage.method = local
; user_prefs_storage.method = ldap
; If using ldap, you can specify which fields to use for a preference.
diff --git a/Allura/test.ini b/Allura/test.ini
index c46c0646a..037bc924f 100644
--- a/Allura/test.ini
+++ b/Allura/test.ini
@@ -87,11 +87,15 @@ auth.require_email_addr = false
forgemail.host = 127.0.0.1
forgemail.port = 8827
+; Disable emailing authentication codes during tests
+auth.email_auth_code.enabled = false
+
[app:task]
use = main
; TurboGears will use controllers/task.py as root controller
override_root = task
+
;
; Logging goes to a test.log file in current directory
;