From ac206d56d6712f63120a6f6b9e1d9332f3b6d6ab Mon Sep 17 00:00:00 2001 From: Valentin Samir Date: Sun, 26 Jun 2016 20:29:47 +0200 Subject: [PATCH] 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)