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
This commit is contained in:
Cédric Krier
2016-05-31 12:49:23 +02:00
parent 39c58f14d1
commit 2c390fe71a
11 changed files with 139 additions and 19 deletions
+1
View File
@@ -1,3 +1,4 @@
* Add customizable login process
* Allow to customize the substitutions used on sequence
* Allow PYSON in tree_invisible attribute
+11
View File
@@ -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
~~~~~~~
+1
View File
@@ -25,5 +25,6 @@ Introduction to all the key parts of trytond:
views/extension
wizard
reports/index
rpc
modules/index
translation
+50
View File
@@ -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`.
+1
View File
@@ -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',
+14
View File
@@ -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):
+11 -5
View File
@@ -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:
+1 -1
View File
@@ -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
+27 -8
View File
@@ -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():
+10 -3
View File
@@ -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
+12 -2
View File
@@ -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()