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
 ;

Reply via email to