Merge pull request #4 from nitmir/dev
Add more optional password check to the mysql auth backend
This commit is contained in:
		
							
								
								
									
										15
									
								
								README.rst
									
									
									
									
									
								
							
							
						
						
									
										15
									
								
								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 <https://en.wikipedia.org/wiki/Crypt_(C)>), 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:
 | 
			
		||||
 
 | 
			
		||||
@@ -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
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -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):
 | 
			
		||||
 
 | 
			
		||||
@@ -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)
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user