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