diff --git a/README.rst b/README.rst index 9eeb2be..29b8057 100644 --- a/README.rst +++ b/README.rst @@ -199,8 +199,19 @@ Mysql backend settings. Only usefull if you are using the mysql authentication b 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 usersame, pass AS password, users.* FROM users WHERE user = %s"`` -* ``CAS_SQL_PASSWORD_CHECK``: The method used to check the user password. Must be - ``"crypt"`` or ``"plain``". The default is ``"crypt"``. +* ``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"``. Test backend settings. Only usefull if you are using the test authentication backend: diff --git a/cas_server/auth.py b/cas_server/auth.py index 4d26f09..7051828 100644 --- a/cas_server/auth.py +++ b/cas_server/auth.py @@ -15,7 +15,7 @@ from django.contrib.auth import get_user_model try: # pragma: no cover import MySQLdb import MySQLdb.cursors - import crypt + from utils import check_password except ImportError: MySQLdb = None @@ -90,17 +90,12 @@ class MysqlAuthUser(AuthUser): # pragma: no cover def test_password(self, password): """test `password` agains the user""" if self.user: - if settings.CAS_SQL_PASSWORD_CHECK == "plain": - return password == self.user["password"] - elif settings.CAS_SQL_PASSWORD_CHECK == "crypt": - if self.user["password"].startswith('$'): - salt = '$'.join(self.user["password"].split('$', 3)[:-1]) - return crypt.crypt(password, salt) == self.user["password"] - else: - return crypt.crypt( - password, - self.user["password"][:2] - ) == self.user["password"] + check_password( + settings.CAS_SQL_PASSWORD_CHECK, + password, + self.user["password"], + settings.CAS_SQL_DBCHARSET + ) else: return False diff --git a/cas_server/tests.py b/cas_server/tests.py index 710b890..7d355cb 100644 --- a/cas_server/tests.py +++ b/cas_server/tests.py @@ -3,6 +3,7 @@ from .default_settings import settings from django.test import TestCase from django.test import Client +import six from lxml import etree from cas_server import models @@ -59,6 +60,68 @@ def get_pgt(): return params +class CheckPasswordCase(TestCase): + """Tests for the utils function `utils.check_password`""" + + def setUp(self): + """Generate random bytes string that will be used ass passwords""" + self.password1 = utils.gen_saml_id() + self.password2 = utils.gen_saml_id() + if not isinstance(self.password1, bytes): + self.password1 = self.password1.encode("utf8") + self.password2 = self.password2.encode("utf8") + + def test_setup(self): + """check that generated password are bytes""" + self.assertIsInstance(self.password1, bytes) + self.assertIsInstance(self.password2, bytes) + + def test_plain(self): + """test the plain auth method""" + self.assertTrue(utils.check_password("plain", self.password1, self.password1, "utf8")) + self.assertFalse(utils.check_password("plain", self.password1, self.password2, "utf8")) + + def test_crypt(self): + """test the crypt auth method""" + if six.PY3: + hashed_password1 = utils.crypt.crypt( + self.password1.decode("utf8"), + "$6$UVVAQvrMyXMF3FF3" + ).encode("utf8") + else: + hashed_password1 = utils.crypt.crypt(self.password1, "$6$UVVAQvrMyXMF3FF3") + + self.assertTrue(utils.check_password("crypt", self.password1, hashed_password1, "utf8")) + self.assertFalse(utils.check_password("crypt", self.password2, hashed_password1, "utf8")) + + def test_ldap_ssha(self): + """test the ldap auth method with a {SSHA} scheme""" + salt = b"UVVAQvrMyXMF3FF3" + hashed_password1 = utils.LdapHashUserPassword.hash(b'{SSHA}', self.password1, salt, "utf8") + + self.assertIsInstance(hashed_password1, bytes) + self.assertTrue(utils.check_password("ldap", self.password1, hashed_password1, "utf8")) + self.assertFalse(utils.check_password("ldap", self.password2, hashed_password1, "utf8")) + + def test_hex_md5(self): + """test the hex_md5 auth method""" + hashed_password1 = utils.hashlib.md5(self.password1).hexdigest() + + self.assertTrue(utils.check_password("hex_md5", self.password1, hashed_password1, "utf8")) + self.assertFalse(utils.check_password("hex_md5", self.password2, hashed_password1, "utf8")) + + def test_hox_sha512(self): + """test the hex_sha512 auth method""" + hashed_password1 = utils.hashlib.sha512(self.password1).hexdigest() + + self.assertTrue( + utils.check_password("hex_sha512", self.password1, hashed_password1, "utf8") + ) + self.assertFalse( + utils.check_password("hex_sha512", self.password2, hashed_password1, "utf8") + ) + + class LoginTestCase(TestCase): def setUp(self): diff --git a/cas_server/utils.py b/cas_server/utils.py index bd7e273..c8b345b 100644 --- a/cas_server/utils.py +++ b/cas_server/utils.py @@ -19,6 +19,10 @@ from django.contrib import messages import random import string import json +import hashlib +import crypt +import base64 +import six from threading import Thread from importlib import import_module from six.moves import BaseHTTPServer @@ -172,3 +176,177 @@ class PGTUrlHandler(BaseHTTPServer.BaseHTTPRequestHandler): httpd_thread.daemon = True httpd_thread.start() return (httpd_thread, host, port) + + +class LdapHashUserPassword(object): + """Please see https://tools.ietf.org/id/draft-stroeder-hashed-userpassword-values-01.html""" + + schemes_salt = {b"{SMD5}", b"{SSHA}", b"{SSHA256}", b"{SSHA384}", b"{SSHA512}", b"{CRYPT}"} + schemes_nosalt = {b"{MD5}", b"{SHA}", b"{SHA256}", b"{SHA384}", b"{SHA512}"} + + _schemes_to_hash = { + b"{SMD5}": hashlib.md5, + b"{MD5}": hashlib.md5, + b"{SSHA}": hashlib.sha1, + b"{SHA}": hashlib.sha1, + b"{SSHA256}": hashlib.sha256, + b"{SHA256}": hashlib.sha256, + b"{SSHA384}": hashlib.sha384, + b"{SHA384}": hashlib.sha384, + b"{SSHA512}": hashlib.sha512, + b"{SHA512}": hashlib.sha512 + } + + _schemes_to_len = { + b"{SMD5}": 16, + b"{SSHA}": 20, + b"{SSHA256}": 32, + b"{SSHA384}": 48, + b"{SSHA512}": 64, + } + + class BadScheme(ValueError): + """Error raised then the hash scheme is not in schemes_salt + schemes_nosalt""" + pass + + class BadHash(ValueError): + """Error raised then the hash is too short""" + pass + + class BadSalt(ValueError): + """Error raised then with the scheme {CRYPT} the salt is invalid""" + pass + + @classmethod + def _raise_bad_scheme(cls, scheme, valid, msg): + """ + Raise BadScheme error for `scheme`, possible valid scheme are + in `valid`, the error message is `msg` + """ + valid_schemes = [s.decode() for s in valid] + valid_schemes.sort() + raise cls.BadScheme(msg % (scheme, u", ".join(valid_schemes))) + + @classmethod + def _test_scheme(cls, scheme): + """Test if a scheme is valide or raise BadScheme""" + if scheme not in cls.schemes_salt and scheme not in cls.schemes_nosalt: + cls._raise_bad_scheme( + scheme, + cls.schemes_salt | cls.schemes_nosalt, + "The scheme %r is not valid. Valide schemes are %s." + ) + + @classmethod + def _test_scheme_salt(cls, scheme): + """Test if the scheme need a salt or raise BadScheme""" + if scheme not in cls.schemes_salt: + cls._raise_bad_scheme( + scheme, + cls.schemes_salt, + "The scheme %r is only valid without a salt. Valide schemes with salt are %s." + ) + + @classmethod + def _test_scheme_nosalt(cls, scheme): + """Test if the scheme need no salt or raise BadScheme""" + if scheme not in cls.schemes_nosalt: + cls._raise_bad_scheme( + scheme, + cls.schemes_nosalt, + "The scheme %r is only valid with a salt. Valide schemes without salt are %s." + ) + + @classmethod + def hash(cls, scheme, password, salt=None, charset="utf8"): + """ + Hash `password` with `scheme` using `salt`. + This three variable beeing encoded in `charset`. + """ + scheme = scheme.upper() + cls._test_scheme(scheme) + if salt is None or salt == b"": + salt = b"" + cls._test_scheme_nosalt(scheme) + elif salt is not None: + cls._test_scheme_salt(scheme) + try: + return scheme + base64.b64encode( + cls._schemes_to_hash[scheme](password + salt).digest() + salt + ) + except KeyError: + if six.PY3: + password = password.decode(charset) + salt = salt.decode(charset) + hashed_password = crypt.crypt(password, salt) + if hashed_password is None: + raise cls.BadSalt("System crypt implementation do not support the salt %r" % salt) + if six.PY3: + hashed_password = hashed_password.encode(charset) + return scheme + hashed_password + + @classmethod + def get_scheme(cls, hashed_passord): + """Return the scheme of `hashed_passord` or raise BadHash""" + if not hashed_passord[0] == b'{'[0] or b'}' not in hashed_passord: + raise cls.BadHash("%r should start with the scheme enclosed with { }" % hashed_passord) + scheme = hashed_passord.split(b'}', 1)[0] + scheme = scheme.upper() + b"}" + return scheme + + @classmethod + def get_salt(cls, hashed_passord): + """Return the salt of `hashed_passord` possibly empty""" + scheme = cls.get_scheme(hashed_passord) + cls._test_scheme(scheme) + if scheme in cls.schemes_nosalt: + return b"" + elif scheme == b'{CRYPT}': + return b'$'.join(hashed_passord.split(b'$', 3)[:-1]) + else: + hashed_passord = base64.b64decode(hashed_passord[len(scheme):]) + if len(hashed_passord) < cls._schemes_to_len[scheme]: + raise cls.BadHash("Hash too short for the scheme %s" % scheme) + return hashed_passord[cls._schemes_to_len[scheme]:] + + +def check_password(method, password, hashed_password, charset): + """ + Check that `password` match `hashed_password` using `method`, + assuming the encoding is `charset`. + """ + if not isinstance(password, six.binary_type): + password = password.encode(charset) + if not isinstance(hashed_password, six.binary_type): + hashed_password = hashed_password.encode(charset) + if method == "plain": + return password == hashed_password + elif method == "crypt": + if hashed_password.startswith(b'$'): + salt = b'$'.join(hashed_password.split(b'$', 3)[:-1]) + elif hashed_password.startswith(b'_'): + salt = hashed_password[:9] + else: + salt = hashed_password[:2] + if six.PY3: + password = password.decode(charset) + salt = salt.decode(charset) + hashed_password = hashed_password.decode(charset) + crypted_password = crypt.crypt(password, salt) + if crypted_password is None: + raise ValueError("System crypt implementation do not support the salt %r" % salt) + return crypted_password == hashed_password + elif method == "ldap": + scheme = LdapHashUserPassword.get_scheme(hashed_password) + salt = LdapHashUserPassword.get_salt(hashed_password) + return LdapHashUserPassword.hash(scheme, password, salt, charset=charset) == hashed_password + elif ( + method.startswith("hex_") and + method[4:] in {"md5", "sha1", "sha224", "sha256", "sha384", "sha512"} + ): + return getattr( + hashlib, + method[4:] + )(password).hexdigest().encode("ascii") == hashed_password.lower() + else: + raise ValueError("Unknown password method check %r" % method)