From 2298b94f782c15b68abbbd9c86448bf01917b50f Mon Sep 17 00:00:00 2001 From: Valentin Samir Date: Sun, 31 Jul 2016 16:52:19 +0200 Subject: [PATCH] Add SqlAuthUser and LdapAuthUser auth classes. Deprecate the usage of SqlAuthUser in favor of SqlAuthUser. SqlAuthUser use django databases management, and thus is compatible with all SQL databases supported by django: postgresql, mysql, sqlite3 and oracle. LdapAuthUser use the full pythonic ldap3 module --- README.rst | 83 ++++++++++++++-- cas_server/auth.py | 172 ++++++++++++++++++++++++++++++--- cas_server/default_settings.py | 31 +++++- cas_server/utils.py | 11 ++- 4 files changed, 271 insertions(+), 26 deletions(-) diff --git a/README.rst b/README.rst index 85a4427..c66f092 100644 --- a/README.rst +++ b/README.rst @@ -193,12 +193,14 @@ Template settings Authentication settings ----------------------- -* ``CAS_AUTH_CLASS``: A dotted path to a class or a class implementing - ``cas_server.auth.AuthUser``. The default is ``"cas_server.auth.DjangoAuthUser"`` +* ``CAS_AUTH_CLASS``: A dotted path to a class or a class implementing + ``cas_server.auth.AuthUser``. The default is ``"cas_server.auth.DjangoAuthUser"`` + Available classes bundled with ``django-cas-server`` are listed below in the + `Authentication backend`_ section. -* ``SESSION_COOKIE_AGE``: This is a django settings. Here, it control the delay in seconds after - which inactive users are logged out. The default is ``1209600`` (2 weeks). You probably should - reduce it to something like ``86400`` seconds (1 day). +* ``SESSION_COOKIE_AGE``: This is a django settings. Here, it control the delay in seconds after + which inactive users are logged out. The default is ``1209600`` (2 weeks). You probably should + reduce it to something like ``86400`` seconds (1 day). * ``CAS_PROXY_CA_CERTIFICATE_PATH``: Path to certificate authorities file. Usually on linux the local CAs are in ``/etc/ssl/certs/ca-certificates.crt``. The default is ``True`` which @@ -214,8 +216,8 @@ Authentication settings Federation settings ------------------- -* ``CAS_FEDERATE``: A boolean for activating the federated mode (see the federate section below). - The default is ``False``. +* ``CAS_FEDERATE``: A boolean for activating the federated mode (see the `Federation mode`_ + section below). The default is ``False``. * ``CAS_FEDERATE_REMEMBER_TIMEOUT``: Time after witch the cookie use for "remember my identity provider" expire. The default is ``604800``, one week. The cookie is called ``_remember_provider``. @@ -269,6 +271,7 @@ Tickets miscellaneous settings Mysql backend settings ---------------------- +Deprecated, see the Sql backend settings. Only usefull if you are using the mysql authentication backend: * ``CAS_SQL_HOST``: Host for the SQL server. The default is ``"localhost"``. @@ -295,6 +298,64 @@ Only usefull if you are using the mysql authentication backend: The default is ``"crypt"``. +Sql backend settings +-------------------- +Only usefull if you are using the sql authentication backend. You must add a ``"cas_server"`` +database to `settings.DATABASES `__ +as defined in the django documentation. It is then the database +use by the sql backend. + +* ``CAS_SQL_USER_QUERY``: The query performed upon user authentication. + The username must be in field ``username``, the password in ``password``, + additional fields are used as the user attributes. + The default is ``"SELECT user AS username, pass AS password, users.* FROM users WHERE user = %s"`` +* ``CAS_SQL_PASSWORD_CHECK``: The method used to check the user password. Must be one of the following: + + * ``"crypt"`` (see ), the password in the database + should begin this $ + * ``"ldap"`` (see https://tools.ietf.org/id/draft-stroeder-hashed-userpassword-values-01.html) + the password in the database must begin with one of {MD5}, {SMD5}, {SHA}, {SSHA}, {SHA256}, + {SSHA256}, {SHA384}, {SSHA384}, {SHA512}, {SSHA512}, {CRYPT}. + * ``"hex_HASH_NAME"`` with ``HASH_NAME`` in md5, sha1, sha224, sha256, sha384, sha512. + The hashed password in the database is compare to the hexadecimal digest of the clear + password hashed with the corresponding algorithm. + * ``"plain"``, the password in the database must be in clear. + + The default is ``"crypt"``. +* ``CAS_SQL_PASSWORD_CHARSET``: Charset the SQL users passwords was hash with. This is needed to + encode the user sended password before hashing it for comparison. The default is ``"utf-8"``. + + +Ldap backend settings +--------------------- +Only usefull if you are using the ldap authentication backend: + +* ``CAS_LDAP_SERVER``: Address of the LDAP server. The default is ``"localhost"``. +* ``CAS_LDAP_USER``: User bind address, for example ``"cn=admin,dc=crans,dc=org"`` for + connecting to the LDAP server. +* ``CAS_LDAP_PASSWORD``: Password for connecting to the LDAP server. +* ``CAS_LDAP_BASE_DN``: LDAP search base DN, for example ``"ou=data,dc=crans,dc=org"``. +* ``CAS_LDAP_USER_QUERY``: Search filter for searching user by username. User inputed usernames are + escaped using ``ldap3.utils.conv.escape_bytes``. The default is ``"(uid=%s)"`` +* ``CAS_LDAP_USERNAME_ATTR``: Attribute used for users usernames. The default is ``"uid"`` +* ``CAS_LDAP_PASSWORD_ATTR``: Attribute used for users passwords. The default is ``"userPassword"`` +* ``CAS_LDAP_PASSWORD_CHECK``: The method used to check the user password. Must be one of the following: + + * ``"crypt"`` (see ), the password in the database + should begin this $ + * ``"ldap"`` (see https://tools.ietf.org/id/draft-stroeder-hashed-userpassword-values-01.html) + the password in the database must begin with one of {MD5}, {SMD5}, {SHA}, {SSHA}, {SHA256}, + {SSHA256}, {SHA384}, {SSHA384}, {SHA512}, {SSHA512}, {CRYPT}. + * ``"hex_HASH_NAME"`` with ``HASH_NAME`` in md5, sha1, sha224, sha256, sha384, sha512. + The hashed password in the database is compare to the hexadecimal digest of the clear + password hashed with the corresponding algorithm. + * ``"plain"``, the password in the database must be in clear. + + The default is ``"ldap"``. +* ``CAS_LDAP_PASSWORD_CHARSET``: Charset the LDAP users passwords was hash with. This is needed to + encode the user sended password before hashing it for comparison. The default is ``"utf-8"``. + + Test backend settings --------------------- Only usefull if you are using the test authentication backend: @@ -316,11 +377,17 @@ Authentication backend for the user are defined by the ``CAS_TEST_*`` settings. * django backend ``cas_server.auth.DjangoAuthUser``: Users are authenticated against django users system. This is the default backend. The returned attributes are the fields available on the user model. -* mysql backend ``cas_server.auth.MysqlAuthUser``: see the 'Mysql backend settings' section. +* mysql backend ``cas_server.auth.MysqlAuthUser``: Deprecated, use the sql backend instead. + see the `Mysql backend settings`_ section. The returned attributes are those return by sql query + ``CAS_SQL_USER_QUERY``. +* sql backend ``cas_server.auth.SqlAuthUser``: see the `Sql backend settings`_ section. The returned attributes are those return by sql query ``CAS_SQL_USER_QUERY``. +* ldap backend ``cas_server.auth.LdapAuthUser``: see the `Ldap backend settings`_ section. + The returned attributes are those of the ldap node returned by the query filter ``CAS_LDAP_USER_QUERY``. * federated backend ``cas_server.auth.CASFederateAuth``: It is automatically used then ``CAS_FEDERATE`` is ``True``. You should not set it manually without setting ``CAS_FEDERATE`` to ``True``. + Logs ==== diff --git a/cas_server/auth.py b/cas_server/auth.py index 31aa4f2..ab0c664 100644 --- a/cas_server/auth.py +++ b/cas_server/auth.py @@ -13,16 +13,25 @@ from django.conf import settings from django.contrib.auth import get_user_model from django.utils import timezone +from django.db import connections, DatabaseError +import warnings from datetime import timedelta +from six.moves import range try: # pragma: no cover import MySQLdb import MySQLdb.cursors - from utils import check_password except ImportError: MySQLdb = None + +try: # pragma: no cover + import ldap3 +except ImportError: + ldap3 = None + from .models import FederatedUser +from .utils import check_password, dictfetchall class AuthUser(object): @@ -116,19 +125,46 @@ class TestAuthUser(AuthUser): return {} -class MysqlAuthUser(AuthUser): # pragma: no cover +class DBAuthUser(AuthUser): # pragma: no cover + """base class for databate based auth classes""" + #: DB user attributes as a :class:`dict` if the username is found in the database. + user = None + + def attributs(self): + """ + The user attributes. + + :return: a :class:`dict` with the user attributes. Attributes may be :func:`unicode` + or :class:`list` of :func:`unicode`. If the user do not exists, the returned + :class:`dict` is empty. + :rtype: dict + """ + if self.user: + return self.user + else: + return {} + + +class MysqlAuthUser(DBAuthUser): # pragma: no cover """ - A mysql authentication class: authentication user agains a mysql database + DEPRECATED, use :class:`SqlAuthUser` instead. + + A mysql authentication class: authenticate user agains a mysql database :param unicode username: A username, stored in the :attr:`username` class attribute. Valid value are fetched from the MySQL database set with ``settings.CAS_SQL_*`` settings parameters using the query ``settings.CAS_SQL_USER_QUERY``. """ - #: Mysql user attributes as a :class:`dict` if the username is found in the database. - user = None def __init__(self, username): + warnings.warn( + ( + "MysqlAuthUser authentication class is deprecated: " + "use cas_server.auth.SqlAuthUser instead" + ), + UserWarning + ) # see the connect function at # http://mysql-python.sourceforge.net/MySQLdb.html#functions-and-attributes # for possible mysql config parameters. @@ -169,24 +205,130 @@ class MysqlAuthUser(AuthUser): # pragma: no cover else: return False - def attributs(self): - """ - The user attributes. - :return: a :class:`dict` with the user attributes. Attributes may be :func:`unicode` - or :class:`list` of :func:`unicode`. If the user do not exists, the returned - :class:`dict` is empty. - :rtype: dict +class SqlAuthUser(DBAuthUser): # pragma: no cover + """ + A SQL authentication class: authenticate user agains a SQL database. The SQL database + must be configures in settings.py as ``settings.DATABASES['cas_server']``. + + :param unicode username: A username, stored in the :attr:`username` + class attribute. Valid value are fetched from the MySQL database set with + ``settings.CAS_SQL_*`` settings parameters using the query + ``settings.CAS_SQL_USER_QUERY``. + """ + + def __init__(self, username): + if "cas_server" not in connections: + raise RuntimeError("Please configure the 'cas_server' database in settings.DATABASES") + for retry_nb in range(3): + try: + with connections["cas_server"].cursor() as curs: + curs.execute(settings.CAS_SQL_USER_QUERY, (username,)) + results = dictfetchall(curs) + if len(results) == 1: + self.user = results[0] + super(SqlAuthUser, self).__init__(self.user['username']) + else: + super(SqlAuthUser, self).__init__(username) + break + except DatabaseError: + connections["cas_server"].close() + if retry_nb == 2: + raise + + def test_password(self, password): + """ + Tests ``password`` agains the user password. + + :param unicode password: a clear text password as submited by the user. + :return: ``True`` if :attr:`username` is valid and ``password`` is + correct, ``False`` otherwise. + :rtype: bool """ if self.user: - return self.user + return check_password( + settings.CAS_SQL_PASSWORD_CHECK, + password, + self.user["password"], + settings.CAS_SQL_PASSWORD_CHARSET + ) else: - return {} + return False + + +class LdapAuthUser(DBAuthUser): # pragma: no cover + """ + A ldap authentication class: authenticate user against a ldap database + + :param unicode username: A username, stored in the :attr:`username` + class attribute. Valid value are fetched from the ldap database set with + ``settings.CAS_LDAP_*`` settings parameters. + """ + + _conn = None + + @classmethod + def get_conn(cls): + """Return a connection object to the ldap database""" + conn = cls._conn + if conn is None or conn.closed: + conn = ldap3.Connection( + settings.CAS_LDAP_SERVER, + settings.CAS_LDAP_USER, + settings.CAS_LDAP_PASSWORD, + auto_bind=True + ) + cls._conn = conn + return conn + + def __init__(self, username): + if not ldap3: + raise RuntimeError("Please install ldap3 before using the LdapAuthUser backend") + # in case we got deconnected from the database, retry to connect 2 times + for retry_nb in range(3): + try: + conn = self.get_conn() + if conn.search( + settings.CAS_LDAP_BASE_DN, + settings.CAS_LDAP_USER_QUERY % ldap3.utils.conv.escape_bytes(username), + attributes=ldap3.ALL_ATTRIBUTES + ) and len(conn.entries) == 1: + user = conn.entries[0].entry_get_attributes_dict() + if user.get(settings.CAS_LDAP_USERNAME_ATTR): + self.user = user + super(LdapAuthUser, self).__init__(user[settings.CAS_LDAP_USERNAME_ATTR][0]) + else: + super(LdapAuthUser, self).__init__(username) + else: + super(LdapAuthUser, self).__init__(username) + break + except ldap3.LDAPCommunicationError: + if retry_nb == 2: + raise + + def test_password(self, password): + """ + Tests ``password`` agains the user password. + + :param unicode password: a clear text password as submited by the user. + :return: ``True`` if :attr:`username` is valid and ``password`` is + correct, ``False`` otherwise. + :rtype: bool + """ + if self.user and self.user.get(settings.CAS_LDAP_PASSWORD_ATTR): + return check_password( + settings.CAS_LDAP_PASSWORD_CHECK, + password, + self.user[settings.CAS_LDAP_PASSWORD_ATTR][0], + settings.CAS_LDAP_PASSWORD_CHARSET + ) + else: + return False class DjangoAuthUser(AuthUser): # pragma: no cover """ - A django auth class: authenticate user agains django internal users + A django auth class: authenticate user against django internal users :param unicode username: A username, stored in the :attr:`username` class attribute. Valid value are usernames of django internal users. diff --git a/cas_server/default_settings.py b/cas_server/default_settings.py index ed9efc2..bfa6a54 100644 --- a/cas_server/default_settings.py +++ b/cas_server/default_settings.py @@ -112,12 +112,39 @@ CAS_SQL_PASSWORD = '' CAS_SQL_DBNAME = '' #: Database charset. CAS_SQL_DBCHARSET = 'utf8' + #: The query performed upon user authentication. -CAS_SQL_USER_QUERY = 'SELECT user AS usersame, pass AS password, users.* FROM users WHERE user = %s' +CAS_SQL_USER_QUERY = 'SELECT user AS username, pass AS password, users.* FROM users WHERE user = %s' #: The method used to check the user password. Must be one of ``"crypt"``, ``"ldap"``, #: ``"hex_md5"``, ``"hex_sha1"``, ``"hex_sha224"``, ``"hex_sha256"``, ``"hex_sha384"``, #: ``"hex_sha512"``, ``"plain"``. -CAS_SQL_PASSWORD_CHECK = 'crypt' # crypt or plain +CAS_SQL_PASSWORD_CHECK = 'crypt' +#: charset the SQL users passwords was hash with +CAS_SQL_PASSWORD_CHARSET = "utf-8" + + +#: Address of the LDAP server +CAS_LDAP_SERVER = 'localhost' +#: LDAP user bind address, for example ``"cn=admin,dc=crans,dc=org"`` for connecting to the LDAP +#: server. +CAS_LDAP_USER = None +#: LDAP connection password +CAS_LDAP_PASSWORD = None +#: LDAP seach base DN, for example ``"ou=data,dc=crans,dc=org"``. +CAS_LDAP_BASE_DN = None +#: LDAP search filter for searching user by username. User inputed usernames are escaped using +#: :func:`ldap3.utils.conv.escape_bytes`. +CAS_LDAP_USER_QUERY = "(uid=%s)" +#: LDAP attribute used for users usernames +CAS_LDAP_USERNAME_ATTR = "uid" +#: LDAP attribute used for users passwords +CAS_LDAP_PASSWORD_ATTR = "userPassword" +#: The method used to check the user password. Must be one of ``"crypt"``, ``"ldap"``, +#: ``"hex_md5"``, ``"hex_sha1"``, ``"hex_sha224"``, ``"hex_sha256"``, ``"hex_sha384"``, +#: ``"hex_sha512"``, ``"plain"``. +CAS_LDAP_PASSWORD_CHECK = "ldap" +#: charset the LDAP users passwords was hash with +CAS_LDAP_PASSWORD_CHARSET = "utf-8" #: Username of the test user. diff --git a/cas_server/utils.py b/cas_server/utils.py index 6142c21..23f7b14 100644 --- a/cas_server/utils.py +++ b/cas_server/utils.py @@ -582,7 +582,7 @@ def check_password(method, password, hashed_password, charset): :param hashed_password: The hashed password as stored in the database :type hashed_password: :obj:`str` or :obj:`unicode` :param str charset: The used char encoding (also used internally, so it must be valid for - the charset used by ``password`` even if it is inputed as an :obj:`unicode`) + the charset used by ``password`` when it was initially ) :return: True if ``password`` match ``hashed_password`` using ``method``, ``False`` otherwise :rtype: bool @@ -670,3 +670,12 @@ def last_version(): "Unable to fetch %s: %s" % (settings.CAS_NEW_VERSION_JSON_URL, error) ) last_version._cache = (time.time(), version, False) + + +def dictfetchall(cursor): + "Return all rows from a django cursor as a dict" + columns = [col[0] for col in cursor.description] + return [ + dict(zip(columns, row)) + for row in cursor.fetchall() + ]