utils.py 24.5 KB
Newer Older
Valentin Samir's avatar
Valentin Samir committed
1
# -*- coding: utf-8 -*-
Valentin Samir's avatar
Valentin Samir committed
2 3 4 5 6 7 8 9 10
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License version 3 for
# more details.
#
# You should have received a copy of the GNU General Public License version 3
# along with this program; if not, write to the Free Software Foundation, Inc., 51
# Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
#
Valentin Samir's avatar
Valentin Samir committed
11
# (c) 2015-2016 Valentin Samir
Valentin Samir's avatar
Valentin Samir committed
12
"""Some util function for the app"""
13
from .default_settings import settings
14

15
from django.core.urlresolvers import reverse
16 17
from django.http import HttpResponseRedirect, HttpResponse
from django.contrib import messages
18 19
from django.contrib.messages import constants as DEFAULT_MESSAGE_LEVELS
from django.core.serializers.json import DjangoJSONEncoder
20
from django.utils import timezone
21 22
from django.core.exceptions import ValidationError
from django.utils.translation import ugettext_lazy as _
23

24
import re
25 26
import random
import string
27
import json
28 29 30 31
import hashlib
import crypt
import base64
import six
32 33 34
import requests
import time
import logging
35
import binascii
36

Valentin Samir's avatar
Valentin Samir committed
37
from importlib import import_module
Valentin Samir's avatar
Valentin Samir committed
38
from datetime import datetime, timedelta
Valentin Samir's avatar
Valentin Samir committed
39
from six.moves.urllib.parse import urlparse, urlunparse, parse_qsl, urlencode
Valentin Samir's avatar
Valentin Samir committed
40

41 42 43 44 45
from . import VERSION

#: logger facility
logger = logging.getLogger(__name__)

Valentin Samir's avatar
Valentin Samir committed
46

47 48 49 50 51 52 53 54 55
def json_encode(obj):
    """Encode a python object to json"""
    try:
        return json_encode.encoder.encode(obj)
    except AttributeError:
        json_encode.encoder = DjangoJSONEncoder(default=six.text_type)
        return json_encode(obj)


56
def context(params):
57 58 59 60 61 62 63 64
    """
        Function that add somes variable to the context before template rendering

        :param dict params: The context dictionary used to render templates.
        :return: The ``params`` dictionary with the key ``settings`` set to
            :obj:`django.conf.settings`.
        :rtype: dict
    """
65
    params["settings"] = settings
66
    params["message_levels"] = DEFAULT_MESSAGE_LEVELS
67 68 69 70 71
    if settings.CAS_NEW_VERSION_HTML_WARNING:
        LAST_VERSION = last_version()
        params["VERSION"] = VERSION
        params["LAST_VERSION"] = LAST_VERSION
        if LAST_VERSION is not None:
Valentin Samir's avatar
Valentin Samir committed
72
            params["upgrade_available"] = decode_version(VERSION) < decode_version(LAST_VERSION)
73 74
        else:
            params["upgrade_available"] = False
75 76 77
    return params


Valentin Samir's avatar
style  
Valentin Samir committed
78
def json_response(request, data):
79 80 81 82 83 84 85 86
    """
        Wrapper dumping `data` to a json and sending it to the user with an HttpResponse

        :param django.http.HttpRequest request: The request object used to generate this response.
        :param dict data: The python dictionnary to return as a json
        :return: The content of ``data`` serialized in json
        :rtype: django.http.HttpResponse
    """
87 88 89 90 91 92
    data["messages"] = []
    for msg in messages.get_messages(request):
        data["messages"].append({'message': msg.message, 'level': msg.level_tag})
    return HttpResponse(json.dumps(data), content_type="application/json")


93
def import_attr(path):
94 95 96 97 98 99 100
    """
        transform a python dotted path to the attr

        :param path: A dotted path to a python object or a python object
        :type path: :obj:`unicode` or anything
        :return: The python object pointed by the dotted path or the python object unchanged
    """
101
    if not isinstance(path, str):
Valentin Samir's avatar
Valentin Samir committed
102
        return path
103 104
    if "." not in path:
        ValueError("%r should be of the form `module.attr` and we just got `attr`" % path)
105
    module, attr = path.rsplit('.', 1)
106 107 108 109 110 111
    try:
        return getattr(import_module(module), attr)
    except ImportError:
        raise ImportError("Module %r not found" % module)
    except AttributeError:
        raise AttributeError("Module %r has not attribut %r" % (module, attr))
112

Valentin Samir's avatar
PEP8  
Valentin Samir committed
113

114
def redirect_params(url_name, params=None):
115 116 117 118 119 120 121 122 123
    """
        Redirect to ``url_name`` with ``params`` as querystring

        :param unicode url_name: a URL pattern name
        :param params: Some parameter to append to the reversed URL
        :type params: :obj:`dict` or :obj:`NoneType<types.NoneType>`
        :return: A redirection to the URL with name ``url_name`` with ``params`` as querystring.
        :rtype: django.http.HttpResponseRedirect
    """
124
    url = reverse(url_name)
Valentin Samir's avatar
Valentin Samir committed
125
    params = urlencode(params if params else {})
126 127
    return HttpResponseRedirect(url + "?%s" % params)

Valentin Samir's avatar
PEP8  
Valentin Samir committed
128

129
def reverse_params(url_name, params=None, **kwargs):
130 131 132 133 134 135 136 137 138 139 140
    """
        compute the reverse url of ``url_name`` and add to it parameters from ``params``
        as querystring

        :param unicode url_name: a URL pattern name
        :param params: Some parameter to append to the reversed URL
        :type params: :obj:`dict` or :obj:`NoneType<types.NoneType>`
        :param **kwargs: additional parameters needed to compure the reverse URL
        :return: The computed reverse URL of ``url_name`` with possible querystring from ``params``
        :rtype: unicode
    """
141 142
    url = reverse(url_name, **kwargs)
    params = urlencode(params if params else {})
Valentin Samir's avatar
Valentin Samir committed
143
    if params:
144
        return u"%s?%s" % (url, params)
Valentin Samir's avatar
Valentin Samir committed
145 146 147 148
    else:
        return url


Valentin Samir's avatar
Valentin Samir committed
149
def copy_params(get_or_post_params, ignore=None):
150 151 152 153 154 155 156 157 158
    """
        copy a :class:`django.http.QueryDict` in a :obj:`dict` ignoring keys in the set ``ignore``

        :param django.http.QueryDict get_or_post_params: A GET or POST
            :class:`QueryDict<django.http.QueryDict>`
        :param set ignore: An optinal set of keys to ignore during the copy
        :return: A copy of get_or_post_params
        :rtype: dict
    """
Valentin Samir's avatar
Valentin Samir committed
159 160
    if ignore is None:
        ignore = set()
Valentin Samir's avatar
Valentin Samir committed
161 162 163 164 165 166 167 168
    params = {}
    for key in get_or_post_params:
        if key not in ignore and get_or_post_params[key]:
            params[key] = get_or_post_params[key]
    return params


def set_cookie(response, key, value, max_age):
169 170 171 172 173 174 175 176
    """
        Set the cookie ``key`` on ``response`` with value ``value`` valid for ``max_age`` secondes

        :param django.http.HttpResponse response: a django response where to set the cookie
        :param unicode key: the cookie key
        :param unicode value: the cookie value
        :param int max_age: the maximum validity age of the cookie
    """
Valentin Samir's avatar
Valentin Samir committed
177 178 179 180 181 182 183 184 185 186 187 188 189 190
    expires = datetime.strftime(
        datetime.utcnow() + timedelta(seconds=max_age),
        "%a, %d-%b-%Y %H:%M:%S GMT"
    )
    response.set_cookie(
        key,
        value,
        max_age=max_age,
        expires=expires,
        domain=settings.SESSION_COOKIE_DOMAIN,
        secure=settings.SESSION_COOKIE_SECURE or None
    )


Valentin Samir's avatar
Valentin Samir committed
191
def get_current_url(request, ignore_params=None):
192 193 194 195 196 197 198 199 200
    """
        Giving a django request, return the current http url, possibly ignoring some GET parameters

        :param django.http.HttpRequest request: The current request object.
        :param set ignore_params: An optional set of GET parameters to ignore
        :return: The URL of the current page, possibly omitting some parameters from
            ``ignore_params`` in the querystring.
        :rtype: unicode
    """
Valentin Samir's avatar
Valentin Samir committed
201 202
    if ignore_params is None:
        ignore_params = set()
203 204
    protocol = u'https' if request.is_secure() else u"http"
    service_url = u"%s://%s%s" % (protocol, request.get_host(), request.path)
Valentin Samir's avatar
Valentin Samir committed
205 206 207
    if request.GET:
        params = copy_params(request.GET, ignore_params)
        if params:
208
            service_url += u"?%s" % urlencode(params)
Valentin Samir's avatar
Valentin Samir committed
209
    return service_url
210 211


Valentin Samir's avatar
Valentin Samir committed
212
def update_url(url, params):
213 214 215 216 217 218 219 220 221
    """
        update parameters using ``params`` in the ``url`` query string

        :param url: An URL possibily with a querystring
        :type url: :obj:`unicode` or :obj:`str`
        :param dict params: A dictionary of parameters for updating the url querystring
        :return: The URL with an updated querystring
        :rtype: unicode
    """
Valentin Samir's avatar
Valentin Samir committed
222
    if not isinstance(url, bytes):
Valentin Samir's avatar
PEP8  
Valentin Samir committed
223
        url = url.encode('utf-8')
Valentin Samir's avatar
Valentin Samir committed
224 225
    for key, value in list(params.items()):
        if not isinstance(key, bytes):
Valentin Samir's avatar
Valentin Samir committed
226 227
            del params[key]
            key = key.encode('utf-8')
Valentin Samir's avatar
Valentin Samir committed
228
        if not isinstance(value, bytes):
Valentin Samir's avatar
Valentin Samir committed
229 230
            value = value.encode('utf-8')
        params[key] = value
Valentin Samir's avatar
Valentin Samir committed
231 232
    url_parts = list(urlparse(url))
    query = dict(parse_qsl(url_parts[4]))
Valentin Samir's avatar
Valentin Samir committed
233
    query.update(params)
Valentin Samir's avatar
Valentin Samir committed
234 235 236 237
    # make the params order deterministic
    query = list(query.items())
    query.sort()
    url_query = urlencode(query)
238
    if not isinstance(url_query, bytes):  # pragma: no cover in python3 urlencode return an unicode
Valentin Samir's avatar
Valentin Samir committed
239 240
        url_query = url_query.encode("utf-8")
    url_parts[4] = url_query
Valentin Samir's avatar
Valentin Samir committed
241
    return urlunparse(url_parts).decode('utf-8')
242

Valentin Samir's avatar
PEP8  
Valentin Samir committed
243

244
def unpack_nested_exception(error):
245 246 247 248 249 250
    """
        If exception are stacked, return the first one

        :param error: A python exception with possible exception embeded within
        :return: A python exception with no exception embeded within
    """
251 252 253 254 255 256 257 258 259 260 261 262 263
    i = 0
    while True:
        if error.args[i:]:
            if isinstance(error.args[i], Exception):
                error = error.args[i]
                i = 0
            else:
                i += 1
        else:
            break
    return error


264 265 266 267 268 269 270 271 272 273 274 275 276
def _gen_ticket(prefix=None, lg=settings.CAS_TICKET_LEN):
    """
        Generate a ticket with prefix ``prefix`` and length ``lg``

        :param unicode prefix: An optional prefix (probably ST, PT, PGT or PGTIOU)
        :param int lg: The length of the generated ticket (with the prefix)
        :return: A randomlly generated ticket of length ``lg``
        :rtype: unicode
    """
    random_part = u''.join(
        random.choice(
            string.ascii_letters + string.digits
        ) for _ in range(lg - len(prefix or "") - 1)
277
    )
278 279 280 281
    if prefix is not None:
        return u'%s-%s' % (prefix, random_part)
    else:
        return random_part
282

Valentin Samir's avatar
PEP8  
Valentin Samir committed
283

284
def gen_lt():
285 286 287 288 289 290 291
    """
        Generate a Login Ticket

        :return: A ticket with prefix ``settings.CAS_LOGIN_TICKET_PREFIX`` and length
            ``settings.CAS_LT_LEN``
        :rtype: unicode
    """
292 293
    return _gen_ticket(settings.CAS_LOGIN_TICKET_PREFIX, settings.CAS_LT_LEN)

Valentin Samir's avatar
PEP8  
Valentin Samir committed
294

295
def gen_st():
296 297 298 299 300 301 302
    """
        Generate a Service Ticket

        :return: A ticket with prefix ``settings.CAS_SERVICE_TICKET_PREFIX`` and length
            ``settings.CAS_ST_LEN``
        :rtype: unicode
    """
303
    return _gen_ticket(settings.CAS_SERVICE_TICKET_PREFIX, settings.CAS_ST_LEN)
304

Valentin Samir's avatar
PEP8  
Valentin Samir committed
305

306
def gen_pt():
307 308 309 310 311 312 313
    """
        Generate a Proxy Ticket

        :return: A ticket with prefix ``settings.CAS_PROXY_TICKET_PREFIX`` and length
            ``settings.CAS_PT_LEN``
        :rtype: unicode
    """
314
    return _gen_ticket(settings.CAS_PROXY_TICKET_PREFIX, settings.CAS_PT_LEN)
315

Valentin Samir's avatar
PEP8  
Valentin Samir committed
316

317
def gen_pgt():
318 319 320 321 322 323 324
    """
        Generate a Proxy Granting Ticket

        :return: A ticket with prefix ``settings.CAS_PROXY_GRANTING_TICKET_PREFIX`` and length
            ``settings.CAS_PGT_LEN``
        :rtype: unicode
    """
325
    return _gen_ticket(settings.CAS_PROXY_GRANTING_TICKET_PREFIX, settings.CAS_PGT_LEN)
326

Valentin Samir's avatar
PEP8  
Valentin Samir committed
327

328
def gen_pgtiou():
329 330 331 332 333 334 335
    """
        Generate a Proxy Granting Ticket IOU

        :return: A ticket with prefix ``settings.CAS_PROXY_GRANTING_TICKET_IOU_PREFIX`` and length
            ``settings.CAS_PGTIOU_LEN``
        :rtype: unicode
    """
336
    return _gen_ticket(settings.CAS_PROXY_GRANTING_TICKET_IOU_PREFIX, settings.CAS_PGTIOU_LEN)
337

338 339

def gen_saml_id():
340 341 342 343 344 345 346
    """
        Generate an saml id

        :return: A random id of length ``settings.CAS_TICKET_LEN``
        :rtype: unicode
    """
    return _gen_ticket()
347 348


Valentin Samir's avatar
Valentin Samir committed
349
def get_tuple(nuplet, index, default=None):
Valentin Samir's avatar
Valentin Samir committed
350
    """
351 352 353 354
        :param tuple nuplet: A tuple
        :param int index: An index
        :param default: An optional default value
        :return: ``nuplet[index]`` if defined, else ``default`` (possibly ``None``)
Valentin Samir's avatar
Valentin Samir committed
355
    """
Valentin Samir's avatar
Valentin Samir committed
356
    if nuplet is None:
357
        return default
358
    try:
Valentin Samir's avatar
Valentin Samir committed
359
        return nuplet[index]
360 361
    except IndexError:
        return default
362

363

364
def crypt_salt_is_valid(salt):
365 366 367 368 369 370 371
    """
        Validate a salt as crypt salt

        :param str salt: a password salt
        :return: ``True`` if ``salt`` is a valid crypt salt on this system, ``False`` otherwise
        :rtype: bool
    """
372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388
    if len(salt) < 2:
        return False
    else:
        if salt[0] == '$':
            if salt[1] == '$':
                return False
            else:
                if '$' not in salt[1:]:
                    return False
                else:
                    hashed = crypt.crypt("", salt)
                    if not hashed or '$' not in hashed[1:]:
                        return False
                    else:
                        return True
        else:
            return True
389

390

391
class LdapHashUserPassword(object):
392 393 394 395
    """
        Class to deal with hashed password as defined at
        https://tools.ietf.org/id/draft-stroeder-hashed-userpassword-values-01.html
    """
396

397
    #: valide schemes that require a salt
398
    schemes_salt = {b"{SMD5}", b"{SSHA}", b"{SSHA256}", b"{SSHA384}", b"{SSHA512}", b"{CRYPT}"}
399
    #: valide sschemes that require no slat
400 401
    schemes_nosalt = {b"{MD5}", b"{SHA}", b"{SHA256}", b"{SHA384}", b"{SHA512}"}

402
    #: map beetween scheme and hash function
403 404 405 406 407 408 409 410 411 412 413 414 415
    _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
    }

416
    #: map between scheme and hash length
417 418 419 420 421 422 423 424 425
    _schemes_to_len = {
        b"{SMD5}": 16,
        b"{SSHA}": 20,
        b"{SSHA256}": 32,
        b"{SSHA384}": 48,
        b"{SSHA512}": 64,
    }

    class BadScheme(ValueError):
426 427 428 429
        """
            Error raised then the hash scheme is not in
            :attr:`LdapHashUserPassword.schemes_salt` + :attr:`LdapHashUserPassword.schemes_nosalt`
        """
430 431 432
        pass

    class BadHash(ValueError):
Valentin Samir's avatar
Valentin Samir committed
433
        """Error raised then the hash is too short"""
434 435 436
        pass

    class BadSalt(ValueError):
437
        """Error raised then, with the scheme ``{CRYPT}``, the salt is invalid"""
438 439 440 441
        pass

    @classmethod
    def _raise_bad_scheme(cls, scheme, valid, msg):
Valentin Samir's avatar
Valentin Samir committed
442
        """
443 444 445 446 447 448 449
            Raise :attr:`BadScheme` error for ``scheme``, possible valid scheme are
            in ``valid``, the error message is ``msg``

            :param bytes scheme: A bad scheme
            :param list valid: A list a valid scheme
            :param str msg: The error template message
            :raises LdapHashUserPassword.BadScheme: always
Valentin Samir's avatar
Valentin Samir committed
450
        """
451
        valid_schemes = [s.decode() for s in valid]
452
        valid_schemes.sort()
453
        raise cls.BadScheme(msg % (scheme, u", ".join(valid_schemes)))
454 455 456

    @classmethod
    def _test_scheme(cls, scheme):
457 458 459 460 461 462
        """
            Test if a scheme is valide or raise BadScheme

            :param bytes scheme: A scheme
            :raises BadScheme: if ``scheme`` is not a valid scheme
        """
463 464 465 466 467 468 469 470 471
        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):
472 473 474 475 476 477
        """
            Test if the scheme need a salt or raise BadScheme

            :param bytes scheme: A scheme
            :raises BadScheme: if ``scheme` require no salt
        """
478 479 480 481 482 483 484 485 486
        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):
487 488 489 490 491 492
        """
            Test if the scheme need no salt or raise BadScheme

            :param bytes scheme: A scheme
            :raises BadScheme: if ``scheme` require a salt
        """
493 494 495 496 497 498 499 500 501
        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"):
Valentin Samir's avatar
Valentin Samir committed
502
        """
503 504 505 506 507 508 509 510 511
           Hash ``password`` with ``scheme`` using ``salt``.
           This three variable beeing encoded in ``charset``.

           :param bytes scheme: A valid scheme
           :param bytes password: A byte string to hash using ``scheme``
           :param bytes salt: An optional salt to use if ``scheme`` requires any
           :param str charset: The encoding of ``scheme``, ``password`` and ``salt``
           :return: The hashed password encoded with ``charset``
           :rtype: bytes
Valentin Samir's avatar
Valentin Samir committed
512
        """
513 514 515 516 517
        scheme = scheme.upper()
        cls._test_scheme(scheme)
        if salt is None or salt == b"":
            salt = b""
            cls._test_scheme_nosalt(scheme)
Valentin Samir's avatar
Valentin Samir committed
518
        else:
519 520
            cls._test_scheme_salt(scheme)
        try:
521 522 523
            return scheme + base64.b64encode(
                cls._schemes_to_hash[scheme](password + salt).digest() + salt
            )
524 525 526 527
        except KeyError:
            if six.PY3:
                password = password.decode(charset)
                salt = salt.decode(charset)
528
            if not crypt_salt_is_valid(salt):
529
                raise cls.BadSalt("System crypt implementation do not support the salt %r" % salt)
530
            hashed_password = crypt.crypt(password, salt)
531 532 533 534 535 536
            if six.PY3:
                hashed_password = hashed_password.encode(charset)
            return scheme + hashed_password

    @classmethod
    def get_scheme(cls, hashed_passord):
537 538 539 540 541 542 543 544
        """
            Return the scheme of ``hashed_passord`` or raise :attr:`BadHash`

            :param bytes hashed_passord: A hashed password
            :return: The scheme used by the hashed password
            :rtype: bytes
            :raises BadHash: if no valid scheme is found within ``hashed_passord``
        """
545
        if not hashed_passord[0] == b'{'[0] or b'}' not in hashed_passord:
546 547 548 549 550 551 552
            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):
553 554 555 556 557 558 559 560 561
        """
            Return the salt of ``hashed_passord`` possibly empty

            :param bytes hashed_passord: A hashed password
            :return: The salt used by the hashed password (empty if no salt is used)
            :rtype: bytes
            :raises BadHash: if no valid scheme is found within ``hashed_passord`` or if the
                hashed password is too short for the scheme found.
        """
562 563 564 565 566
        scheme = cls.get_scheme(hashed_passord)
        cls._test_scheme(scheme)
        if scheme in cls.schemes_nosalt:
            return b""
        elif scheme == b'{CRYPT}':
Valentin Samir's avatar
Valentin Samir committed
567
            return b'$'.join(hashed_passord.split(b'$', 3)[:-1])[len(scheme):]
568
        else:
569 570
            try:
                hashed_passord = base64.b64decode(hashed_passord[len(scheme):])
571
            except (TypeError, binascii.Error) as error:
572
                raise cls.BadHash("Bad base64: %s" % error)
573 574 575 576 577 578
            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):
Valentin Samir's avatar
Valentin Samir committed
579
    """
580 581 582 583 584 585 586 587 588 589
        Check that ``password`` match `hashed_password` using ``method``,
        assuming the encoding is ``charset``.

        :param str method: on of ``"crypt"``, ``"ldap"``, ``"hex_md5"``, ``"hex_sha1"``,
            ``"hex_sha224"``, ``"hex_sha256"``, ``"hex_sha384"``, ``"hex_sha512"``, ``"plain"``
        :param password: The user inputed password
        :type password: :obj:`str` or :obj:`unicode`
        :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
590
            the charset used by ``password`` when it was initially )
591 592 593
        :return: True if ``password`` match ``hashed_password`` using ``method``,
            ``False`` otherwise
        :rtype: bool
Valentin Samir's avatar
Valentin Samir committed
594
    """
595 596 597 598 599 600 601 602 603
    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])
Valentin Samir's avatar
Valentin Samir committed
604
        elif hashed_password.startswith(b'_'):  # pragma: no cover old BSD format not supported
605 606 607 608 609 610 611
            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)
612
        if not crypt_salt_is_valid(salt):
613
            raise ValueError("System crypt implementation do not support the salt %r" % salt)
614
        crypted_password = crypt.crypt(password, salt)
615 616 617 618 619 620 621 622 623
        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"}
    ):
624 625 626 627
        return getattr(
            hashlib,
            method[4:]
        )(password).hexdigest().encode("ascii") == hashed_password.lower()
628 629
    else:
        raise ValueError("Unknown password method check %r" % method)
630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662


def decode_version(version):
    """
        decode a version string following version semantic http://semver.org/ input a tuple of int

        :param unicode version: A dotted version
        :return: A tuple a int
        :rtype: tuple
    """
    return tuple(int(sub_version) for sub_version in version.split('.'))


def last_version():
    """
        Fetch the last version from pypi and return it. On successful fetch from pypi, the response
        is cached 24h, on error, it is cached 10 min.

        :return: the last django-cas-server version
        :rtype: unicode
    """
    try:
        last_update, version, success = last_version._cache
    except AttributeError:
        last_update = 0
        version = None
        success = False
    cache_delta = 24 * 3600 if success else 600
    if (time.time() - last_update) < cache_delta:
        return version
    else:
        try:
            req = requests.get(settings.CAS_NEW_VERSION_JSON_URL)
663 664
            data = json.loads(req.text)
            versions = list(data["releases"].keys())
665 666 667 668 669 670 671 672
            versions.sort()
            version = versions[-1]
            last_version._cache = (time.time(), version, True)
            return version
        except (
            KeyError,
            ValueError,
            requests.exceptions.RequestException
673
        ) as error:  # pragma: no cover (should not happen unless pypi is not available)
674 675 676 677
            logger.error(
                "Unable to fetch %s: %s" % (settings.CAS_NEW_VERSION_JSON_URL, error)
            )
            last_version._cache = (time.time(), version, False)
678 679 680 681 682 683 684 685 686


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()
    ]
687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705


def logout_request(ticket):
    """
        Forge a SLO logout request

        :param unicode ticket: A ticket value
        :return: A SLO XML body request
        :rtype: unicode
    """
    return u"""<samlp:LogoutRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
 ID="%(id)s" Version="2.0" IssueInstant="%(datetime)s">
<saml:NameID xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"></saml:NameID>
<samlp:SessionIndex>%(ticket)s</samlp:SessionIndex>
</samlp:LogoutRequest>""" % {
        'id': gen_saml_id(),
        'datetime': timezone.now().isoformat(),
        'ticket':  ticket
    }
706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721


def regexpr_validator(value):
    """
        Test that ``value`` is a valid regular expression

        :param unicode value: A regular expression to test
        :raises ValidationError: if ``value`` is not a valid regular expression
    """
    try:
        re.compile(value)
    except re.error:
        raise ValidationError(
            _('"%(value)s" is not a valid regular expression'),
            params={'value': value}
        )