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:
@@ -1,3 +1,4 @@
|
||||
* Add customizable login process
|
||||
* Allow to customize the substitutions used on sequence
|
||||
* Allow PYSON in tree_invisible attribute
|
||||
|
||||
|
||||
@@ -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
|
||||
~~~~~~~
|
||||
|
||||
|
||||
@@ -25,5 +25,6 @@ Introduction to all the key parts of trytond:
|
||||
views/extension
|
||||
wizard
|
||||
reports/index
|
||||
rpc
|
||||
modules/index
|
||||
translation
|
||||
|
||||
@@ -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`.
|
||||
@@ -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',
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user