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