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)