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