changeset 92d471068c78 in trytond:default
details: https://hg.tryton.org/trytond?cmd=changeset&node=92d471068c78
description:
        Add ip_address and device_cookie login method options

        issue10987
        review360491008
diffstat:

 CHANGELOG                    |   1 +
 doc/topics/configuration.rst |  26 +++++++++++++++++-
 trytond/res/user.py          |  42 +++++++++++++++++++++++++++++
 trytond/tests/test_user.py   |  62 ++++++++++++++++++++++++++++++++++++++++++++
 4 files changed, 129 insertions(+), 2 deletions(-)

diffs (194 lines):

diff -r 0fc174ad1504 -r 92d471068c78 CHANGELOG
--- a/CHANGELOG Tue Dec 21 23:29:45 2021 +0100
+++ b/CHANGELOG Wed Dec 29 12:39:51 2021 +0100
@@ -1,3 +1,4 @@
+* Add ip_address and device_cookie login method options
 * Add support for Python 3.10
 * Remove support for Python 3.6
 * Add creatable attribute on tree and form views
diff -r 0fc174ad1504 -r 92d471068c78 doc/topics/configuration.rst
--- a/doc/topics/configuration.rst      Tue Dec 21 23:29:45 2021 +0100
+++ b/doc/topics/configuration.rst      Wed Dec 29 12:39:51 2021 +0100
@@ -405,13 +405,35 @@
 
     authentications = password+sms,ldap
 
+Each combined method can have options to skip them if they are met except for
+the first method.
+They are defined by appending their name to the method name after a question
+mark (``?``) and separated by colons (``:``).
+
+Example::
+
+   authentications = password+sms?ip_address:device_cookie
+
+
 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.
+By default, Tryton supports the ``ip_address`` and ``device_cookie`` options.
+The ``ip_address`` compares the client IP address with the known network list
+defined in `authentication_ip_network`_.
+The ``device_cookie`` checks the client device is a known device of the user.
+Other modules can define additional authentication methods and options, please
+refer to their documentation for more information.
 
 Default: ``password``
 
+authentication_ip_network
+~~~~~~~~~~~~~~~~~~~~~~~~~
+
+A comma separated list of known IP networks used to check for ``ip_address``
+authentication method option.
+
+Default: ``''``
+
 max_age
 ~~~~~~~
 
diff -r 0fc174ad1504 -r 92d471068c78 trytond/res/user.py
--- a/trytond/res/user.py       Tue Dec 21 23:29:45 2021 +0100
+++ b/trytond/res/user.py       Wed Dec 29 12:39:51 2021 +0100
@@ -670,6 +670,7 @@
         pool = Pool()
         LoginAttempt = pool.get('res.user.login.attempt')
         UserDevice = pool.get('res.user.device')
+        parameters = parameters.copy()
 
         count_ip = LoginAttempt.count_ip()
         if count_ip > config.getint(
@@ -678,6 +679,7 @@
             raise RateLimitException()
         device_cookie = UserDevice.get_valid_cookie(
             login, parameters.get('device_cookie'))
+        parameters['device_cookie'] = device_cookie
         count = LoginAttempt.count(login, device_cookie)
         if count > config.getint('session', 'max_attempt', default=5):
             LoginAttempt.add(login, device_cookie)
@@ -687,6 +689,15 @@
                 'session', 'authentications', default='password').split(','):
             user_ids = set()
             for method in methods.split('+'):
+                if user_ids:
+                    try:
+                        method, options = method.split('?', 1)
+                    except ValueError:
+                        options = []
+                    else:
+                        options = options.split(':')
+                    if cls._check_login_options(options, login, parameters):
+                        continue
                 try:
                     func = getattr(cls, '_login_%s' % method)
                 except AttributeError:
@@ -701,6 +712,37 @@
         LoginAttempt.add(login, device_cookie)
 
     @classmethod
+    def _check_login_options(cls, options, login, parameters):
+        for option in options:
+            try:
+                func = getattr(cls, '_check_login_options_%s' % option)
+            except AttributeError:
+                logger.info("Missing login option: %s", option)
+                continue
+            if func(login, parameters):
+                return True
+        else:
+            return False
+
+    @classmethod
+    def _check_login_options_ip_address(cls, login, parameters):
+        context = Transaction().context
+        if context.get('_request') and context['_request'].get('remote_addr'):
+            ip_address = ipaddress.ip_address(
+                str(context['_request']['remote_addr']))
+            network_list = config.get('session', 'authentication_ip_network')
+            if network_list:
+                for network in network_list.split(','):
+                    ip_network = ipaddress.ip_network(network)
+                    if ip_address in ip_network:
+                        return True
+        return False
+
+    @classmethod
+    def _check_login_options_device_cookie(cls, login, parameters):
+        return bool(parameters.get('device_cookie'))
+
+    @classmethod
     def _login_password(cls, login, parameters):
         if 'password' not in parameters:
             msg = gettext('res.msg_user_password', login=login)
diff -r 0fc174ad1504 -r 92d471068c78 trytond/tests/test_user.py
--- a/trytond/tests/test_user.py        Tue Dec 21 23:29:45 2021 +0100
+++ b/trytond/tests/test_user.py        Wed Dec 29 12:39:51 2021 +0100
@@ -291,6 +291,68 @@
         self.assertEqual(LoginAttempt.count('user', None), 1)
         self.assertEqual(LoginAttempt.count('user', cookie), 0)
 
+    @with_transaction()
+    def test_authentication_option_ip_address(self):
+        "Test authentication with ip_address option"
+        pool = Pool()
+        User = pool.get('res.user')
+
+        user = User(login='user')
+        user.save()
+
+        ip_network = config.get(
+            'session', 'authentication_ip_network', default='')
+        config.set(
+            'session', 'authentication_ip_network',
+            '192.168.0.0/16,127.0.0.0/8')
+        self.addCleanup(
+            config.set, 'session', 'authentication_ip_network', ip_network)
+
+        with patch.object(User, '_login_always', create=True) as always, \
+                patch.object(User, '_login_never', create=True) as never:
+            always.return_value = user.id
+            never.return_value = None
+
+            with set_authentications('always+never?ip_address'):
+                for address, result in [
+                        ('192.168.0.1', user.id),
+                        ('172.17.0.1', None),
+                        ('127.0.0.1', user.id),
+                        ]:
+                    with self.subTest(address=address):
+                        with Transaction().set_context(_request={
+                                    'remote_addr': address,
+                                    }):
+                            self.assertEqual(
+                                User.get_login('user', {}), result)
+
+    @with_transaction()
+    def test_authentication_option_device_cookie(self):
+        "Test authentication with device cookie option"
+        pool = Pool()
+        User = pool.get('res.user')
+        UserDevice = pool.get('res.user.device')
+
+        user = User(login='user')
+        user.save()
+        with Transaction().set_user(user.id):
+            cookie = UserDevice.renew(None)
+
+        with patch.object(User, '_login_always', create=True) as always, \
+                patch.object(User, '_login_never', create=True) as never:
+            always.return_value = user.id
+            never.return_value = None
+
+            with set_authentications('always+never?device_cookie'):
+                for value, result in [
+                        (cookie, user.id),
+                        ('not cookie', None),
+                        ]:
+                    with self.subTest(cookie=value):
+                        self.assertEqual(
+                            User.get_login('user', {'device_cookie': value}),
+                            result)
+
 
 def suite():
     return unittest.TestLoader().loadTestsFromTestCase(UserTestCase)

Reply via email to