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/08/30 14:18:31 UTC
[5/8] allura git commit: [#8117] basic 2FA support
[#8117] basic 2FA support
Project: http://git-wip-us.apache.org/repos/asf/allura/repo
Commit: http://git-wip-us.apache.org/repos/asf/allura/commit/67cad38e
Tree: http://git-wip-us.apache.org/repos/asf/allura/tree/67cad38e
Diff: http://git-wip-us.apache.org/repos/asf/allura/diff/67cad38e
Branch: refs/heads/db/8117
Commit: 67cad38eaf4d7a6abb776aacd029a65e45de727b
Parents: 8649faa
Author: Dave Brondsema <da...@brondsema.net>
Authored: Tue Aug 16 11:35:26 2016 -0400
Committer: Dave Brondsema <da...@brondsema.net>
Committed: Mon Aug 29 14:59:19 2016 -0400
----------------------------------------------------------------------
Allura/allura/controllers/auth.py | 134 ++++++++++-
Allura/allura/lib/app_globals.py | 1 +
Allura/allura/lib/helpers.py | 10 +-
Allura/allura/lib/multifactor.py | 233 +++++++++++++++++++
Allura/allura/lib/plugin.py | 19 +-
Allura/allura/model/__init__.py | 3 +-
Allura/allura/model/auth.py | 5 +-
Allura/allura/model/multifactor.py | 41 ++++
Allura/allura/templates/login_multifactor.html | 39 ++++
.../templates/site_admin_user_details.html | 1 +
Allura/allura/templates/user_prefs.html | 35 +++
Allura/allura/templates/user_totp.html | 53 +++++
Allura/allura/tests/test_multifactor.py | 64 +++++
Allura/development.ini | 12 +
Allura/setup.py | 4 +
requirements.txt | 12 +
16 files changed, 654 insertions(+), 12 deletions(-)
----------------------------------------------------------------------
http://git-wip-us.apache.org/repos/asf/allura/blob/67cad38e/Allura/allura/controllers/auth.py
----------------------------------------------------------------------
diff --git a/Allura/allura/controllers/auth.py b/Allura/allura/controllers/auth.py
index 9ad0dea..d326397 100644
--- a/Allura/allura/controllers/auth.py
+++ b/Allura/allura/controllers/auth.py
@@ -19,6 +19,7 @@ import logging
import os
import datetime
import re
+from urlparse import urlparse, urljoin
import bson
import tg
@@ -28,7 +29,7 @@ from pylons import tmpl_context as c, app_globals as g
from pylons import request, response
from webob import exc as wexc
from paste.deploy.converters import asbool
-from urlparse import urlparse, urljoin
+from cryptography.hazmat.primitives.twofactor import InvalidToken
import allura.tasks.repo_tasks
from allura import model as M
@@ -47,6 +48,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.controllers import BaseController
log = logging.getLogger(__name__)
@@ -244,7 +246,7 @@ class AuthController(BaseController):
em.send_verification_link()
flash('User "%s" registered. Verification link was sent to your email.' % username)
else:
- plugin.AuthenticationProvider.get(request).login(user)
+ plugin.AuthenticationProvider.get(request).login(user) # TODO test this flow
flash('User "%s" registered' % username)
redirect('/')
@@ -294,25 +296,62 @@ class AuthController(BaseController):
plugin.AuthenticationProvider.get(request).logout()
redirect(config.get('auth.post_logout_url', '/'))
+ @staticmethod
+ def _verify_return_to(return_to):
+ # protect against any "open redirect" attacks using an external URL
+ if not return_to:
+ return_to = '/'
+ rt_host = urlparse(urljoin(config['base_url'], return_to)).netloc
+ base_host = urlparse(config['base_url']).netloc
+ if rt_host == base_host:
+ return return_to
+ else:
+ return '/'
+
@expose()
@require_post()
@validate(F.login_form, error_handler=index)
def do_login(self, return_to=None, **kw):
location = '/'
- if session.get('expired-username'):
+ if session.get('multifactor-username'):
+ location = tg.url('/auth/multifactor', dict(return_to=return_to))
+ 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))
else:
location = tg.url(plugin.AuthenticationProvider.pwd_expired_allowed_urls[0])
elif return_to and return_to != request.url:
- rt_host = urlparse(urljoin(config['base_url'], return_to)).netloc
- base_host = urlparse(config['base_url']).netloc
- if rt_host == base_host:
- location = return_to
+ location = self._verify_return_to(return_to)
redirect(location)
+ @expose('jinja:allura:templates/login_multifactor.html')
+ def multifactor(self, return_to='', **kwargs):
+ return dict(
+ return_to=return_to,
+ )
+
+ @expose('jinja:allura:templates/login_multifactor.html')
+ @require_post()
+ def do_multifactor(self, code, **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:
+ c.form_errors['code'] = 'Invalid code, please try again.'
+ return self.multifactor(**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(content_type='text/plain')
def refresh_repo(self, *repo_path):
# post-commit hooks use this
@@ -452,6 +491,7 @@ class PreferencesController(BaseController):
def _check_security(self):
require_authenticated()
+
@with_trailing_slash
@expose('jinja:allura:templates/user_prefs.html')
def index(self, **kw):
@@ -604,6 +644,86 @@ class PreferencesController(BaseController):
c.user.set_pref('disable_user_messages', not allow_user_messages)
redirect(request.referer)
+ @expose('jinja:allura:templates/user_totp.html')
+ @without_trailing_slash
+ #@reconfirm_password
+ def totp_new(self, **kw):
+ '''
+ auth_provider = plugin.AuthenticationProvider.get(request)
+ # TODO: don't require it every single time
+ if not kw.get('password') or not auth_provider.validate_password(c.user, kw.get('password')):
+ flash('You must provide your current password to set up multifactor authentication', 'error')
+ redirect('.')
+ '''
+
+ totp_service = TotpService.get()
+ if 'totp_new_key' not in session:
+ # never been here yet
+ # get a new key
+ totp = totp_service.Totp(key=None)
+ # don't save to database until confirmed, just session for now
+ session['totp_new_key'] = totp.key
+ session.save()
+ else:
+ # use key from session, so we don't regenerate new keys on each page load
+ key = session['totp_new_key']
+ totp = totp_service.Totp(key)
+
+ qr = totp_service.get_qr_code(totp, c.user)
+ h.auditlog_user('Visited multifactor new TOTP page')
+ return dict(
+ qr=qr,
+ setup=True,
+ )
+
+ @expose('jinja:allura:templates/user_totp.html')
+ @without_trailing_slash
+ #@reconfirm_password
+ def totp_view(self, **kw):
+ totp_service = TotpService.get()
+ totp = totp_service.get_totp(c.user)
+ qr = totp_service.get_qr_code(totp, c.user)
+ h.auditlog_user('Viewed multifactor TOTP config page')
+ return dict(
+ qr=qr,
+ setup=False,
+ )
+
+ @expose('jinja:allura:templates/user_totp.html')
+ @require_post()
+ # @reconfirm_password
+ def totp_set(self, code, **kw):
+ # TODO: email notification
+ key = session['totp_new_key']
+ totp_service = TotpService.get()
+ totp = totp_service.Totp(key)
+ try:
+ totp_service.verify(totp, code)
+ except InvalidToken:
+ h.auditlog_user('Failed to set up multifactor TOTP (wrong code)')
+ c.form_errors['code'] = 'Invalid code, please try again.'
+ return self.totp_new(**kw)
+ else:
+ h.auditlog_user('Set up multifactor TOTP')
+ totp_service.set_secret_key(c.user, key)
+ c.user.set_pref('multifactor', True)
+ del session['totp_new_key']
+ session.save()
+ tg.flash('Two factor authentication has now been set up.')
+ redirect('.')
+
+ @expose()
+ @require_post()
+ # @reconfirm_password
+ def multifactor_disable(self):
+ # TODO: email notification
+ h.auditlog_user('Disabled multifactor TOTP')
+ totp_service = TotpService.get()
+ totp_service.set_secret_key(c.user, None)
+ c.user.set_pref('multifactor', False)
+ tg.flash('Multifactor authentication has now been disabled.')
+ redirect('.')
+
class UserInfoController(BaseController):
http://git-wip-us.apache.org/repos/asf/allura/blob/67cad38e/Allura/allura/lib/app_globals.py
----------------------------------------------------------------------
diff --git a/Allura/allura/lib/app_globals.py b/Allura/allura/lib/app_globals.py
index 33fa123..d390e43 100644
--- a/Allura/allura/lib/app_globals.py
+++ b/Allura/allura/lib/app_globals.py
@@ -306,6 +306,7 @@ 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')
)
# Neighborhood cache
http://git-wip-us.apache.org/repos/asf/allura/blob/67cad38e/Allura/allura/lib/helpers.py
----------------------------------------------------------------------
diff --git a/Allura/allura/lib/helpers.py b/Allura/allura/lib/helpers.py
index 6e10208..890deb6 100644
--- a/Allura/allura/lib/helpers.py
+++ b/Allura/allura/lib/helpers.py
@@ -16,7 +16,7 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
-
+import base64
import sys
import os
import os.path
@@ -35,6 +35,7 @@ from collections import defaultdict
import shlex
import socket
from functools import partial
+from cStringIO import StringIO
import tg
import genshi.template
@@ -1286,3 +1287,10 @@ def rate_limit(cfg_opt, artifact_count, start_date, exception=None):
artifact_count = artifact_count()
if artifact_count >= count:
raise exception()
+
+
+def base64uri(image, format='PNG', mimetype='image/png'):
+ output = StringIO()
+ image.save(output, format=format)
+ data = base64.b64encode(output.getvalue())
+ return 'data:{};base64,{}'.format(mimetype, data)
\ No newline at end of file
http://git-wip-us.apache.org/repos/asf/allura/blob/67cad38e/Allura/allura/lib/multifactor.py
----------------------------------------------------------------------
diff --git a/Allura/allura/lib/multifactor.py b/Allura/allura/lib/multifactor.py
new file mode 100644
index 0000000..fadc0d4
--- /dev/null
+++ b/Allura/allura/lib/multifactor.py
@@ -0,0 +1,233 @@
+# 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.
+
+import os
+import logging
+from collections import OrderedDict
+from base64 import b32decode, b32encode
+from time import time
+
+import bson
+import errno
+from cryptography.hazmat.primitives.twofactor import InvalidToken
+
+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.totp import TOTP
+from cryptography.hazmat.primitives.hashes import SHA1
+import qrcode
+
+
+log = logging.getLogger(__name__)
+
+
+class TotpService(object):
+ '''
+ An interface for handling multifactor auth TOTP secret keys. Common functionality
+ is provided in this base class, and specific subclasses implement different storage options.
+
+ To use a new provider, expose an entry point in setup.py::
+
+ [allura.multifactor.totp_service]
+ mytotp = foo.bar:MyTotpService
+
+ Then in your .ini file, set auth.multifactor.totp.service=mytotp
+ '''
+
+ @classmethod
+ def get(cls):
+ method = config.get('auth.multifactor.totp.service', 'mongodb')
+ return g.entry_points['multifactor_totp'][method]()
+
+ def Totp(self, key):
+ # simple constructor helper
+
+ if not key:
+ key = os.urandom(20) # == 160 bytes which is recommended
+
+ totp = TOTP(key,
+ asint(config.get('auth.multifactor.totp.length', 6)),
+ SHA1(),
+ asint(config.get('auth.multifactor.totp.time', 30)),
+ backend=default_backend())
+
+ totp.key = key # for convenience, else you have to use `totp._hotp._key`
+
+ return totp
+
+ def verify(self, totp, code):
+ code = code.replace(' ', '') # Google authenticator puts a space in their codes
+ code = bytes(code) # can't be unicode
+
+ # TODO prohibit re-use of a successful code, although it seems unlikely with a 30s window
+ # see https://tools.ietf.org/html/rfc6238#section-5.2 paragraph 5
+
+ # try the 1 previous time-window and current
+ # per https://tools.ietf.org/html/rfc6238#section-5.2 paragraph 1
+ windows = asint(config.get('auth.multifactor.totp.windows', 2))
+ for time_window in range(windows):
+ try:
+ return totp.verify(code, time() - time_window*30)
+ except InvalidToken:
+ last_window = (time_window == windows - 1)
+ if last_window:
+ raise
+
+ def get_totp(self, user):
+ '''
+ :param user: a :class:`User <allura.model.auth.User>`
+ :param bool generate_new: generate (but does not save) if one does not exist already
+ :return:
+ '''
+ key = self.get_secret_key(user)
+ return self.Totp(key)
+
+ def get_qr_code(self, totp, user, **qrcode_params):
+ qrcode_params.setdefault('box_size', 5)
+ uri = totp.get_provisioning_uri(user.username, config['site_name'])
+ qr = qrcode.make(uri, **qrcode_params)
+ return qr
+
+ def get_secret_key(self, user):
+ '''
+ :param user: a :class:`User <allura.model.auth.User>`
+ :return: key
+ '''
+ raise NotImplementedError('get_secret_key')
+
+ def set_secret_key(self, user, key):
+ '''
+ :param user: a :class:`User <allura.model.auth.User>`
+ :param bytes key: may be `None` to clear out a key
+ '''
+ raise NotImplementedError('set_secret_key')
+
+
+class MongodbTotpService(TotpService):
+
+ def get_secret_key(self, user):
+ from allura import model as M
+ if user.is_anonymous():
+ return None
+ record = M.TotpKey.query.get(user_id=user._id)
+ if record:
+ return record.key
+
+ def set_secret_key(self, user, key):
+ from allura import model as M
+ if key is not None:
+ key = bson.binary.Binary(key)
+ M.TotpKey.query.update({'user_id': user._id},
+ {'user_id': user._id, 'key': key},
+ upsert=True)
+
+
+class GoogleAuthenticatorFile(object):
+ '''
+ Server-side .google_authenticator file for PAM
+ https://github.com/google/google-authenticator/blob/master/libpam/FILEFORMAT
+ '''
+
+ def __init__(self):
+ self.key = None
+ self.options = OrderedDict()
+ self.recovery_codes = []
+
+ @classmethod
+ def load(cls, contents):
+ gaf = GoogleAuthenticatorFile()
+ lines = contents.split('\n')
+ b32key = lines[0]
+ padding = '=' * (-len(b32key) % 8)
+ gaf.key = b32decode(b32key + padding)
+ for line in lines[1:]:
+ if line.startswith('" '):
+ opt_value = line[2:]
+ if ' ' in opt_value:
+ opt, value = opt_value.split(' ', 1)
+ else:
+ opt = opt_value
+ value = None
+ gaf.options[opt] = value
+ elif line:
+ gaf.recovery_codes.append(line)
+ return gaf
+
+ def dump(self):
+ lines = []
+ lines.append(b32encode(self.key).replace('=', ''))
+ for opt, value in self.options.iteritems():
+ parts = ['"', opt]
+ if value is not None:
+ parts.append(value)
+ lines.append(' '.join(parts))
+ lines += self.recovery_codes
+ lines.append('')
+ return '\n'.join(lines)
+
+
+class GoogleAuthenticatorPamFilesystemTotpService(TotpService):
+ '''
+ Store in home directories, compatible with the TOTP PAM module for Google Authenticator
+ https://github.com/google/google-authenticator/tree/master/libpam
+ '''
+
+ @property
+ def basedir(self):
+ return config['auth.multifactor.totp.filesystem.basedir']
+
+ def config_file(self, user):
+ username = user.username
+ if '/' in username:
+ raise ValueError('Insecure username contains "/": %s' % username)
+ return os.path.join(self.basedir, username, '.google_authenticator')
+
+ def get_secret_key(self, user):
+ try:
+ with open(self.config_file(user)) as f:
+ gaf = GoogleAuthenticatorFile.load(f.read())
+ return gaf.key
+ except IOError as e:
+ if e.errno == errno.ENOENT: # file doesn't exist
+ return None
+ else:
+ raise
+
+ def set_secret_key(self, user, key):
+ if key is None:
+ # this also deletes the recovery keys, since they're stored in the same file
+ os.remove(self.config_file(user))
+ else:
+ userdir = os.path.dirname(self.config_file(user))
+ if not os.path.exists(userdir):
+ os.makedirs(userdir, 0700)
+ try:
+ with open(self.config_file(user)) as f:
+ gaf = GoogleAuthenticatorFile.load(f.read())
+ except IOError as e:
+ if e.errno == errno.ENOENT: # file doesn't exist
+ gaf = GoogleAuthenticatorFile()
+ gaf.options['RATE_LIMIT'] = '3 30'
+ gaf.options['DISALLOW_REUSE'] = None
+ gaf.options['TOTP_AUTH'] = None
+ else:
+ raise
+ gaf.key = key
+ with open(self.config_file(user), 'w') as f:
+ f.write(gaf.dump())
http://git-wip-us.apache.org/repos/asf/allura/blob/67cad38e/Allura/allura/lib/plugin.py
----------------------------------------------------------------------
diff --git a/Allura/allura/lib/plugin.py b/Allura/allura/lib/plugin.py
index cd4f336..832b5c4 100644
--- a/Allura/allura/lib/plugin.py
+++ b/Allura/allura/lib/plugin.py
@@ -33,7 +33,6 @@ from hashlib import sha256
from base64 import b64encode
from datetime import datetime, timedelta
import calendar
-import json
try:
import ldap
@@ -80,6 +79,10 @@ class AuthenticationProvider(object):
'/auth/pwd_expired_change',
'/auth/logout',
]
+ multifactor_allowed_urls = [
+ '/auth/multifactor',
+ '/auth/do_multifactor',
+ ]
def __init__(self, request):
self.request = request
@@ -103,6 +106,10 @@ class AuthenticationProvider(object):
username = self.session.get('username') or self.session.get('expired-username')
user = M.User.query.get(username=username)
+ if 'multifactor-username' in self.session and request.path not in self.multifactor_allowed_urls:
+ # ensure any partially completed multifactor login is not left open, if user goes to any other pages
+ del self.session['multifactor-username']
+ self.session.save()
if user is None:
return M.User.anonymous()
if user.disabled or user.pending:
@@ -126,6 +133,7 @@ class AuthenticationProvider(object):
# Don't try to re-post; the body has been lost.
location = tg.url(self.pwd_expired_allowed_urls[0])
redirect(location)
+
return user
def register_user(self, user_doc):
@@ -146,10 +154,17 @@ class AuthenticationProvider(object):
'''
raise NotImplementedError('_login')
- def login(self, user=None):
+ def login(self, user=None, multifactor_success=False):
if user is None:
user = self._login() # raises exception if auth fails
+ if user.get_pref('multifactor') and not multifactor_success:
+ self.session['multifactor-username'] = user.username
+ self.session.save()
+ return None
+ else:
+ self.session.pop('multifactor-username', None)
+
if self.is_password_expired(user):
self.session['pwd-expired'] = True
self.session['expired-username'] = user.username
http://git-wip-us.apache.org/repos/asf/allura/blob/67cad38e/Allura/allura/model/__init__.py
----------------------------------------------------------------------
diff --git a/Allura/allura/model/__init__.py b/Allura/allura/model/__init__.py
index a69e7c9..0b1bea2 100644
--- a/Allura/allura/model/__init__.py
+++ b/Allura/allura/model/__init__.py
@@ -36,6 +36,7 @@ from .stats import Stats
from .oauth import OAuthToken, OAuthConsumerToken, OAuthRequestToken, OAuthAccessToken
from .monq_model import MonQTask
from .webhook import Webhook
+from .multifactor import TotpKey
from .types import ACE, ACL, EVERYONE, ALL_PERMISSIONS, DENY_ALL, MarkdownCache
from .session import main_doc_session, main_orm_session
@@ -60,4 +61,4 @@ __all__ = [
'OAuthRequestToken', 'OAuthAccessToken', 'MonQTask', 'Webhook', 'ACE', 'ACL', 'EVERYONE', 'ALL_PERMISSIONS',
'DENY_ALL', 'MarkdownCache', 'main_doc_session', 'main_orm_session', 'project_doc_session', 'project_orm_session',
'artifact_orm_session', 'repository_orm_session', 'task_orm_session', 'ArtifactSessionExtension', 'repository',
- 'repo_refresh', 'SiteNotification']
+ 'repo_refresh', 'SiteNotification', 'TotpKey']
http://git-wip-us.apache.org/repos/asf/allura/blob/67cad38e/Allura/allura/model/auth.py
----------------------------------------------------------------------
diff --git a/Allura/allura/model/auth.py b/Allura/allura/model/auth.py
index 7f6442c..fa26353 100644
--- a/Allura/allura/model/auth.py
+++ b/Allura/allura/model/auth.py
@@ -300,7 +300,10 @@ class User(MappedClass, ActivityNode, ActivityObject, SearchIndexable):
results_per_page=int,
email_address=str,
email_format=str,
- disable_user_messages=bool))
+ disable_user_messages=bool,
+ multifactor=bool,
+ #totp=S.Binary,
+ ))
# Additional top-level fields can/should be accessed with get/set_pref also
# Not sure why we didn't put them within the 'preferences' dictionary :(
display_name = FieldPropertyDisplayName(str)
http://git-wip-us.apache.org/repos/asf/allura/blob/67cad38e/Allura/allura/model/multifactor.py
----------------------------------------------------------------------
diff --git a/Allura/allura/model/multifactor.py b/Allura/allura/model/multifactor.py
new file mode 100644
index 0000000..2a4e2f0
--- /dev/null
+++ b/Allura/allura/model/multifactor.py
@@ -0,0 +1,41 @@
+# 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.
+
+import logging
+
+from ming import schema as S
+from ming.odm import FieldProperty
+from ming.odm.declarative import MappedClass
+
+from .session import main_orm_session
+
+log = logging.getLogger(__name__)
+
+
+class TotpKey(MappedClass):
+ '''
+ For use with "mongodb" TOTP service
+ '''
+
+ class __mongometa__:
+ session = main_orm_session
+ name = 'multifactor_totp'
+ unique_indexes = ['user_id']
+
+ _id = FieldProperty(S.ObjectId)
+ user_id = FieldProperty(S.ObjectId, required=True)
+ key = FieldProperty(str, required=True)
http://git-wip-us.apache.org/repos/asf/allura/blob/67cad38e/Allura/allura/templates/login_multifactor.html
----------------------------------------------------------------------
diff --git a/Allura/allura/templates/login_multifactor.html b/Allura/allura/templates/login_multifactor.html
new file mode 100644
index 0000000..07eaa80
--- /dev/null
+++ b/Allura/allura/templates/login_multifactor.html
@@ -0,0 +1,39 @@
+{#-
+ 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 g.theme.master %}
+
+{% block title %}{{ config['site_name'] }} Multifactor Login{% endblock %}
+
+{% block header %}Multifactor Login{% endblock %}
+
+{% block content %}
+<form method="post" action="/auth/do_multifactor">
+ <h2>Please enter your Multifactor Authentication Code</h2>
+ {% if c.form_errors['code'] %}
+ <div class="fielderror">{{ c.form_errors['code'] }}</div>
+ {% 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">
+ {{ lib.csrf_token() }}
+</form>
+
+{% endblock %}
http://git-wip-us.apache.org/repos/asf/allura/blob/67cad38e/Allura/allura/templates/site_admin_user_details.html
----------------------------------------------------------------------
diff --git a/Allura/allura/templates/site_admin_user_details.html b/Allura/allura/templates/site_admin_user_details.html
index 1288ecb..d4d41d3 100644
--- a/Allura/allura/templates/site_admin_user_details.html
+++ b/Allura/allura/templates/site_admin_user_details.html
@@ -35,6 +35,7 @@
<li>Username: {{ user.username }} (<a href="{{ user.url() }}">Go to profile page</a>)</li>
<li>Full name: {{ user.get_pref('display_name') }}</li>
<li>Registered: {{ user.registration_date() }} ({{ h.ago(user.registration_date(), show_date_after=None) }})</li>
+ <li>Multifactor auth: {{ user.get_pref('multifactor') or False }}</li>
</ul>
{% block extra_general_info %}{% endblock %}
</div>
http://git-wip-us.apache.org/repos/asf/allura/blob/67cad38e/Allura/allura/templates/user_prefs.html
----------------------------------------------------------------------
diff --git a/Allura/allura/templates/user_prefs.html b/Allura/allura/templates/user_prefs.html
index 74d5c08..f2cd5fc 100644
--- a/Allura/allura/templates/user_prefs.html
+++ b/Allura/allura/templates/user_prefs.html
@@ -101,6 +101,36 @@
{% endif %}
{% endblock %}
+ {% block multifactor %}
+ {% if h.asbool(tg.config.get('auth.multifactor.totp', False)) %}
+ <fieldset class="preferences multifactor">
+ <legend>Multifactor Authentication</legend>
+ <p>Multifactor authentication is currently
+ {% set user_multifactor = c.user.get_pref('multifactor') %}
+ {% if user_multifactor %}
+ <strong style="color:green">enabled</strong>
+ {%- else -%}
+ <strong style="color:red">disabled</strong>
+ {%- endif -%}
+ .
+ </p>
+ <p><b class="fa fa-cog"></b> <a href="totp_new">
+ {% if user_multifactor %}
+ Regenerate multifactor key (e.g. for a new phone).
+ {% else %}
+ Set up multifactor authentication.
+ {% endif %}
+ </a></p>
+ {% if user_multifactor %}
+ <p><b class="fa fa-qrcode"></b> <a href="totp_view">View existing configuration</a></p>
+
+ {# FIXME, need confirmation #}
+ <p><b class="fa fa-trash"></b> <a href="multifactor_disable" class="post-link">Disable</a></p>
+ {% endif %}
+ </fieldset>
+ {% endif %}
+ {% endblock %}
+
{% block upload_key_form %}
{% if h.asbool(tg.config.get('auth.allow_upload_ssh_key', False)) %}
<fieldset class="preferences">
@@ -139,6 +169,11 @@
padding: 0;
border: 0;
}
+ .multifactor .fa {
+ font-size: 300%;
+ vertical-align: middle;
+ margin-right: 5px;
+ }
</style>
{% endblock %}
http://git-wip-us.apache.org/repos/asf/allura/blob/67cad38e/Allura/allura/templates/user_totp.html
----------------------------------------------------------------------
diff --git a/Allura/allura/templates/user_totp.html b/Allura/allura/templates/user_totp.html
new file mode 100644
index 0000000..2376948
--- /dev/null
+++ b/Allura/allura/templates/user_totp.html
@@ -0,0 +1,53 @@
+{#-
+ 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}} / Multifactor Authentication Setup{% endblock %}
+
+{% block header %}Multifactor Authentication Setup for {{c.user.username}}{% endblock %}
+
+{% block content %}
+ {{ super() }}
+ <div class='grid-20'>
+ {% if setup %}
+ {% if c.user.get_pref('multifactor') %}
+ <h3>Warning: this will invalidate your previous multifactor configuration.</h3>
+ {% endif %}
+ <h2>Install App</h2>
+ <p>To use two-factor authentication, you will need to install an app on your phone.
+ You can use Duo Mobile, Authy, Google Authenticator, or Authenticator for Windows phones.</p>
+ {% endif %}
+
+ <h2>Scan this barcode with your app</h2>
+ <img src="{{ h.base64uri(qr) }}"/>
+
+ {% if setup %}
+ <h2>Enter the code</h2>
+ <p>
+ Enter the 6-digit code to confirm it is set up correctly:
+ <form method="POST" action="totp_set">
+ <div class="fielderror">{{ c.form_errors['code'] }}</div>
+ <input type="text" name="code" autofocus autocomplete="off"/>
+ {{ lib.csrf_token() }}
+ </form>
+ </p>
+ {% endif %}
+ </div>
+{% endblock %}
http://git-wip-us.apache.org/repos/asf/allura/blob/67cad38e/Allura/allura/tests/test_multifactor.py
----------------------------------------------------------------------
diff --git a/Allura/allura/tests/test_multifactor.py b/Allura/allura/tests/test_multifactor.py
new file mode 100644
index 0000000..b531807
--- /dev/null
+++ b/Allura/allura/tests/test_multifactor.py
@@ -0,0 +1,64 @@
+# 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.
+
+
+import textwrap
+
+from nose.tools import assert_equal
+
+from allura.lib.multifactor import GoogleAuthenticatorFile
+
+
+class TestGoogleAuthenticatorFile(object):
+ sample = textwrap.dedent('''\
+ 7CL3WL756ISQCU5HRVNAODC44Q
+ " RATE_LIMIT 3 30
+ " DISALLOW_REUSE
+ " TOTP_AUTH
+ 43504045
+ 16951331
+ 16933944
+ 38009587
+ 49571579
+ ''')
+ # different key length
+ sample2 = textwrap.dedent('''\
+ LQQTTQUEW3VAGA6O5XICCWGBXUWXI737
+ " TOTP_AUTH
+ ''')
+
+ def test_parse(self):
+ gaf = GoogleAuthenticatorFile.load(self.sample)
+ assert_equal(gaf.key, b'\xf8\x97\xbb/\xfd\xf2%\x01S\xa7\x8dZ\x07\x0c\\\xe4')
+ assert_equal(gaf.options['RATE_LIMIT'], '3 30')
+ assert_equal(gaf.options['DISALLOW_REUSE'], None)
+ assert_equal(gaf.options['TOTP_AUTH'], None)
+ assert_equal(gaf.recovery_codes, [
+ '43504045',
+ '16951331',
+ '16933944',
+ '38009587',
+ '49571579',
+ ])
+
+ def test_dump(self):
+ gaf = GoogleAuthenticatorFile.load(self.sample)
+ assert_equal(gaf.dump(), self.sample)
+
+ def test_dump2(self):
+ gaf = GoogleAuthenticatorFile.load(self.sample2)
+ assert_equal(gaf.dump(), self.sample2)
http://git-wip-us.apache.org/repos/asf/allura/blob/67cad38e/Allura/development.ini
----------------------------------------------------------------------
diff --git a/Allura/development.ini b/Allura/development.ini
index e4e0ffc..24ab3be 100644
--- a/Allura/development.ini
+++ b/Allura/development.ini
@@ -190,6 +190,18 @@ auth.upload_ssh_url = /auth/preferences/
; In seconds
auth.recovery_hash_expiry_period = 600
+; TOTP stands for Time-based One Time Password
+; it is the most common two-factor auth protocol, used with Google Authenticator and other phone apps
+auth.multifactor.totp = true
+; Length of codes, 6 or 8 is recommended
+auth.multifactor.totp.length = 6
+; Time window codes are valid, in seconds. 30 is recommended
+auth.multifactor.totp.time = 30
+; secret key storage location. "filesystem-googleauth" is another option (compatible with Google Authenticator PAM plugin)
+auth.multifactor.totp.service = mongodb
+; if using filesystem storage:
+auth.multifactor.totp.filesystem.basedir = /var/lib/allura/totp-keys
+
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/67cad38e/Allura/setup.py
----------------------------------------------------------------------
diff --git a/Allura/setup.py b/Allura/setup.py
index 75a847b..c10ec73 100644
--- a/Allura/setup.py
+++ b/Allura/setup.py
@@ -124,6 +124,10 @@ setup(
[allura.webhooks]
repo-push = allura.webhooks:RepoPushWebhookSender
+ [allura.multifactor.totp]
+ mongodb = allura.lib.multifactor:MongodbTotpService
+ filesystem-googleauth = allura.lib.multifactor:GoogleAuthenticatorPamFilesystemTotpService
+
[paste.paster_command]
taskd = allura.command.taskd:TaskdCommand
taskd_cleanup = allura.command.taskd_cleanup:TaskdCleanupCommand
http://git-wip-us.apache.org/repos/asf/allura/blob/67cad38e/requirements.txt
----------------------------------------------------------------------
diff --git a/requirements.txt b/requirements.txt
index 9876b17..fc32006 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -37,6 +37,7 @@ python-dateutil==1.5
python-magic==0.4.3
python-oembed==0.2.1
pytz==2014.10
+qrcode==5.3
requests==2.0.0
oauthlib==0.4.2
requests-oauthlib==0.4.0
@@ -51,6 +52,17 @@ TimerMiddleware==0.4.4
TurboGears2==2.1.5
WebOb==1.0.8
+# dependencies for cryptography
+cryptography==1.4
+# loose spec for setuptools since it is a very base package; but cryptography needs it past a certain version
+setuptools>=11.3
+cffi==1.7.0
+pycparser==2.14
+enum34==1.1.6
+ipaddress==1.0.16
+idna==2.1
+pyasn1==0.1.9
+
# tg2 deps (not used directly)
Babel==1.3
Mako==0.3.2