changeset 6845ead3108d in trytond:default
details: https://hg.tryton.org/trytond?cmd=changeset;node=6845ead3108d
description:
        Allow authentication methods to be combined

        issue9303
        review291621002
diffstat:

 CHANGELOG                    |   1 +
 doc/topics/configuration.rst |  19 ++++++++++++++-----
 trytond/res/user.py          |  27 ++++++++++++++++-----------
 trytond/tests/test_user.py   |  41 ++++++++++++++++++++++++++++++++++++++++-
 4 files changed, 71 insertions(+), 17 deletions(-)

diffs (159 lines):

diff -r b0684e2aa5b0 -r 6845ead3108d CHANGELOG
--- a/CHANGELOG Tue May 19 19:36:09 2020 +0200
+++ b/CHANGELOG Thu May 21 09:06:10 2020 +0200
@@ -1,3 +1,4 @@
+* Allow combining authentication methods together
 * Add context to export CSV route
 
 Version 5.6.0 - 2020-05-04
diff -r b0684e2aa5b0 -r 6845ead3108d doc/topics/configuration.rst
--- a/doc/topics/configuration.rst      Tue May 19 19:36:09 2020 +0200
+++ b/doc/topics/configuration.rst      Thu May 21 09:06:10 2020 +0200
@@ -283,11 +283,19 @@
 authentications
 ~~~~~~~~~~~~~~~
 
-A comma separated list of login methods to use to authenticate the user.
-By default, Tryton supports only the `password` method which compare the
-password entered by the user against a stored hash. But other modules can
-define new methods (please refers to their documentation).
-The methods are tested following the order of the list.
+A comma separated list of the authentication methods to try when attempting to
+verify a user's identity. Each method is tried in turn, following the order of
+the list, until one succeeds. In order to allow `multi-factor authentication`_,
+individual methods can be combined together using a plus (`+`) symbol.
+
+Example::
+
+    authentications = password+sms,ldap
+
+By default, Tryton only supports the `password` method.  This method compares
+the password entered by the user against a stored hash of the user's password.
+Other modules can define additional authentication methods, please refer to
+their documentation for more information.
 
 Default: `password`
 
@@ -496,3 +504,4 @@
 .. _SSL-CERT: https://docs.python.org/library/ssl.html#ssl.wrap_socket
 .. _STARTTLS: http://en.wikipedia.org/wiki/STARTTLS
 .. _WSGI middleware: 
https://en.wikipedia.org/wiki/Web_Server_Gateway_Interface#Specification_overview
+.. _`multi-factor authentication`: 
https://en.wikipedia.org/wiki/Multi-factor_authentication
diff -r b0684e2aa5b0 -r 6845ead3108d trytond/res/user.py
--- a/trytond/res/user.py       Tue May 19 19:36:09 2020 +0200
+++ b/trytond/res/user.py       Thu May 21 09:06:10 2020 +0200
@@ -11,6 +11,7 @@
 import logging
 import uuid
 import mmap
+import re
 try:
     import secrets
 except ImportError:
@@ -59,8 +60,8 @@
     'UserConfigStart', 'UserConfig',
     ]
 logger = logging.getLogger(__name__)
-_has_password = 'password' in config.get(
-    'session', 'authentications', default='password').split(',')
+_has_password = 'password' in re.split('[,+]', config.get(
+    'session', 'authentications', default='password'))
 
 passlib_path = config.get('password', 'passlib')
 if passlib_path:
@@ -625,17 +626,21 @@
             LoginAttempt.add(login)
             raise RateLimitException()
         Transaction().atexit(time.sleep, random.randint(0, 2 ** count - 1))
-        for method in config.get(
+        for methods in config.get(
                 'session', 'authentications', default='password').split(','):
-            try:
-                func = getattr(cls, '_login_%s' % method)
-            except AttributeError:
-                logger.info('Missing login method: %s', method)
-                continue
-            user_id = func(login, parameters)
-            if user_id:
+            user_ids = set()
+            for method in methods.split('+'):
+                try:
+                    func = getattr(cls, '_login_%s' % method)
+                except AttributeError:
+                    logger.info('Missing login method: %s', method)
+                    break
+                user_ids.add(func(login, parameters))
+                if len(user_ids) != 1 or not all(user_ids):
+                    break
+            if len(user_ids) == 1 and all(user_ids):
                 LoginAttempt.remove(login)
-                return user_id
+                return user_ids.pop()
         LoginAttempt.add(login)
 
     @classmethod
diff -r b0684e2aa5b0 -r 6845ead3108d trytond/tests/test_user.py
--- a/trytond/tests/test_user.py        Tue May 19 19:36:09 2020 +0200
+++ b/trytond/tests/test_user.py        Thu May 21 09:06:10 2020 +0200
@@ -3,7 +3,8 @@
 import datetime
 import os
 import unittest
-from unittest.mock import patch, ANY
+from contextlib import contextmanager
+from unittest.mock import patch, ANY, Mock
 
 from trytond.tests.test_tryton import activate_module, with_transaction
 from trytond.pool import Pool
@@ -15,6 +16,16 @@
 FROM = 'try...@example.com'
 
 
+@contextmanager
+def set_authentications(methods):
+    saved_methods = config.get('session', 'authentications')
+    config.set('session', 'authentications', methods)
+    try:
+        yield
+    finally:
+        config.set('session', 'authentications', saved_methods)
+
+
 class UserTestCase(unittest.TestCase):
     'Test User'
 
@@ -205,6 +216,34 @@
                     'password': user.password_reset,
                     }))
 
+    @with_transaction()
+    def test_authentications(self):
+        "Test authentications"
+        pool = Pool()
+        User = pool.get('res.user')
+
+        user = User(login='user')
+        user.save()
+
+        User._login_always = Mock(return_value=user.id)
+        User._login_different = Mock(return_value=user.id + 1)
+        User._login_never = Mock(return_value=None)
+
+        for methods, result in (
+                ('never,never', None),
+                ('never,always', user.id),
+                ('always,never', user.id),
+                ('always,always', user.id),
+                ('never+never', None),
+                ('never+always', None),
+                ('always+never', None),
+                ('always+always', user.id),
+                ('always+different', None),
+                ):
+            with self.subTest(methods=methods, result=result):
+                with set_authentications(methods):
+                    self.assertEqual(User.get_login('user', {}), result)
+
 
 def suite():
     return unittest.TestLoader().loadTestsFromTestCase(UserTestCase)

Reply via email to