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()