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:25 UTC

[3/4] allura git commit: [#8118] refactor and share Google Authenticator file handling, implement recovery codes for it

[#8118] refactor and share Google Authenticator file handling, implement recovery codes for it


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

Branch: refs/heads/master
Commit: 533d61e45bb1db6da83ea3f318cfcf74e57b0f1a
Parents: c044a01
Author: Dave Brondsema <da...@brondsema.net>
Authored: Tue Sep 6 15:23:47 2016 -0400
Committer: Dave Brondsema <da...@brondsema.net>
Committed: Wed Sep 7 13:54:24 2016 -0400

----------------------------------------------------------------------
 Allura/allura/lib/multifactor.py        | 85 ++++++++++++++++--------
 Allura/allura/tests/test_multifactor.py | 97 ++++++++++++++++++++++------
 2 files changed, 135 insertions(+), 47 deletions(-)
----------------------------------------------------------------------


http://git-wip-us.apache.org/repos/asf/allura/blob/533d61e4/Allura/allura/lib/multifactor.py
----------------------------------------------------------------------
diff --git a/Allura/allura/lib/multifactor.py b/Allura/allura/lib/multifactor.py
index 95683df..cfd6247 100644
--- a/Allura/allura/lib/multifactor.py
+++ b/Allura/allura/lib/multifactor.py
@@ -193,11 +193,7 @@ class GoogleAuthenticatorFile(object):
         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
-    '''
+class GoogleAuthenticatorPamFilesystemMixin(object):
 
     @property
     def basedir(self):
@@ -209,39 +205,54 @@ class GoogleAuthenticatorPamFilesystemTotpService(TotpService):
             raise ValueError('Insecure username contains "/": %s' % username)
         return os.path.join(self.basedir, username, '.google_authenticator')
 
-    def get_secret_key(self, user):
+    def read_file(self, user, autocreate=False):
+        if autocreate:
+            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())
-                return gaf.key
+                return GoogleAuthenticatorFile.load(f.read())
         except IOError as e:
             if e.errno == errno.ENOENT:  # file doesn't exist
-                return None
+                if autocreate:
+                    gaf = GoogleAuthenticatorFile()
+                    gaf.options['RATE_LIMIT'] = '3 30'
+                    gaf.options['DISALLOW_REUSE'] = None
+                    gaf.options['TOTP_AUTH'] = None
+                    return gaf
+                else:
+                    return None
             else:
                 raise
 
+    def write_file(self, user, gaf):
+        with open(self.config_file(user), 'w') as f:
+            f.write(gaf.dump())
+
+
+class GoogleAuthenticatorPamFilesystemTotpService(TotpService, GoogleAuthenticatorPamFilesystemMixin):
+    '''
+    Store in home directories, compatible with the TOTP PAM module for Google Authenticator
+    https://github.com/google/google-authenticator/tree/master/libpam
+    '''
+
+    def get_secret_key(self, user):
+        gaf = self.read_file(user)
+        if gaf:
+            return gaf.key
+        else:
+            return None
+
     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 = self.read_file(user, autocreate=True)
             gaf.key = key
-            with open(self.config_file(user), 'w') as f:
-                f.write(gaf.dump())
+            self.write_file(user, gaf)
 
 
 class RecoveryCodeService(object):
@@ -333,7 +344,27 @@ class MongodbRecoveryCodeService(RecoveryCodeService):
             raise InvalidRecoveryCode
 
 
-class GoogleAuthenticatorPamFilesystemRecoveryCodeService(RecoveryCodeService):
+class GoogleAuthenticatorPamFilesystemRecoveryCodeService(RecoveryCodeService, GoogleAuthenticatorPamFilesystemMixin):
 
     def get_codes(self, user):
-        return []
+        gaf = self.read_file(user)
+        if gaf:
+            return gaf.recovery_codes
+        else:
+            return []
+
+    def replace_codes(self, user, codes):
+        gaf = self.read_file(user)
+        if gaf:
+            gaf.recovery_codes = codes
+            self.write_file(user, gaf)
+        elif codes:
+            raise IOError('No .google-authenticator file exists, cannot add recovery codes.')
+
+    def verify_and_remove_code(self, user, code):
+        gaf = self.read_file(user)
+        if gaf and code in gaf.recovery_codes:
+            gaf.recovery_codes.remove(code)
+            self.write_file(user, gaf)
+            return True
+        raise InvalidRecoveryCode

http://git-wip-us.apache.org/repos/asf/allura/blob/533d61e4/Allura/allura/tests/test_multifactor.py
----------------------------------------------------------------------
diff --git a/Allura/allura/tests/test_multifactor.py b/Allura/allura/tests/test_multifactor.py
index 6828757..3c7bb03 100644
--- a/Allura/allura/tests/test_multifactor.py
+++ b/Allura/allura/tests/test_multifactor.py
@@ -14,8 +14,7 @@
 #       KIND, either express or implied.  See the License for the
 #       specific language governing permissions and limitations
 #       under the License.
-
-
+import shutil
 import textwrap
 import os
 
@@ -32,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
+from allura.lib.multifactor import GoogleAuthenticatorPamFilesystemRecoveryCodeService
 
 
 class TestGoogleAuthenticatorFile(object):
@@ -154,11 +154,20 @@ class TestMongodbTotpService():
         assert_equal(None, srv.get_secret_key(user))
 
 
-class TestGoogleAuthenticatorPamFilesystemTotpService():
-    sample_key = b'\x00K\xda\xbfv\xc2B\xaa\x1a\xbe\xa5\x96b\xb2\xa0Z:\xc9\xcf\x8a'
+class TestGoogleAuthenticatorPamFilesystemMixin(object):
 
     def setUp(self):
-        config['auth.multifactor.totp.filesystem.basedir'] = os.path.join(os.getenv('TMPDIR', '/tmp'), 'totp-test')
+        self.totp_basedir = os.path.join(os.getenv('TMPDIR', '/tmp'), 'totp-test')
+        config['auth.multifactor.totp.filesystem.basedir'] = self.totp_basedir
+
+    def tearDown(self):
+        if os.path.exists(self.totp_basedir):
+            shutil.rmtree(self.totp_basedir)
+
+
+class TestGoogleAuthenticatorPamFilesystemTotpService(TestGoogleAuthenticatorPamFilesystemMixin):
+
+    sample_key = b'\x00K\xda\xbfv\xc2B\xaa\x1a\xbe\xa5\x96b\xb2\xa0Z:\xc9\xcf\x8a'
 
     def test_none(self):
         srv = GoogleAuthenticatorPamFilesystemTotpService()
@@ -202,24 +211,24 @@ class TestRecoveryCodeService(object):
         assert_equal(len(recovery.saved_codes), asint(config.get('auth.multifactor.recovery_code.count', 10)))
 
 
-class TestMongodbRecoveryCodeService(object):
+class TestAnyRecoveryCodeServiceImplementation(object):
 
-    def setUp(self):
-        config = {
-            'ming.main.uri': 'mim://allura_test',
-        }
-        ming.configure(**config)
+    __test__ = False
 
-    def test_get_codes(self):
-        recovery = MongodbRecoveryCodeService()
-        user = Mock(_id=bson.ObjectId())
+    def test_get_codes_none(self):
+        recovery = self.Service()
+        user = self.mock_user()
         assert_equal(recovery.get_codes(user), [])
+
+    def test_regen_get_codes(self):
+        recovery = self.Service()
+        user = self.mock_user()
         recovery.regenerate_codes(user)
         assert recovery.get_codes(user)
 
     def test_replace_codes(self):
-        recovery = MongodbRecoveryCodeService()
-        user = Mock(_id=bson.ObjectId())
+        recovery = self.Service()
+        user = self.mock_user()
         codes = [
             '12345',
             '67890'
@@ -228,16 +237,16 @@ class TestMongodbRecoveryCodeService(object):
         assert_equal(recovery.get_codes(user), codes)
 
     def test_verify_fail(self):
-        recovery = MongodbRecoveryCodeService()
-        user = Mock(_id=bson.ObjectId())
+        recovery = self.Service()
+        user = self.mock_user()
         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())
+        recovery = self.Service()
+        user = self.mock_user()
         codes = [
             '12345',
             '67890'
@@ -246,3 +255,51 @@ class TestMongodbRecoveryCodeService(object):
         result = recovery.verify_and_remove_code(user, '12345')
         assert_equal(result, True)
         assert_equal(recovery.get_codes(user), ['67890'])
+
+
+class TestMongodbRecoveryCodeService(TestAnyRecoveryCodeServiceImplementation):
+
+    __test__ = True
+
+    Service = MongodbRecoveryCodeService
+
+    def setUp(self):
+        config = {
+            'ming.main.uri': 'mim://allura_test',
+        }
+        ming.configure(**config)
+
+    def mock_user(self):
+        return Mock(_id=bson.ObjectId())
+
+
+class TestGoogleAuthenticatorPamFilesystemRecoveryCodeService(TestAnyRecoveryCodeServiceImplementation,
+                                                              TestGoogleAuthenticatorPamFilesystemMixin):
+
+    __test__ = True
+
+    Service = GoogleAuthenticatorPamFilesystemRecoveryCodeService
+
+    def mock_user(self):
+        return Mock(username='some-user-guy')
+
+    def setUp(self):
+        super(TestGoogleAuthenticatorPamFilesystemRecoveryCodeService, self).setUp()
+
+        # make a regular .google-authenticator file first, so recovery keys have somewhere to go
+        GoogleAuthenticatorPamFilesystemTotpService().set_secret_key(self.mock_user(),
+                                                                     b'\x00K\xda\xbfv\xc2B\xaa\x1a\xbe\xa5\x96b\xb2\xa0Z:\xc9\xcf\x8a')
+
+    def test_get_codes_none_when_no_file(self):
+        # this deletes the file
+        GoogleAuthenticatorPamFilesystemTotpService().set_secret_key(self.mock_user(), None)
+
+        super(TestGoogleAuthenticatorPamFilesystemRecoveryCodeService, self).test_get_codes_none()
+
+    def test_replace_codes_when_no_file(self):
+        # this deletes the file
+        GoogleAuthenticatorPamFilesystemTotpService().set_secret_key(self.mock_user(), None)
+
+        # then it errors because no .google-authenticator file
+        with assert_raises(IOError):
+            super(TestGoogleAuthenticatorPamFilesystemRecoveryCodeService, self).test_replace_codes()