From ac206d56d6712f63120a6f6b9e1d9332f3b6d6ab Mon Sep 17 00:00:00 2001 From: Valentin Samir Date: Sun, 26 Jun 2016 20:29:47 +0200 Subject: [PATCH 1/4] Add some password check methods to the MySQL auth backend --- README.rst | 14 +++- cas_server/auth.py | 18 ++--- cas_server/utils.py | 156 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 175 insertions(+), 13 deletions(-) diff --git a/README.rst b/README.rst index 9eeb2be..070a437 100644 --- a/README.rst +++ b/README.rst @@ -199,8 +199,18 @@ 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..0c147c2 100644 --- a/cas_server/auth.py +++ b/cas_server/auth.py @@ -16,6 +16,7 @@ try: # pragma: no cover import MySQLdb import MySQLdb.cursors import crypt + from utils import check_password except ImportError: MySQLdb = None @@ -90,17 +91,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/utils.py b/cas_server/utils.py index bd7e273..340a898 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,155 @@ 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): + pass + + class BadHash(ValueError): + pass + + class BadSalt(ValueError): + pass + + @classmethod + def _raise_bad_scheme(cls, scheme, valid, msg): + valid_schemes = [s for s in valid] + valid_schemes.sort() + raise cls.BadScheme(msg % (scheme, ", ".join(valid_schemes))) + + @classmethod + def _test_scheme(cls, scheme): + 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): + 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): + 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"): + 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): + if not hashed_passord[0] == b'{' or not b'}' 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): + 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): + 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() == hashed_password.lower() + else: + raise ValueError("Unknown password method check %r" % method) From 6faeaad57e835c73a2f4e023f2f60ecd479b77cb Mon Sep 17 00:00:00 2001 From: Valentin Samir Date: Sun, 26 Jun 2016 20:34:26 +0200 Subject: [PATCH 2/4] Typo in README.rst --- README.rst | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 070a437..29b8057 100644 --- a/README.rst +++ b/README.rst @@ -200,7 +200,8 @@ Mysql backend settings. Only usefull if you are using the mysql authentication b 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 one of the following: - * ``"crypt"`` (see ``), the password in the database + + * ``"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}, From 2fac47f0b178a7faf2eb039c9c5aecb45eaafb2f Mon Sep 17 00:00:00 2001 From: Valentin Samir Date: Sun, 26 Jun 2016 21:44:41 +0200 Subject: [PATCH 3/4] Add unit test for the utils function check_password --- cas_server/auth.py | 1 - cas_server/tests.py | 55 +++++++++++++++++++++++++++++++++++++++++++++ cas_server/utils.py | 20 +++++++++-------- 3 files changed, 66 insertions(+), 10 deletions(-) diff --git a/cas_server/auth.py b/cas_server/auth.py index 0c147c2..7051828 100644 --- a/cas_server/auth.py +++ b/cas_server/auth.py @@ -15,7 +15,6 @@ 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 diff --git a/cas_server/tests.py b/cas_server/tests.py index 710b890..3f53a04 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,60 @@ def get_pgt(): return params +class CheckPasswordCase(TestCase): + + def setUp(self): + 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): + self.assertIsInstance(self.password1, bytes) + self.assertIsInstance(self.password2, bytes) + + def test_plain(self): + 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): + 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): + 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): + 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): + 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 340a898..68325eb 100644 --- a/cas_server/utils.py +++ b/cas_server/utils.py @@ -177,6 +177,7 @@ class PGTUrlHandler(BaseHTTPServer.BaseHTTPRequestHandler): 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""" @@ -204,8 +205,6 @@ class LdapHashUserPassword(object): b"{SSHA512}": 64, } - - class BadScheme(ValueError): pass @@ -217,9 +216,9 @@ class LdapHashUserPassword(object): @classmethod def _raise_bad_scheme(cls, scheme, valid, msg): - valid_schemes = [s for s in valid] + valid_schemes = [s.decode() for s in valid] valid_schemes.sort() - raise cls.BadScheme(msg % (scheme, ", ".join(valid_schemes))) + raise cls.BadScheme(msg % (scheme, u", ".join(valid_schemes))) @classmethod def _test_scheme(cls, scheme): @@ -258,7 +257,9 @@ class LdapHashUserPassword(object): elif salt is not None: cls._test_scheme_salt(scheme) try: - return scheme + base64.b64encode(cls._schemes_to_hash[scheme](password + salt).digest() + salt) + return scheme + base64.b64encode( + cls._schemes_to_hash[scheme](password + salt).digest() + salt + ) except KeyError: if six.PY3: password = password.decode(charset) @@ -272,13 +273,12 @@ class LdapHashUserPassword(object): @classmethod def get_scheme(cls, hashed_passord): - if not hashed_passord[0] == b'{' or not b'}' in hashed_passord: + 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): scheme = cls.get_scheme(hashed_passord) @@ -294,7 +294,6 @@ class LdapHashUserPassword(object): return hashed_passord[cls._schemes_to_len[scheme]:] - def check_password(method, password, hashed_password, charset): if not isinstance(password, six.binary_type): password = password.encode(charset) @@ -325,6 +324,9 @@ def check_password(method, password, hashed_password, charset): method.startswith("hex_") and method[4:] in {"md5", "sha1", "sha224", "sha256", "sha384", "sha512"} ): - return getattr(hashlib, method[4:])(password).hexdigest() == hashed_password.lower() + return getattr( + hashlib, + method[4:] + )(password).hexdigest().encode("ascii") == hashed_password.lower() else: raise ValueError("Unknown password method check %r" % method) From 93c2dae96b6658ac7e7780b83f31a5fb8d0d264e Mon Sep 17 00:00:00 2001 From: Valentin Samir Date: Sun, 26 Jun 2016 22:07:38 +0200 Subject: [PATCH 4/4] Add docstrings --- cas_server/tests.py | 8 ++++++++ cas_server/utils.py | 20 ++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/cas_server/tests.py b/cas_server/tests.py index 3f53a04..7d355cb 100644 --- a/cas_server/tests.py +++ b/cas_server/tests.py @@ -61,8 +61,10 @@ def get_pgt(): 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): @@ -70,14 +72,17 @@ class CheckPasswordCase(TestCase): 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"), @@ -90,6 +95,7 @@ class CheckPasswordCase(TestCase): 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") @@ -98,12 +104,14 @@ class CheckPasswordCase(TestCase): 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( diff --git a/cas_server/utils.py b/cas_server/utils.py index 68325eb..c8b345b 100644 --- a/cas_server/utils.py +++ b/cas_server/utils.py @@ -206,22 +206,30 @@ class LdapHashUserPassword(object): } 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, @@ -231,6 +239,7 @@ class LdapHashUserPassword(object): @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, @@ -240,6 +249,7 @@ class LdapHashUserPassword(object): @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, @@ -249,6 +259,10 @@ class LdapHashUserPassword(object): @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"": @@ -273,6 +287,7 @@ class LdapHashUserPassword(object): @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] @@ -281,6 +296,7 @@ class LdapHashUserPassword(object): @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: @@ -295,6 +311,10 @@ class LdapHashUserPassword(object): 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):