You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@allura.apache.org by br...@apache.org on 2016/09/08 19:44:24 UTC

[2/4] allura git commit: [#8118] 2fa recovery codes

[#8118] 2fa recovery codes


Project: http://git-wip-us.apache.org/repos/asf/allura/repo
Commit: http://git-wip-us.apache.org/repos/asf/allura/commit/c044a01d
Tree: http://git-wip-us.apache.org/repos/asf/allura/tree/c044a01d
Diff: http://git-wip-us.apache.org/repos/asf/allura/diff/c044a01d

Branch: refs/heads/master
Commit: c044a01dacbdf6d0d3a3b2886848bb8cf0a730d4
Parents: d240da9
Author: Dave Brondsema <da...@brondsema.net>
Authored: Mon Sep 5 14:49:12 2016 -0400
Committer: Dave Brondsema <da...@brondsema.net>
Committed: Wed Sep 7 13:54:24 2016 -0400

----------------------------------------------------------------------
 Allura/allura/controllers/auth.py               |  57 ++++++++--
 Allura/allura/lib/app_globals.py                |   3 +-
 Allura/allura/lib/exceptions.py                 |   6 +-
 Allura/allura/lib/helpers.py                    |  12 +-
 Allura/allura/lib/multifactor.py                | 106 ++++++++++++++++-
 Allura/allura/model/multifactor.py              |  15 +++
 Allura/allura/templates/login_multifactor.html  |  47 +++++++-
 .../templates/mail/twofactor_recovery_regen.md  |  27 +++++
 Allura/allura/templates/user_prefs.html         |   4 +
 .../allura/templates/user_recovery_codes.html   | 114 +++++++++++++++++++
 Allura/allura/tests/functional/test_auth.py     |  73 +++++++++++-
 Allura/allura/tests/test_helpers.py             |   7 +-
 Allura/allura/tests/test_multifactor.py         |  72 ++++++++++++
 Allura/development.ini                          |   8 ++
 Allura/setup.py                                 |   4 +
 15 files changed, 534 insertions(+), 21 deletions(-)
----------------------------------------------------------------------


http://git-wip-us.apache.org/repos/asf/allura/blob/c044a01d/Allura/allura/controllers/auth.py
----------------------------------------------------------------------
diff --git a/Allura/allura/controllers/auth.py b/Allura/allura/controllers/auth.py
index 5276682..b37a288 100644
--- a/Allura/allura/controllers/auth.py
+++ b/Allura/allura/controllers/auth.py
@@ -23,6 +23,7 @@ from urlparse import urlparse, urljoin
 
 import bson
 import tg
+from allura.lib.exceptions import InvalidRecoveryCode
 from tg import expose, flash, redirect, validate, config, session
 from tg.decorators import with_trailing_slash, without_trailing_slash
 from pylons import tmpl_context as c, app_globals as g
@@ -48,7 +49,7 @@ from allura.lib.widgets import (
     DisableAccountForm)
 from allura.lib.widgets import forms, form_fields as ffw
 from allura.lib import mail_util
-from allura.lib.multifactor import TotpService
+from allura.lib.multifactor import TotpService, RecoveryCodeService
 from allura.controllers import BaseController
 from allura.tasks.mail_tasks import send_system_mail_to_user
 
@@ -321,26 +322,32 @@ class AuthController(BaseController):
         redirect(location)
 
     @expose('jinja:allura:templates/login_multifactor.html')
-    def multifactor(self, return_to='', **kwargs):
+    def multifactor(self, return_to='', mode='totp', **kwargs):
         return dict(
             return_to=return_to,
+            mode=mode,
         )
 
     @expose('jinja:allura:templates/login_multifactor.html')
     @require_post()
-    def do_multifactor(self, code, **kwargs):
+    def do_multifactor(self, code, mode, **kwargs):
         if 'multifactor-username' not in session:
             tg.flash('Your multifactor login was disrupted, please start over.', 'error')
             redirect('/auth/', return_to=kwargs.get('return_to', ''))
 
         user = M.User.by_username(session['multifactor-username'])
-        totp_service = TotpService.get()
-        totp = totp_service.get_totp(user)
         try:
-            totp_service.verify(totp, code)
-        except InvalidToken:
+            if mode == 'totp':
+                totp_service = TotpService.get()
+                totp = totp_service.get_totp(user)
+                totp_service.verify(totp, code)
+            elif mode == 'recovery':
+                recovery = RecoveryCodeService.get()
+                recovery.verify_and_remove_code(user, code)
+                h.auditlog_user('Logged in using a multifactor recovery code', user=user)
+        except (InvalidToken, InvalidRecoveryCode):
             c.form_errors['code'] = 'Invalid code, please try again.'
-            return self.multifactor(**kwargs)
+            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'))
@@ -701,7 +708,7 @@ class PreferencesController(BaseController):
                 config=config,
             ))
             send_system_mail_to_user(c.user, u'Two-Factor Authentication Enabled', email_body)
-            redirect('.')
+            redirect('/auth/preferences/multifactor_recovery')
 
     @expose()
     @require_post()
@@ -710,6 +717,8 @@ class PreferencesController(BaseController):
         h.auditlog_user('Disabled multifactor TOTP')
         totp_service = TotpService.get()
         totp_service.set_secret_key(c.user, None)
+        recovery = RecoveryCodeService.get()
+        recovery.delete_all(c.user)
         c.user.set_pref('multifactor', False)
         tg.flash('Multifactor authentication has now been disabled.')
         email_body = g.jinja2_env.get_template('allura:templates/mail/twofactor_disabled.md').render(dict(
@@ -728,6 +737,36 @@ class PreferencesController(BaseController):
         ))
         send_system_mail_to_user(c.user, u'Two-Factor Authentication Apps', email_body)
 
+    @expose('jinja:allura:templates/user_recovery_codes.html')
+    @reconfirm_auth
+    @without_trailing_slash
+    def multifactor_recovery(self, **kw):
+        if not c.user.get_pref('multifactor'):
+            redirect('.')
+        recovery = RecoveryCodeService.get()
+        codes = recovery.get_codes(c.user)
+        if not codes:
+            codes = recovery.regenerate_codes(c.user)
+        h.auditlog_user('Viewed multifactor recovery codes')
+        return dict(
+            codes=codes,
+        )
+
+    @expose()
+    @require_post()
+    @reconfirm_auth
+    def multifactor_recovery_regen(self, **kw):
+        recovery = RecoveryCodeService.get()
+        recovery.regenerate_codes(c.user)
+        email_body = g.jinja2_env.get_template('allura:templates/mail/twofactor_recovery_regen.md').render(dict(
+            user=c.user,
+            config=config,
+        ))
+        h.auditlog_user('Regenerated multifactor recovery codes')
+        send_system_mail_to_user(c.user, u'Two-Factor Recovery Codes Regenerated', email_body)
+        tg.flash('Your recovery codes have been regenerated.  Save the new codes!')
+        redirect('/auth/preferences/multifactor_recovery')
+
 
 class UserInfoController(BaseController):
 

http://git-wip-us.apache.org/repos/asf/allura/blob/c044a01d/Allura/allura/lib/app_globals.py
----------------------------------------------------------------------
diff --git a/Allura/allura/lib/app_globals.py b/Allura/allura/lib/app_globals.py
index d390e43..2e0307d 100644
--- a/Allura/allura/lib/app_globals.py
+++ b/Allura/allura/lib/app_globals.py
@@ -306,7 +306,8 @@ class Globals(object):
             # imported (after load, the ep itself is not used)
             macros=_cache_eps('allura.macros'),
             webhooks=_cache_eps('allura.webhooks'),
-            multifactor_totp=_cache_eps('allura.multifactor.totp')
+            multifactor_totp=_cache_eps('allura.multifactor.totp'),
+            multifactor_recovery_code=_cache_eps('allura.multifactor.recovery_code'),
         )
 
         # Neighborhood cache

http://git-wip-us.apache.org/repos/asf/allura/blob/c044a01d/Allura/allura/lib/exceptions.py
----------------------------------------------------------------------
diff --git a/Allura/allura/lib/exceptions.py b/Allura/allura/lib/exceptions.py
index 45843bf..e43a8fc 100644
--- a/Allura/allura/lib/exceptions.py
+++ b/Allura/allura/lib/exceptions.py
@@ -15,7 +15,7 @@
 #       specific language governing permissions and limitations
 #       under the License.
 
-import webob
+import webob.exc
 from formencode import Invalid
 
 
@@ -84,6 +84,10 @@ class InvalidNBFeatureValueError(ForgeError):
     pass
 
 
+class InvalidRecoveryCode(ForgeError):
+    pass
+
+
 class CompoundError(ForgeError):
 
     def __repr__(self):

http://git-wip-us.apache.org/repos/asf/allura/blob/c044a01d/Allura/allura/lib/helpers.py
----------------------------------------------------------------------
diff --git a/Allura/allura/lib/helpers.py b/Allura/allura/lib/helpers.py
index 5148f17..f06da1e 100644
--- a/Allura/allura/lib/helpers.py
+++ b/Allura/allura/lib/helpers.py
@@ -1289,8 +1289,12 @@ def rate_limit(cfg_opt, artifact_count, start_date, exception=None):
                 raise exception()
 
 
-def base64uri(image, format='PNG', mimetype='image/png'):
-    output = StringIO()
-    image.save(output, format=format)
-    data = base64.b64encode(output.getvalue())
+def base64uri(content_or_image, image_format='PNG', mimetype='image/png'):
+    if hasattr(content_or_image, 'save'):
+        output = StringIO()
+        content_or_image.save(output, format=image_format)
+        content = output.getvalue()
+    else:
+        content = content_or_image
+    data = base64.b64encode(content)
     return 'data:{};base64,{}'.format(mimetype, data)
\ No newline at end of file

http://git-wip-us.apache.org/repos/asf/allura/blob/c044a01d/Allura/allura/lib/multifactor.py
----------------------------------------------------------------------
diff --git a/Allura/allura/lib/multifactor.py b/Allura/allura/lib/multifactor.py
index 56827a4..95683df 100644
--- a/Allura/allura/lib/multifactor.py
+++ b/Allura/allura/lib/multifactor.py
@@ -17,21 +17,26 @@
 
 import os
 import logging
+import random
+import string
 from collections import OrderedDict
 from base64 import b32decode, b32encode
 from time import time
-
-import bson
 import errno
-from cryptography.hazmat.primitives.twofactor import InvalidToken
 
+import bson
+from allura.lib.exceptions import InvalidRecoveryCode
 from tg import config
 from pylons import app_globals as g
 from paste.deploy.converters import asint
 from cryptography.hazmat.backends import default_backend
+from cryptography.hazmat.primitives.twofactor import InvalidToken
 from cryptography.hazmat.primitives.twofactor.totp import TOTP
 from cryptography.hazmat.primitives.hashes import SHA1
 import qrcode
+from ming.odm import session
+
+from allura.model.multifactor import RecoveryCode
 
 
 log = logging.getLogger(__name__)
@@ -237,3 +242,98 @@ class GoogleAuthenticatorPamFilesystemTotpService(TotpService):
             gaf.key = key
             with open(self.config_file(user), 'w') as f:
                 f.write(gaf.dump())
+
+
+class RecoveryCodeService(object):
+    '''
+    An interface for handling multifactor recovery codes.  Common functionality
+    is provided in this base class, and specific subclasses implement different storage options.
+    A provider must implement :meth:`get_codes`, :meth:`replace_codes`, and :meth:`verify_and_remove_code`.
+
+
+    To use a new provider, expose an entry point in setup.py::
+
+        [allura.multifactor.recovery_code]
+        myrecovery = foo.bar:MyRecoveryCodeService
+
+    Then in your .ini file, set ``auth.multifactor.recovery_code.service=myrecovery``
+    '''
+
+    @classmethod
+    def get(cls):
+        '''
+        :rtype: RecoveryCodeService
+        '''
+        method = config.get('auth.multifactor.recovery_code.service', 'mongodb')
+        return g.entry_points['multifactor_recovery_code'][method]()
+
+    def generate_one_code(self):
+        # for compatibility with Google PAM file, we only do digits
+        length = asint(config.get('auth.multifactor.recovery_code.length', 8))
+        return ''.join([random.choice(string.digits) for i in xrange(length)])
+
+    def regenerate_codes(self, user):
+        '''
+        Regenerate and replace existing codes
+
+        :param user: a :class:`User <allura.model.auth.User>`
+        :return: codes, ``list[str]``
+        '''
+        count = asint(config.get('auth.multifactor.recovery_code.count', 10))
+        codes = [
+            self.generate_one_code() for i in xrange(count)
+        ]
+        self.replace_codes(user, codes)
+        return codes
+
+    def delete_all(self, user):
+        return self.replace_codes(user, [])
+
+    def get_codes(self, user):
+        '''
+        :param user: a :class:`User <allura.model.auth.User>`
+        :return: list[str]
+        '''
+        raise NotImplementedError('get_codes')
+
+    def replace_codes(self, user, codes):
+        '''
+        :param user: a :class:`User <allura.model.auth.User>`
+        '''
+        raise NotImplementedError('replace_codes')
+
+    def verify_and_remove_code(self, user, code):
+        '''
+        :param user: a :class:`User <allura.model.auth.User>`
+        :param code: str
+        :raises: InvalidRecoveryCode
+        '''
+        raise NotImplementedError('verify_and_remove_code')
+
+
+class MongodbRecoveryCodeService(RecoveryCodeService):
+
+    def get_codes(self, user):
+        return [rc.code for rc in
+                RecoveryCode.query.find({'user_id': user._id}).sort('_id')]
+
+    def replace_codes(self, user, codes):
+        RecoveryCode.query.remove({'user_id': user._id})
+        for code in codes:
+            rc = RecoveryCode(user_id=user._id, code=code)
+            session(rc).flush(rc)
+
+    def verify_and_remove_code(self, user, code):
+        rc = RecoveryCode.query.get(user_id=user._id, code=code)
+        if rc:
+            rc.query.delete()
+            session(rc).flush(rc)
+            return True
+        else:
+            raise InvalidRecoveryCode
+
+
+class GoogleAuthenticatorPamFilesystemRecoveryCodeService(RecoveryCodeService):
+
+    def get_codes(self, user):
+        return []

http://git-wip-us.apache.org/repos/asf/allura/blob/c044a01d/Allura/allura/model/multifactor.py
----------------------------------------------------------------------
diff --git a/Allura/allura/model/multifactor.py b/Allura/allura/model/multifactor.py
index 24a7898..cf88ce6 100644
--- a/Allura/allura/model/multifactor.py
+++ b/Allura/allura/model/multifactor.py
@@ -39,3 +39,18 @@ class TotpKey(MappedClass):
     _id = FieldProperty(S.ObjectId)
     user_id = FieldProperty(S.ObjectId, required=True)
     key = FieldProperty(bytes, required=True)
+
+
+class RecoveryCode(MappedClass):
+    '''
+    For use with "mongodb" recovery code service
+    '''
+
+    class __mongometa__:
+        session = main_orm_session
+        name = 'multifactor_recovery_code'
+        indexes = ['user_id']
+
+    _id = FieldProperty(S.ObjectId)
+    user_id = FieldProperty(S.ObjectId, required=True)
+    code = FieldProperty(str, required=True)

http://git-wip-us.apache.org/repos/asf/allura/blob/c044a01d/Allura/allura/templates/login_multifactor.html
----------------------------------------------------------------------
diff --git a/Allura/allura/templates/login_multifactor.html b/Allura/allura/templates/login_multifactor.html
index 3edb53c..f71755f 100644
--- a/Allura/allura/templates/login_multifactor.html
+++ b/Allura/allura/templates/login_multifactor.html
@@ -27,7 +27,9 @@
 <form method="post" action="/auth/do_multifactor">
     <h2>Enter your Multifactor Authentication Code</h2>
     <p>
-    Please enter the {{ config['auth.multifactor.totp.length'] }}-digit code from your authenticator app:<br>
+    <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 c.form_errors['code'] %}
         <span class="fielderror">{{ c.form_errors['code'] }}</span><br>
     {% endif %}
@@ -35,8 +37,51 @@
     <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>
+    <input type="hidden" name="mode" value="{{ mode }}"/>
     {{ lib.csrf_token() }}
     </p>
 </form>
 
 {% endblock %}
+
+{% block extra_css %}
+<style type="text/css">
+    .{{ 'recovery' if mode == 'totp' else 'totp' }} {
+        display: none;
+    }
+    .alternate-links {
+        /* align with floated button */
+        display: inline-block;
+        margin: 6px 10px;
+    }
+</style>
+{% endblock %}
+
+{% block extra_js %}
+<script type="text/javascript">
+  $(function() {
+      $('.show-recovery').click(function(e){
+          $('.recovery').show();
+          $('.totp').hide();
+          $('.fielderror').empty()
+          $('input[name=mode]').val('recovery');
+          $('input[name=code]').focus();
+      });
+      $('.show-totp').click(function(e){
+          $('.totp').show();
+          $('.recovery').hide();
+          $('.fielderror').empty()
+          $('input[name=mode]').val('totp');
+          $('input[name=code]').focus();
+      });
+  });
+</script>
+{% endblock %}
\ No newline at end of file

http://git-wip-us.apache.org/repos/asf/allura/blob/c044a01d/Allura/allura/templates/mail/twofactor_recovery_regen.md
----------------------------------------------------------------------
diff --git a/Allura/allura/templates/mail/twofactor_recovery_regen.md b/Allura/allura/templates/mail/twofactor_recovery_regen.md
new file mode 100644
index 0000000..ff997a4
--- /dev/null
+++ b/Allura/allura/templates/mail/twofactor_recovery_regen.md
@@ -0,0 +1,27 @@
+{#
+       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.
+-#}
+
+Hello {{ user.username }},
+
+You have recently regenerated the recovery codes for two-factor authentication on {{ config['site_name'] }}.
+This is a confirmation email. 
+
+{% block footer %}
+If you did not do this, please contact us immediately.
+{% endblock %}
\ No newline at end of file

http://git-wip-us.apache.org/repos/asf/allura/blob/c044a01d/Allura/allura/templates/user_prefs.html
----------------------------------------------------------------------
diff --git a/Allura/allura/templates/user_prefs.html b/Allura/allura/templates/user_prefs.html
index 59a5497..c7a8677 100644
--- a/Allura/allura/templates/user_prefs.html
+++ b/Allura/allura/templates/user_prefs.html
@@ -123,6 +123,7 @@
             </a></p>
             {% if user_multifactor %}
                 <p><b class="fa fa-qrcode"></b> <a href="totp_view">View existing configuration</a></p>
+                <p><b class="fa fa-life-ring"></b> <a href="multifactor_recovery">View recovery codes</a></p>
                 <form action="multifactor_disable" id="multifactor_disable" method="post">
                 <p>
                     <b class="fa fa-trash"></b> <a href="#" class="disable">Disable</a>
@@ -176,6 +177,9 @@
       font-size: 300%;
       vertical-align: middle;
       margin-right: 5px;
+      /* icons aren't all the same width :( */
+      width: 1em;
+      text-align: center;
   }
 </style>
 {% endblock %}

http://git-wip-us.apache.org/repos/asf/allura/blob/c044a01d/Allura/allura/templates/user_recovery_codes.html
----------------------------------------------------------------------
diff --git a/Allura/allura/templates/user_recovery_codes.html b/Allura/allura/templates/user_recovery_codes.html
new file mode 100644
index 0000000..52b713a
--- /dev/null
+++ b/Allura/allura/templates/user_recovery_codes.html
@@ -0,0 +1,114 @@
+{#-
+       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.
+-#}
+{% set hide_left_bar = True %}
+{% extends "allura:templates/user_account_base.html" %}
+
+{% block title %}{{c.user.username}} / Recovery Codes for Multifactor Authentication{% endblock %}
+
+{% block header %}Recovery Codes for Multifactor Authentication{% endblock %}
+
+
+{% macro plaintext(codes) %}
+{{ config['site_name'] }} Recovery Codes:
+{% for code in codes %}
+{{ code }}
+{% endfor %}
+{% endmacro %}
+
+{% block content %}
+  {{ super() }}
+  <div class='grid-20'>
+    <h2 class="subtitle">Your {{ config['site_name'] }} Recovery Codes</h2>
+    <p>
+        <a href="#" class="print"><strong>Print</strong></a> these!
+        <a href="{{ h.base64uri(plaintext(codes), mimetype='text/plain') }}" download="{{ config['site_name'] }} recovery codes.txt" class="download"><strong>Download</strong></a> them!
+        Keep them safe.
+    </p>
+    <p>
+        It is essential to keep a copy of these recovery codes, otherwise you may get permanently locked out of your account, if you lose
+        your authenticator device.
+    </p>
+    <p>
+        <a href="/auth/preferences/">Back to Account Settings</a>
+    </p>
+    <hr>
+    <p class="codes">
+    {% for code in codes %}
+        {{ code }}<br>
+    {% endfor %}
+    </p>
+    <hr>
+    <form action="multifactor_recovery_regen" id="multifactor_recovery_regen" method="post">
+        <p>
+            If you regenerate your recovery codes, your old ones will not work any more.
+            You will need to save the new ones.
+        </p>
+        <p>
+            <input type="submit" value="Regenerate Codes">
+        </p>
+        {{ lib.csrf_token() }}
+    </form>
+  </div>
+{% endblock %}
+
+{% block extra_css %}
+<style type="text/css">
+p.codes {
+    -moz-columns: 2;
+    columns: 2;
+    font-size: 300%;
+    line-height: 125%;
+    padding-bottom: 0;
+}
+@media print {
+    /* don't show link destinations */
+    a:after {
+        content: '';
+    }
+}
+</style>
+{% endblock %}
+
+
+{% block extra_js %}
+<script type="text/javascript">
+$(function() {
+    $('#multifactor_recovery_regen').submit(function(e) {
+        var ok = confirm('Are you sure you want to regenerate your recovery codes?');
+        if(!ok) {
+            e.preventDefault();
+        }
+    });
+    $('.print').click(function(e) {
+        window.print();
+    });
+    if (window.navigator.msSaveBlob) {
+        // IE doesn't support base64 links or the download attr, so use its msSaveBlob
+        $('.download').click(function(e) {
+            e.preventDefault();
+            var fileData = $('.subtitle').text() + '\n\n' + $('.codes').text();
+            fileData = fileData.replace(new RegExp('\n', 'g'), '\r\n');
+            blobObject = new Blob([fileData]);
+            var fileName = $('.download').attr('download');
+            window.navigator.msSaveBlob(blobObject, fileName);
+        });
+    }
+});
+</script>
+{% endblock %}
\ No newline at end of file

http://git-wip-us.apache.org/repos/asf/allura/blob/c044a01d/Allura/allura/tests/functional/test_auth.py
----------------------------------------------------------------------
diff --git a/Allura/allura/tests/functional/test_auth.py b/Allura/allura/tests/functional/test_auth.py
index d340a2f..baefab6 100644
--- a/Allura/allura/tests/functional/test_auth.py
+++ b/Allura/allura/tests/functional/test_auth.py
@@ -22,7 +22,7 @@ import json
 from urlparse import urlparse, parse_qs
 from urllib import urlencode
 
-from allura.lib.multifactor import TotpService
+from allura.lib.multifactor import TotpService, RecoveryCodeService
 from allura.tests.decorators import audits, out_audits
 from bson import ObjectId
 
@@ -2080,6 +2080,9 @@ class TestTwoFactor(TestController):
         assert_equal(tasks[0].kwargs['subject'], 'Two-Factor Authentication Enabled')
         assert_in('new two-factor authentication', tasks[0].kwargs['text'])
 
+        r = r.follow()
+        assert_in('Recovery Codes', r)
+
     def test_reset_totp(self):
         self._init_totp()
 
@@ -2146,6 +2149,7 @@ class TestTwoFactor(TestController):
         user = M.User.query.get(username='test-admin')
         assert_equal(user.get_pref('multifactor'), False)
         assert_equal(TotpService().get().get_secret_key(user), None)
+        assert_equal(RecoveryCodeService().get().get_codes(user), [])
 
         # email confirmation
         tasks = M.MonQTask.query.find(dict(task_name='allura.tasks.mail_tasks.sendsimplemail')).all()
@@ -2214,6 +2218,48 @@ class TestTwoFactor(TestController):
         r = r.follow()
         assert_in('Password Login', r)
 
+    def test_login_recovery_code(self):
+        self._init_totp()
+
+        # so test-admin isn't automatically logged in for all requests
+        self.app.extra_environ = {'disable_auth_magic': 'True'}
+
+        # regular login
+        r = self.app.get('/auth/?return_to=/p/foo')
+        r.form['username'] = 'test-admin'
+        r.form['password'] = 'foo'
+        r = r.form.submit()
+
+        # check results
+        assert r.location.endswith('/auth/multifactor?return_to=%2Fp%2Ffoo'), r
+        r = r.follow()
+        assert not r.session.get('username')
+
+        # change login mode
+        r.form['mode'] = 'recovery'
+
+        # try an invalid code
+        r.form['code'] = 'invalid-code'
+        r = r.form.submit()
+        assert_in('Invalid code', r)
+        assert not r.session.get('username')
+
+        # use a valid code
+        user = M.User.by_username('test-admin')
+        recovery = RecoveryCodeService().get()
+        recovery.regenerate_codes(user)
+        recovery_code = recovery.get_codes(user)[0]
+        r.form['code'] = recovery_code
+        with audits('Logged in using a multifactor recovery code', user=True):
+            r = r.form.submit()
+
+        # confirm login and final page
+        assert_equal(r.session['username'], 'test-admin')
+        assert r.location.endswith('/p/foo'), r
+
+        # confirm code used up
+        assert_not_in(recovery_code, RecoveryCodeService().get().get_codes(user))
+
     def test_view_key(self):
         self._init_totp()
 
@@ -2226,6 +2272,31 @@ class TestTwoFactor(TestController):
             r = r.form.submit()
             assert_in('Scan this barcode', r)
 
+    def test_view_recovery_codes_and_regen(self):
+        self._init_totp()
+
+        # reconfirm password
+        with out_audits(user=True):
+            r = self.app.get('/auth/preferences/multifactor_recovery')
+            assert_in('Password Confirmation', r)
+
+        # actual visit
+        with audits('Viewed multifactor recovery codes', user=True):
+            r.form['password'] = 'foo'
+            r = r.form.submit()
+            assert_in('Download', r)
+            assert_in('Print', r)
+
+        # regenerate codes
+        with audits('Regenerated multifactor recovery codes', user=True):
+            r = r.forms['multifactor_recovery_regen'].submit()
+
+        # email confirmation
+        tasks = M.MonQTask.query.find(dict(task_name='allura.tasks.mail_tasks.sendsimplemail')).all()
+        assert_equal(len(tasks), 1)
+        assert_equal(tasks[0].kwargs['subject'], 'Two-Factor Recovery Codes Regenerated')
+        assert_in('regenerated', tasks[0].kwargs['text'])
+
     def test_send_links(self):
         r = self.app.get('/auth/preferences/totp_new')
         r.form['password'] = 'foo'

http://git-wip-us.apache.org/repos/asf/allura/blob/c044a01d/Allura/allura/tests/test_helpers.py
----------------------------------------------------------------------
diff --git a/Allura/allura/tests/test_helpers.py b/Allura/allura/tests/test_helpers.py
index cb7e1a6..388760c 100644
--- a/Allura/allura/tests/test_helpers.py
+++ b/Allura/allura/tests/test_helpers.py
@@ -590,10 +590,15 @@ def test_convert_bools():
                   {'foo': 'true', 'baz': True})
 
 
-def test_base64uri():
+def test_base64uri_img():
     img_file = path.join(path.dirname(__file__), 'data', 'user.png')
     with open(img_file) as img_file_handle:
         img = PIL.Image.open(img_file_handle)
         b64img = h.base64uri(img)
         assert b64img.startswith('data:image/png;base64,'), b64img[:100]
         assert len(b64img) > 500
+
+
+def test_base64uri_text():
+    b64txt = h.base64uri('blah blah blah 123 456 foo bar baz', mimetype='text/plain')
+    assert b64txt.startswith('data:text/plain;base64,'), b64txt

http://git-wip-us.apache.org/repos/asf/allura/blob/c044a01d/Allura/allura/tests/test_multifactor.py
----------------------------------------------------------------------
diff --git a/Allura/allura/tests/test_multifactor.py b/Allura/allura/tests/test_multifactor.py
index c3b8f97..6828757 100644
--- a/Allura/allura/tests/test_multifactor.py
+++ b/Allura/allura/tests/test_multifactor.py
@@ -20,6 +20,9 @@ import textwrap
 import os
 
 import bson
+from allura.lib.exceptions import InvalidRecoveryCode
+from paste.deploy.converters import asint
+
 import ming
 from cryptography.hazmat.primitives.twofactor import InvalidToken
 from mock import patch, Mock
@@ -28,6 +31,7 @@ from tg import config
 
 from allura.lib.multifactor import GoogleAuthenticatorFile, TotpService, MongodbTotpService
 from allura.lib.multifactor import GoogleAuthenticatorPamFilesystemTotpService
+from allura.lib.multifactor import RecoveryCodeService, MongodbRecoveryCodeService
 
 
 class TestGoogleAuthenticatorFile(object):
@@ -174,3 +178,71 @@ class TestGoogleAuthenticatorPamFilesystemTotpService():
         assert_equal(self.sample_key, srv.get_secret_key(user))
         srv.set_secret_key(user, None)
         assert_equal(None, srv.get_secret_key(user))
+
+
+class TestRecoveryCodeService(object):
+
+    def test_generate_one_code(self):
+        code = RecoveryCodeService().generate_one_code()
+        assert code
+        another_code = RecoveryCodeService().generate_one_code()
+        assert code != another_code
+
+    def test_regenerate_codes(self):
+        class DummyRecoveryService(RecoveryCodeService):
+            def replace_codes(self, user, codes):
+                self.saved_user = user
+                self.saved_codes = codes
+        recovery = DummyRecoveryService()
+        user = Mock(username='some-user-guy')
+
+        recovery.regenerate_codes(user)
+
+        assert_equal(recovery.saved_user, user)
+        assert_equal(len(recovery.saved_codes), asint(config.get('auth.multifactor.recovery_code.count', 10)))
+
+
+class TestMongodbRecoveryCodeService(object):
+
+    def setUp(self):
+        config = {
+            'ming.main.uri': 'mim://allura_test',
+        }
+        ming.configure(**config)
+
+    def test_get_codes(self):
+        recovery = MongodbRecoveryCodeService()
+        user = Mock(_id=bson.ObjectId())
+        assert_equal(recovery.get_codes(user), [])
+        recovery.regenerate_codes(user)
+        assert recovery.get_codes(user)
+
+    def test_replace_codes(self):
+        recovery = MongodbRecoveryCodeService()
+        user = Mock(_id=bson.ObjectId())
+        codes = [
+            '12345',
+            '67890'
+        ]
+        recovery.replace_codes(user, codes)
+        assert_equal(recovery.get_codes(user), codes)
+
+    def test_verify_fail(self):
+        recovery = MongodbRecoveryCodeService()
+        user = Mock(_id=bson.ObjectId())
+        with assert_raises(InvalidRecoveryCode):
+            recovery.verify_and_remove_code(user, '11111')
+        with assert_raises(InvalidRecoveryCode):
+            recovery.verify_and_remove_code(user, '')
+
+    def test_verify_and_remove_code(self):
+        recovery = MongodbRecoveryCodeService()
+        user = Mock(_id=bson.ObjectId())
+        codes = [
+            '12345',
+            '67890'
+        ]
+        recovery.replace_codes(user, codes)
+        result = recovery.verify_and_remove_code(user, '12345')
+        assert_equal(result, True)
+        assert_equal(recovery.get_codes(user), ['67890'])

http://git-wip-us.apache.org/repos/asf/allura/blob/c044a01d/Allura/development.ini
----------------------------------------------------------------------
diff --git a/Allura/development.ini b/Allura/development.ini
index 31ad42f..b160ef6 100644
--- a/Allura/development.ini
+++ b/Allura/development.ini
@@ -205,6 +205,14 @@ auth.multifactor.totp.service = mongodb
 ; if using filesystem storage:
 auth.multifactor.totp.filesystem.basedir = /var/lib/allura/totp-keys
 
+; recovery code storage location.  "filesystem-googleauth" is another option (compatible with Google Authenticator PAM plugin)
+auth.multifactor.recovery_code.service = mongodb
+; number of recovery codes to generate per user
+auth.multifactor.recovery_code.count = 10
+; length of each code.  Must be 8 for compatibility with "filesystem-googleauth" files
+auth.multifactor.recovery_code.length = 8
+
+
 user_prefs_storage.method = local
 ; user_prefs_storage.method = ldap
 ; If using ldap, you can specify which fields to use for a preference.

http://git-wip-us.apache.org/repos/asf/allura/blob/c044a01d/Allura/setup.py
----------------------------------------------------------------------
diff --git a/Allura/setup.py b/Allura/setup.py
index c10ec73..3005d07 100644
--- a/Allura/setup.py
+++ b/Allura/setup.py
@@ -128,6 +128,10 @@ setup(
     mongodb = allura.lib.multifactor:MongodbTotpService
     filesystem-googleauth = allura.lib.multifactor:GoogleAuthenticatorPamFilesystemTotpService
 
+    [allura.multifactor.recovery_code]
+    mongodb = allura.lib.multifactor:MongodbRecoveryCodeService
+    filesystem-googleauth = allura.lib.multifactor:GoogleAuthenticatorPamFilesystemRecoveryCodeService
+
     [paste.paster_command]
     taskd = allura.command.taskd:TaskdCommand
     taskd_cleanup = allura.command.taskd_cleanup:TaskdCleanupCommand