From 2c390fe71ae830338e95d05898054b5eabb044ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Tue, 31 May 2016 12:49:23 +0200 Subject: [PATCH] Add customizable login process It adds new configuration option to define the list of login methods. It defines a new Exception to use to request user data during the login process. It separates the generic login attempt protection from the actual authentication method. It replace the API of the login methods to use a dictionary of parameters instead of a single password. issue5521 review16191003 --- CHANGELOG | 1 + doc/topics/configuration.rst | 11 ++++++++ doc/topics/index.rst | 1 + doc/topics/rpc.rst | 50 +++++++++++++++++++++++++++++++++ trytond/config.py | 1 + trytond/exceptions.py | 14 +++++++++ trytond/protocols/dispatcher.py | 16 +++++++---- trytond/protocols/wrappers.py | 2 +- trytond/res/user.py | 35 +++++++++++++++++------ trytond/security.py | 13 +++++++-- trytond/tests/test_user.py | 14 +++++++-- 11 files changed, 139 insertions(+), 19 deletions(-) create mode 100644 doc/topics/rpc.rst diff --git a/CHANGELOG b/CHANGELOG index 3b4ba1ae73..b6d8eea971 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,4 @@ +* Add customizable login process * Allow to customize the substitutions used on sequence * Allow PYSON in tree_invisible attribute diff --git a/doc/topics/configuration.rst b/doc/topics/configuration.rst index f02029fa19..367ca52085 100644 --- a/doc/topics/configuration.rst +++ b/doc/topics/configuration.rst @@ -201,6 +201,17 @@ Defines the default `From` address for emails sent by Tryton. session ------- +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. + +Default: `password` + timeout ~~~~~~~ diff --git a/doc/topics/index.rst b/doc/topics/index.rst index 6560776ff7..2a170c7e60 100644 --- a/doc/topics/index.rst +++ b/doc/topics/index.rst @@ -25,5 +25,6 @@ Introduction to all the key parts of trytond: views/extension wizard reports/index + rpc modules/index translation diff --git a/doc/topics/rpc.rst b/doc/topics/rpc.rst new file mode 100644 index 0000000000..32c4de5f84 --- /dev/null +++ b/doc/topics/rpc.rst @@ -0,0 +1,50 @@ +.. _topics-rpc: + +===================== +Remote Procedure Call +===================== + +There are two protocols supported by trytond: `JSON-RPC`_ (Version 1.0) and +`XML-RPC`_. +The URL of the calls must end with the database name with a trailing '/'. + +The available methods are: + +common.db.login +--------------- + +It takes as parameters: the user name and a dictionary of login parameters. +It returns in case of success the user ID and the session. +If the parameters are not valid to authenticate the user, it returns nothing. +Otherwise if it misses a key in the parameters, it raises a `LoginException` +exception with the missing key name, type and the message to ask to the +user. + +common.db.logout +---------------- + +It takes no parameters and it invalidate the current session. + +.. TODO:: other methods + +.. _`JSON-RPC`: https://en.wikipedia.org/wiki/JSON-RPC +.. _`XML-RPC`: https://en.wikipedia.org/wiki/XML-RPC + +Authorization +============= + +Most of the calls require authorization, there are two methods: + +Basic +----- + +It follows the `Basic access authentication`_. + +.. _`Basic access authentication`: https://en.wikipedia.org/wiki/Basic_access_authentication + +Session +------- + +The authorization field is constructed by the username, the user ID and the +session combined with a single colon and encoded in Base64. +The session is retrieved by calling the method `common.db.login`. diff --git a/trytond/config.py b/trytond/config.py index 44ceaf9576..dc69ecc8ed 100644 --- a/trytond/config.py +++ b/trytond/config.py @@ -59,6 +59,7 @@ class TrytonConfigParser(ConfigParser.RawConfigParser): self.add_section('email') self.set('email', 'uri', 'smtp://localhost:25') self.add_section('session') + self.set('session', 'authentications', 'password') self.set('session', 'timeout', 600) self.add_section('report') self.set('report', 'unoconv', diff --git a/trytond/exceptions.py b/trytond/exceptions.py index 1b05f7886e..86cb32e181 100644 --- a/trytond/exceptions.py +++ b/trytond/exceptions.py @@ -26,6 +26,20 @@ class UserWarning(TrytonException): self.code = 2 +class LoginException(TrytonException): + """Request the named parameter for the login process. + The type can be 'password' or 'char'. + """ + + def __init__(self, name, message, type='password'): + super(LoginException, self).__init__( + 'LoginException', (name, message, type)) + self.name = name + self.message = message + self.type = type + self.code = 3 + + class ConcurrencyException(TrytonException): def __init__(self, message): diff --git a/trytond/protocols/dispatcher.py b/trytond/protocols/dispatcher.py index 7414ec3112..dbed4fab49 100644 --- a/trytond/protocols/dispatcher.py +++ b/trytond/protocols/dispatcher.py @@ -7,6 +7,7 @@ import pydoc from functools import wraps from werkzeug.utils import redirect +from werkzeug.exceptions import abort from sql import Table from trytond.pool import Pool @@ -16,7 +17,8 @@ from trytond.config import config from trytond import __version__ from trytond.transaction import Transaction from trytond.cache import Cache -from trytond.exceptions import UserError, UserWarning, ConcurrencyException +from trytond.exceptions import ( + UserError, UserWarning, ConcurrencyException, LoginException) from trytond.tools import is_instance_method from trytond.wsgi import app @@ -59,21 +61,24 @@ def rpc(request, database_name): request, database_name, *request.params) -def login(request, database_name, user, password): +def login(request, database_name, user, parameters, language=None): Database = backend.get('Database') DatabaseOperationalError = backend.get('DatabaseOperationalError') try: Database(database_name).connect() except DatabaseOperationalError: logger.error('fail to connect to %s', database_name, exc_info=True) - return False - session = security.login(database_name, user, password) + abort(403) + session = security.login( + database_name, user, parameters, language=language) with Transaction().start(database_name, 0): Cache.clean(database_name) Cache.resets(database_name) msg = 'successful login' if session else 'bad login or password' logger.info('%s \'%s\' from %s using %s on database \'%s\'', msg, user, request.remote_addr, request.scheme, database_name) + if not session: + abort(403) return session @@ -213,7 +218,8 @@ def _dispatch(request, pool, *args, **kwargs): continue logger.error(log_message, *log_args, exc_info=True) raise - except (ConcurrencyException, UserError, UserWarning): + except (ConcurrencyException, UserError, UserWarning, + LoginException): logger.debug(log_message, *log_args, exc_info=True) raise except Exception: diff --git a/trytond/protocols/wrappers.py b/trytond/protocols/wrappers.py index bf53950ea0..e1716e3698 100644 --- a/trytond/protocols/wrappers.py +++ b/trytond/protocols/wrappers.py @@ -58,7 +58,7 @@ class Request(_Request): abort(403) else: user_id = security.login( - database_name, auth.username, auth.password, cache=False) + database_name, auth.username, auth, cache=False) if not user_id: abort(401) return user_id diff --git a/trytond/res/user.py b/trytond/res/user.py index 64d500bea1..6949d6fef1 100644 --- a/trytond/res/user.py +++ b/trytond/res/user.py @@ -7,6 +7,7 @@ import random import hashlib import time import datetime +import logging from functools import wraps from itertools import groupby, ifilter from operator import attrgetter @@ -32,11 +33,13 @@ from ..pool import Pool from ..config import config from ..pyson import PYSONEncoder from ..rpc import RPC +from ..exceptions import LoginException __all__ = [ 'User', 'LoginAttempt', 'UserAction', 'UserGroup', 'Warning_', 'UserConfigStart', 'UserConfig', ] +logger = logging.getLogger(__name__) class User(ModelSQL, ModelView): @@ -361,9 +364,9 @@ class User(ModelSQL, ModelView): return preferences.copy() @classmethod - def set_preferences(cls, values, old_password=False): + def set_preferences(cls, values, parameters): ''' - Set user preferences. + Set user preferences using login parameters ''' pool = Pool() Lang = pool.get('ir.lang') @@ -375,7 +378,7 @@ class User(ModelSQL, ModelView): if field not in fields or field == 'groups': del values_clean[field] if field == 'password': - if not cls.get_login(user.login, old_password): + if not cls._login_password(user.login, parameters): cls.raise_user_error('wrong_password') if field == 'language': langs = Lang.search([ @@ -460,19 +463,35 @@ class User(ModelSQL, ModelView): return result @classmethod - def get_login(cls, login, password): + def get_login(cls, login, parameters): ''' Return user id if password matches ''' LoginAttempt = Pool().get('res.user.login.attempt') time.sleep(2 ** LoginAttempt.count(login) - 1) - user_id, password_hash = cls._get_login(login) - if user_id: - if cls.check_password(password, password_hash): + for method 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: LoginAttempt.remove(login) return user_id LoginAttempt.add(login) - return 0 + + @classmethod + def _login_password(cls, login, parameters): + if 'password' not in parameters: + msg = cls.fields_get(['password'])['password']['string'] + raise LoginException('password', msg, type='password') + user_id, password_hash = cls._get_login(login) + if user_id and password_hash: + password = parameters['password'] + if cls.check_password(password, password_hash): + return user_id @staticmethod def hash_method(): diff --git a/trytond/security.py b/trytond/security.py index c874c7cb53..034ac48924 100644 --- a/trytond/security.py +++ b/trytond/security.py @@ -9,6 +9,7 @@ from trytond.pool import Pool from trytond.config import config from trytond.transaction import Transaction from trytond import backend +from trytond.exceptions import LoginException def _get_pool(dbname): @@ -19,11 +20,17 @@ def _get_pool(dbname): return pool -def login(dbname, loginname, password, cache=True): - with Transaction().start(dbname, 0): +def login(dbname, loginname, parameters, cache=True, language=None): + context = {'language': language} + with Transaction().start(dbname, 0, context=context) as transaction: pool = _get_pool(dbname) User = pool.get('res.user') - user_id = User.get_login(loginname, password) + try: + user_id = User.get_login(loginname, parameters) + except LoginException: + # Let's store any changes done + transaction.commit() + raise if user_id: if not cache: return user_id diff --git a/trytond/tests/test_user.py b/trytond/tests/test_user.py index a6a75e94c7..109e5953bb 100644 --- a/trytond/tests/test_user.py +++ b/trytond/tests/test_user.py @@ -5,6 +5,7 @@ import unittest from trytond.tests.test_tryton import install_module, with_transaction from trytond.pool import Pool from trytond.res.user import bcrypt +from trytond.config import config class UserTestCase(unittest.TestCase): @@ -14,6 +15,11 @@ class UserTestCase(unittest.TestCase): def setUpClass(cls): install_module('res') + def setUp(self): + methods = config.get('session', 'authentications') + config.set('session', 'authentications', 'password') + self.addCleanup(config.set, 'session', 'authentications', methods) + def create_user(self, login, password, hash_method=None): pool = Pool() User = pool.get('res.user') @@ -37,10 +43,14 @@ class UserTestCase(unittest.TestCase): User = pool.get('res.user') user, = User.search([('login', '=', login)]) - user_id = User.get_login(login, password) + user_id = User.get_login(login, { + 'password': password, + }) self.assertEqual(user_id, user.id) - bad_user_id = User.get_login(login, password + 'wrong') + bad_user_id = User.get_login(login, { + 'password': password + 'wrong', + }) self.assertEqual(bad_user_id, 0) @with_transaction()