You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@allura.apache.org by di...@apache.org on 2026/05/01 18:20:56 UTC

(allura) 01/07: [#8601] use link instead of code for email auth, and longer code

This is an automated email from the ASF dual-hosted git repository.

asf-gitbox-commits pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/allura.git

commit b14f36cf8f7a27b35138a4506275f628b48b45de
Author: Dave Brondsema <da...@brondsema.net>
AuthorDate: Tue Apr 21 11:52:52 2026 -0400

    [#8601] use link instead of code for email auth, and longer code
---
 Allura/allura/controllers/auth.py                  |  71 +++++++++----
 Allura/allura/lib/multifactor.py                   |  34 +++----
 Allura/allura/lib/plugin.py                        |  13 ++-
 Allura/allura/model/auth.py                        |  18 +++-
 ...thentication_code.txt => login_email_sent.html} |  21 ++--
 Allura/allura/templates/login_multifactor.html     |  66 +++++-------
 .../allura/templates/mail/authentication_code.txt  |   6 +-
 Allura/allura/tests/functional/test_auth.py        | 112 ++++++++++-----------
 8 files changed, 190 insertions(+), 151 deletions(-)

diff --git a/Allura/allura/controllers/auth.py b/Allura/allura/controllers/auth.py
index fee01427a..6a3a3bc51 100644
--- a/Allura/allura/controllers/auth.py
+++ b/Allura/allura/controllers/auth.py
@@ -16,6 +16,7 @@
 #       under the License.
 from __future__ import annotations
 
+import hmac
 import logging
 import json
 import os
@@ -27,7 +28,7 @@
 import bson
 import formencode as fe
 import tg
-from tg import expose, flash, redirect, validate, config, session
+from tg import expose, flash, redirect, validate, config, session, override_template
 from tg.decorators import with_trailing_slash, without_trailing_slash
 from tg import tmpl_context as c, app_globals as g
 from tg import request, response
@@ -404,8 +405,10 @@ 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))
+        if session.get('mode') == 'email_code':
+            location = tg.url('/auth/login_email_sent', dict(return_to=return_to) if return_to else {})
+        elif session.get('multifactor-username'):
+            location = tg.url('/auth/multifactor', dict(return_to=return_to) if return_to else {})
         elif session.get('expired-username'):
             if return_to and return_to not in plugin.AuthenticationProvider.pwd_expired_allowed_urls:
                 location = tg.url(plugin.AuthenticationProvider.pwd_expired_allowed_urls[0], dict(return_to=return_to))
@@ -418,15 +421,17 @@ def do_login(self, return_to=None, **kw):
 
     @expose('jinja:allura:templates/login_multifactor.html')
     def multifactor(self, return_to='', mode='totp', **kwargs):
-        if not asbool(config.get('auth.multifactor.totp', False)):
-            raise wexc.HTTPNotFound
-
         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'
+
+        # this URL is also used for email code verification, but there's a different template to use
+        if session.get('mode') == 'email_code' and asbool(config.get('auth.email_auth_code.enabled', False)):
+            override_template(self.multifactor, 'jinja:allura:templates/login_email_sent.html')
+            return dict()
+
+        if not asbool(config.get('auth.multifactor.totp', False)):
+            raise wexc.HTTPNotFound
 
         return dict(
             return_to=return_to,
@@ -445,7 +450,7 @@ def do_multifactor(self, code, mode, **kwargs):
 
         if 'multifactor-username' not in session:
             tg.flash('Your multifactor login was disrupted, please start over.', 'error')
-            plugin.AuthenticationProvider.get(request).logout()  # clears all cookies that might be interfering
+            plugin.AuthenticationProvider.get(request).logout()  # clears all cookies that might be interfering - also the flash message cookie :(
             redirect('/auth/', {'return_to': kwargs.get('return_to', '')})
 
         user = M.User.by_username(session['multifactor-username'])
@@ -458,10 +463,6 @@ 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)
@@ -470,15 +471,49 @@ 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'))
             redirect(return_to)
 
+    @expose('jinja:allura:templates/login_email_sent.html')
+    def login_email_sent(self, return_to='/', **kwargs):
+        if not asbool(config.get('auth.email_auth_code.enabled', False)):
+            raise wexc.HTTPNotFound
+        if not c.user.is_anonymous():
+            redirect(self._verify_return_to(return_to))
+        if 'multifactor-username' not in session:
+            redirect('/auth/')
+        return dict()
+
+    @expose()
+    def login_email_verify(self, token, return_to='/', **kwargs):
+        if not asbool(config.get('auth.email_auth_code.enabled', False)):
+            raise wexc.HTTPNotFound
+        if not c.user.is_anonymous():
+            redirect(self._verify_return_to(return_to))
+
+        if not session.get('multifactor-username'):
+            tg.flash('Your login session was disrupted.  Be sure to use the same browser when logging in and opening the email verification link.', 'error')
+            redirect('/auth/', {'return_to': kwargs['return_to']} if kwargs.get('return_to') else {})
+
+        user = M.User.by_username(session['multifactor-username'])
+        if not user:
+            redirect('/auth/')
+
+        try:
+            EmailCodeAuthenticationService().validate_token(user, token)
+        except InvalidEmailAuthCode:
+            tg.flash('Invalid or expired login link.', 'error')
+            session.pop('multifactor-username', None)
+            session.pop('mode', None)
+            session.save()
+            redirect('/auth/')
+
+        h.auditlog_user('Logged in using email authentication link', user=user)
+        plugin.AuthenticationProvider.get(request).login(user=user, multifactor_success=True)
+        redirect(self._verify_return_to(return_to))
+
     @expose(content_type='text/plain')
     def refresh_repo(self, *repo_path):
         # post-commit hooks use this
diff --git a/Allura/allura/lib/multifactor.py b/Allura/allura/lib/multifactor.py
index 053c670e6..1a46fe05f 100644
--- a/Allura/allura/lib/multifactor.py
+++ b/Allura/allura/lib/multifactor.py
@@ -14,7 +14,7 @@
 #       KIND, either express or implied.  See the License for the
 #       specific language governing permissions and limitations
 #       under the License.
-
+import hmac
 import os
 import logging
 import random
@@ -458,23 +458,21 @@ def verify_and_remove_code(self, user, code):
         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:
+class EmailCodeAuthenticationService:
+    def generate_token(self, user):
+        token_expiration_secs = 10 * 60
+        token = secrets.token_urlsafe(nbytes=8)
+        token_expiration_timestamp = int(time() + token_expiration_secs)
+        user.set_tool_data('AuthEmailCode', token=token, token_expiry=token_expiration_timestamp)
+        return token
+
+    def validate_token(self, user, token):
+        user_token = user.get_tool_data('AuthEmailCode', 'token')
+        user_token_expiry = user.get_tool_data('AuthEmailCode', 'token_expiry')
+        if user_token:
+            # compare_digest more secure than == (against timing analysis attacks)
+            if hmac.compare_digest(user_token, token) and int(time()) < user_token_expiry:
                 # remove the AuthEmailCode tool data from the user
-                user.set_tool_data('AuthEmailCode', code="", code_expiry=0)
+                user.set_tool_data('AuthEmailCode', token='', token_expiry=0)
                 return True
         raise InvalidEmailAuthCode
diff --git a/Allura/allura/lib/plugin.py b/Allura/allura/lib/plugin.py
index 216d516e2..476c1bf69 100644
--- a/Allura/allura/lib/plugin.py
+++ b/Allura/allura/lib/plugin.py
@@ -96,6 +96,8 @@ class AuthenticationProvider:
     multifactor_allowed_urls = [
         '/auth/multifactor',
         '/auth/do_multifactor',
+        '/auth/login_email_sent',
+        '/auth/login_email_verify',
         '/auth/logout',
     ]
     cfg_prefix_pwds = 'auth.password.'
@@ -188,7 +190,7 @@ def register_user(self, user_doc):
         '''
         raise NotImplementedError('register_user')
 
-    def _login(self):
+    def _login(self) -> M.User:
         '''
         Authorize a user, usually using ``self.request.params['username']`` and ``['password']``
 
@@ -212,6 +214,9 @@ def login(self, user: M.User = None, multifactor_success: bool = False) -> M.Use
                 h.auditlog_user('Failed login', user=M.User.by_username(self.request.params['username']))
                 raise
 
+        if user.is_anonymous():
+            raise ValueError('Cannot log in an anonymous user')
+
         if user.get_pref('multifactor') and not multifactor_success:
             self.session['multifactor-username'] = user.username
             h.auditlog_user('Multifactor login - password ok, code not entered yet', user=user)
@@ -232,7 +237,7 @@ def login(self, user: M.User = None, multifactor_success: bool = False) -> M.Use
             self.session['multifactor-username'] = user.username
             self.session['mode'] = 'email_code'
             self.session.save()
-            user.send_email_auth_code()
+            user.send_email_auth_code(return_to=self.request.params.get('return_to', '/'))
             return None
         else:
             # Validate if we used an auth code to skip the `after_login` which sends a foreign login email
@@ -651,7 +656,7 @@ def register_user(self, user_doc):
             u.reg_date = datetime.utcnow()
         return u
 
-    def _login(self):
+    def _login(self) -> M.User:
         user = self.by_username(self.request.params['username'])
         if not self.validate_password(user, self.request.params['password']):
             raise exc.HTTPUnauthorized()
@@ -907,7 +912,7 @@ def set_password(self, user, old_password, new_password, set_timestamp=True):
         except ldap.INVALID_CREDENTIALS:
             raise exc.HTTPUnauthorized()
 
-    def _login(self):
+    def _login(self) -> M.User:
         if ldap is None:
             raise Exception('The python-ldap package needs to be installed.  '
                             'Run `pip install python-ldap` in your allura environment.')
diff --git a/Allura/allura/model/auth.py b/Allura/allura/model/auth.py
index 0b331ed3a..70990552c 100644
--- a/Allura/allura/model/auth.py
+++ b/Allura/allura/model/auth.py
@@ -20,8 +20,9 @@
 import logging
 import typing
 
+import tg
 from markupsafe import Markup
-from urllib.parse import urlparse
+from urllib.parse import urlparse, urlencode
 from email import header
 from hashlib import sha256
 from collections import deque
@@ -470,14 +471,23 @@ 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'):
+    def send_email_auth_code(self, return_to='/', subject_tmpl='{site_name} Authentication Code'):
+        from allura.controllers.auth import AuthController
+
         email_address = self.get_pref('email_address')
-        email_auth_code = EmailCodeAuthenticationService().generate_code(self)
+        token = EmailCodeAuthenticationService().generate_token(self)
+        return_to = AuthController._verify_return_to(return_to)
+        link_params = {
+            'token': token,
+        }
+        if return_to and return_to != '/':
+            link_params['return_to'] = return_to
+        verify_url = h.absurl(tg.url('/auth/login_email_verify', link_params))
         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,
+            verify_url=verify_url,
         ))
         allura.tasks.mail_tasks.send_system_mail_to_user(email_address, subject, text)
 
diff --git a/Allura/allura/templates/mail/authentication_code.txt b/Allura/allura/templates/login_email_sent.html
similarity index 61%
copy from Allura/allura/templates/mail/authentication_code.txt
copy to Allura/allura/templates/login_email_sent.html
index 9f4637116..9152677cc 100644
--- a/Allura/allura/templates/mail/authentication_code.txt
+++ b/Allura/allura/templates/login_email_sent.html
@@ -1,4 +1,4 @@
-{#
+{#-
        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
@@ -16,11 +16,20 @@
        specific language governing permissions and limitations
        under the License.
 -#}
+{% set hide_left_bar = True %}
+{% extends g.theme.master %}
 
-An attempt to sign-in to your {{ config['site_name'] }} account was detected.
+{% block title %}{{ config['site_name'] }} Verify Your Sign-In{% endblock %}
 
-If this was you, use the following verification code:
+{% block header %}Verify Your Sign-In{% endblock %}
 
-{{ email_auth_code }}
-
-If you didn't request it, click <a href="/auth/forgotten_password">here</a> to reset your password immediately
+{% block content %}
+<h2>Check Your Email</h2>
+<p>A sign-in verification link has been sent to your email address.</p>
+<p>Click the link in the email to complete your sign-in. The link expires in 10 minutes.</p>
+<p>
+    <a href="/auth/logout?return_to=/auth/">
+        <input type="button" value="Log Out and Start Over">
+    </a>
+</p>
+{% endblock %}
diff --git a/Allura/allura/templates/login_multifactor.html b/Allura/allura/templates/login_multifactor.html
index cf05f0064..579a482df 100644
--- a/Allura/allura/templates/login_multifactor.html
+++ b/Allura/allura/templates/login_multifactor.html
@@ -25,50 +25,30 @@
 
 {% block content %}
 <form method="post" action="/auth/do_multifactor">
-    {% 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>
-            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>
+    <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>
+        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>
 
 </form>
 
diff --git a/Allura/allura/templates/mail/authentication_code.txt b/Allura/allura/templates/mail/authentication_code.txt
index 9f4637116..e359b0d77 100644
--- a/Allura/allura/templates/mail/authentication_code.txt
+++ b/Allura/allura/templates/mail/authentication_code.txt
@@ -19,8 +19,10 @@
 
 An attempt to sign-in to your {{ config['site_name'] }} account was detected.
 
-If this was you, use the following verification code:
+If this was you, click the link below to complete your sign-in:
 
-{{ email_auth_code }}
+{{ verify_url }}
+
+This link expires in 10 minutes.
 
 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 e28d4dabd..524d95820 100644
--- a/Allura/allura/tests/functional/test_auth.py
+++ b/Allura/allura/tests/functional/test_auth.py
@@ -2958,12 +2958,13 @@ def test_settings_off(self):
                         '/auth/preferences/multifactor_disable',
                         '/auth/preferences/multifactor_recovery',
                         '/auth/preferences/multifactor_recovery_regen',
-                        '/auth/multifactor',
                         '/auth/do_multifactor',
                         ]:
                 self.app.post(url,
                               {'password': 'foo', '_csrf_token': self.app.cookies['_csrf_token']},
                               status=404)
+            self.app.get('/auth/multifactor/', status=404,
+                         extra_environ={'username': '*anonymous'},)
 
     def test_user_disabled(self):
         r = self.app.get('/auth/preferences/')
@@ -3357,14 +3358,13 @@ 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
+    def test_mfa_no_email_auth(self, send_system_mail_to_user):
+        self.app.extra_environ = {'disable_auth_magic': 'True'}
         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)
+            r = self.app.get('/auth/')
 
             f = r.forms[0]
             encoded = self.app.antispam_field_names(f)
@@ -3376,14 +3376,13 @@ def test_email_auth_code_with_mfa(self, send_system_mail_to_user):
 
     @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'}
+    def test_email_auth_code(self, send_system_mail_to_user):
+        self.app.extra_environ = {'disable_auth_magic': 'True'}
         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/')
+            r = self.app.get('/auth/?return_to=/p/test/')
             f = r.forms[0]
             encoded = self.app.antispam_field_names(f)
             f[encoded['username']] = 'test-user'
@@ -3391,49 +3390,47 @@ def test_email_auth_code_no_mfa(self, send_system_mail_to_user):
             r = f.submit(status=302)
             r = r.follow()
 
-            # Validate the email with the auth code was sent
+            # Validate that the user is directed to the "check your email" page
+            assert 'Check Your Email' in r.text
+            assert r.request.path == '/auth/login_email_sent'
+
+            user = M.User.by_username('test-user')
+            user_token = user.get_tool_data('AuthEmailCode', 'token')
+            user_expiry_ts = user.get_tool_data('AuthEmailCode', 'token_expiry')
+
+            # Make sure a token was generated
+            assert user_token
+            assert len(user_token) > 10
+
+            # Validate the token is still valid
+            assert int(time_time()) < int(user_expiry_ts)
+
+            # Validate the email with the verification link 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
+            email_text = send_system_mail_to_user.call_args[0][2]
 
-            # 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
+            link = [line for line in email_text.splitlines() if '/auth/login_email_verify' in line][0]
+            assert f'token={user_token}' in link
 
-    @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)
+            # Click the verification link and validate the user is logged in and at final return_to URL
+            r = self.app.get(link, status=302)
+            assert r.location == 'http://localhost/p/test/'
+            assert r.session.get('username') == 'test-user'
 
-        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_auth_token_no_session(self):
+        self.app.extra_environ = {'disable_auth_magic': 'True'}
+        r = self.app.get('/auth/login_email_verify?token=badtoken', status=302)
+        r = r.follow()
+        assert 'Your login session was disrupted' in r.text
+        assert not r.session.get('username')
 
     @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'}
+    def test_input_incorrect_auth_token(self):
+        self.app.extra_environ = {'disable_auth_magic': 'True'}
         user = M.User.by_username('test-user')
         user.set_pref('multifactor', False)
 
@@ -3445,16 +3442,17 @@ def test_input_incorrect_auth_code(self):
             f[encoded['password']] = 'foo'
             r = f.submit(status=302)
             r = r.follow()
+            r.mustcontain('Check Your Email')
 
-            # Submit an incorrect code
-            r.form['code'] = '1234567'
-            r = r.form.submit()
-            assert 'Invalid code' in r.text
+            # Use an incorrect token
+            r = self.app.get('/auth/login_email_verify?token=badtoken', status=302)
+            r = r.follow()
+            assert 'Invalid or expired login link' in r.text
+            assert not r.session.get('username')
 
     @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'}
+    def test_input_expired_auth_token(self):
+        self.app.extra_environ = {'disable_auth_magic': 'True'}
         user = M.User.by_username('test-user')
         user.set_pref('multifactor', False)
 
@@ -3466,15 +3464,17 @@ def test_input_expired_auth_code(self):
             f[encoded['password']] = 'foo'
             r = f.submit(status=302)
             r = r.follow()
+            r.mustcontain('Check Your Email')
 
-            # Set the code expiration to 5 minutes ago
+            # Set the token expiration to 15 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()
+            user_token = user.get_tool_data('AuthEmailCode', 'token')
+            user.set_tool_data('AuthEmailCode', token_expiry=int(time_time()) - 900)
 
-            assert 'Invalid code' in r.text
+            r = self.app.get(f'/auth/login_email_verify?token={user_token}', status=302)
+            r = r.follow()
+            assert 'Invalid or expired login link' in r.text
+            assert not r.session.get('username')
 
 
 class TestTrackUserSessions(TestController):