Coverage for cas_server/utils.py: 95%
301 statements
« prev ^ index » next coverage.py v7.3.1, created at 2023-09-13 11:30 +0000
« prev ^ index » next coverage.py v7.3.1, created at 2023-09-13 11:30 +0000
1# -*- coding: utf-8 -*-
2# This program is distributed in the hope that it will be useful, but WITHOUT
3# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
4# FOR A PARTICULAR PURPOSE. See the GNU General Public License version 3 for
5# more details.
6#
7# You should have received a copy of the GNU General Public License version 3
8# along with this program; if not, write to the Free Software Foundation, Inc., 51
9# Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
10#
11# (c) 2015-2016 Valentin Samir
12"""Some util function for the app"""
13from .default_settings import settings
15from django.http import HttpResponseRedirect, HttpResponse
16from django.contrib import messages
17from django.contrib.messages import constants as DEFAULT_MESSAGE_LEVELS
18from django.core.serializers.json import DjangoJSONEncoder
19from django.utils import timezone
20from django.core.exceptions import ValidationError
21try:
22 from django.urls import reverse
23 from django.utils.translation import gettext_lazy as _
24except ImportError:
25 from django.core.urlresolvers import reverse
26 from django.utils.translation import ugettext_lazy as _
28import re
29import random
30import string
31import json
32import hashlib
33import crypt
34import base64
35import six
36import requests
37import time
38import logging
39import binascii
41from importlib import import_module
42from datetime import datetime, timedelta
43from six.moves.urllib.parse import urlparse, urlunparse, parse_qsl, urlencode
45from . import VERSION
47#: logger facility
48logger = logging.getLogger(__name__)
51def json_encode(obj):
52 """Encode a python object to json"""
53 try:
54 return json_encode.encoder.encode(obj)
55 except AttributeError:
56 json_encode.encoder = DjangoJSONEncoder(default=six.text_type)
57 return json_encode(obj)
60def context(params):
61 """
62 Function that add somes variable to the context before template rendering
64 :param dict params: The context dictionary used to render templates.
65 :return: The ``params`` dictionary with the key ``settings`` set to
66 :obj:`django.conf.settings`.
67 :rtype: dict
68 """
69 params["settings"] = settings
70 params["message_levels"] = DEFAULT_MESSAGE_LEVELS
72 if settings.CAS_NEW_VERSION_HTML_WARNING:
73 LAST_VERSION = last_version()
74 params["VERSION"] = VERSION
75 params["LAST_VERSION"] = LAST_VERSION
76 if LAST_VERSION is not None:
77 params["upgrade_available"] = decode_version(VERSION) < decode_version(LAST_VERSION)
78 else:
79 params["upgrade_available"] = False
81 if settings.CAS_INFO_MESSAGES_ORDER:
82 params["CAS_INFO_RENDER"] = []
83 for msg_name in settings.CAS_INFO_MESSAGES_ORDER:
84 if msg_name in settings.CAS_INFO_MESSAGES:
85 if not isinstance(settings.CAS_INFO_MESSAGES[msg_name], dict):
86 continue
87 msg = settings.CAS_INFO_MESSAGES[msg_name].copy()
88 if "message" in msg:
89 msg["name"] = msg_name
90 # use info as default infox type
91 msg["type"] = msg.get("type", "info")
92 # make box discardable by default
93 msg["discardable"] = msg.get("discardable", True)
94 msg_hash = (
95 six.text_type(msg["message"]).encode("utf-8") +
96 msg["type"].encode("utf-8")
97 )
98 # hash depend of the rendering language
99 msg["hash"] = hashlib.md5(msg_hash).hexdigest()
100 params["CAS_INFO_RENDER"].append(msg)
101 return params
104def json_response(request, data):
105 """
106 Wrapper dumping `data` to a json and sending it to the user with an HttpResponse
108 :param django.http.HttpRequest request: The request object used to generate this response.
109 :param dict data: The python dictionnary to return as a json
110 :return: The content of ``data`` serialized in json
111 :rtype: django.http.HttpResponse
112 """
113 data["messages"] = []
114 for msg in messages.get_messages(request):
115 data["messages"].append({'message': msg.message, 'level': msg.level_tag})
116 return HttpResponse(json.dumps(data), content_type="application/json")
119def import_attr(path):
120 """
121 transform a python dotted path to the attr
123 :param path: A dotted path to a python object or a python object
124 :type path: :obj:`unicode` or :obj:`str` or anything
125 :return: The python object pointed by the dotted path or the python object unchanged
126 """
127 # if we got a str, decode it to unicode (normally it should only contain ascii)
128 if isinstance(path, six.binary_type): 128 ↛ 129line 128 didn't jump to line 129, because the condition on line 128 was never true
129 path = path.decode("utf-8")
130 # if path is not an unicode, return it unchanged (may be it is already the attribute to import)
131 if not isinstance(path, six.text_type):
132 return path
133 if u"." not in path:
134 ValueError("%r should be of the form `module.attr` and we just got `attr`" % path)
135 module, attr = path.rsplit(u'.', 1)
136 try:
137 return getattr(import_module(module), attr)
138 except ImportError:
139 raise ImportError("Module %r not found" % module)
140 except AttributeError:
141 raise AttributeError("Module %r has not attribut %r" % (module, attr))
144def redirect_params(url_name, params=None):
145 """
146 Redirect to ``url_name`` with ``params`` as querystring
148 :param unicode url_name: a URL pattern name
149 :param params: Some parameter to append to the reversed URL
150 :type params: :obj:`dict` or :obj:`NoneType<types.NoneType>`
151 :return: A redirection to the URL with name ``url_name`` with ``params`` as querystring.
152 :rtype: django.http.HttpResponseRedirect
153 """
154 url = reverse(url_name)
155 params = urlencode(params if params else {})
156 return HttpResponseRedirect(url + "?%s" % params)
159def reverse_params(url_name, params=None, **kwargs):
160 """
161 compute the reverse url of ``url_name`` and add to it parameters from ``params``
162 as querystring
164 :param unicode url_name: a URL pattern name
165 :param params: Some parameter to append to the reversed URL
166 :type params: :obj:`dict` or :obj:`NoneType<types.NoneType>`
167 :param **kwargs: additional parameters needed to compure the reverse URL
168 :return: The computed reverse URL of ``url_name`` with possible querystring from ``params``
169 :rtype: unicode
170 """
171 url = reverse(url_name, **kwargs)
172 params = urlencode(params if params else {})
173 if params:
174 return u"%s?%s" % (url, params)
175 else:
176 return url
179def copy_params(get_or_post_params, ignore=None):
180 """
181 copy a :class:`django.http.QueryDict` in a :obj:`dict` ignoring keys in the set ``ignore``
183 :param django.http.QueryDict get_or_post_params: A GET or POST
184 :class:`QueryDict<django.http.QueryDict>`
185 :param set ignore: An optinal set of keys to ignore during the copy
186 :return: A copy of get_or_post_params
187 :rtype: dict
188 """
189 if ignore is None:
190 ignore = set()
191 params = {}
192 for key in get_or_post_params:
193 if key not in ignore and get_or_post_params[key]:
194 params[key] = get_or_post_params[key]
195 return params
198def set_cookie(response, key, value, max_age):
199 """
200 Set the cookie ``key`` on ``response`` with value ``value`` valid for ``max_age`` secondes
202 :param django.http.HttpResponse response: a django response where to set the cookie
203 :param unicode key: the cookie key
204 :param unicode value: the cookie value
205 :param int max_age: the maximum validity age of the cookie
206 """
207 expires = datetime.strftime(
208 datetime.utcnow() + timedelta(seconds=max_age),
209 "%a, %d-%b-%Y %H:%M:%S GMT"
210 )
211 response.set_cookie(
212 key,
213 value,
214 max_age=max_age,
215 expires=expires,
216 domain=settings.SESSION_COOKIE_DOMAIN,
217 secure=settings.SESSION_COOKIE_SECURE or None
218 )
221def get_current_url(request, ignore_params=None):
222 """
223 Giving a django request, return the current http url, possibly ignoring some GET parameters
225 :param django.http.HttpRequest request: The current request object.
226 :param set ignore_params: An optional set of GET parameters to ignore
227 :return: The URL of the current page, possibly omitting some parameters from
228 ``ignore_params`` in the querystring.
229 :rtype: unicode
230 """
231 if ignore_params is None:
232 ignore_params = set()
233 protocol = u'https' if request.is_secure() else u"http"
234 service_url = u"%s://%s%s" % (protocol, request.get_host(), request.path)
235 if request.GET:
236 params = copy_params(request.GET, ignore_params)
237 if params:
238 service_url += u"?%s" % urlencode(params)
239 return service_url
242def update_url(url, params):
243 """
244 update parameters using ``params`` in the ``url`` query string
246 :param url: An URL possibily with a querystring
247 :type url: :obj:`unicode` or :obj:`str`
248 :param dict params: A dictionary of parameters for updating the url querystring
249 :return: The URL with an updated querystring
250 :rtype: unicode
251 """
252 def to_unicode(data):
253 if isinstance(data, bytes):
254 return data.decode('utf-8')
255 else:
256 return data
258 def to_bytes(data):
259 if not isinstance(data, bytes):
260 return data.encode('utf-8')
261 else:
262 return data
264 if six.PY3:
265 url = to_unicode(url)
266 params = {to_unicode(key): to_unicode(value) for (key, value) in params.items()}
267 else:
268 url = to_bytes(url)
269 params = {to_bytes(key): to_bytes(value) for (key, value) in params.items()}
271 url_parts = list(urlparse(url))
272 query = dict(parse_qsl(url_parts[4], keep_blank_values=True))
273 query.update(params)
274 # make the params order deterministic
275 query = list(query.items())
276 query.sort()
277 url_query = urlencode(query)
278 url_parts[4] = url_query
279 url = urlunparse(url_parts)
281 if isinstance(url, bytes): 281 ↛ 282line 281 didn't jump to line 282, because the condition on line 281 was never true
282 url = url.decode('utf-8')
283 return url
286def unpack_nested_exception(error):
287 """
288 If exception are stacked, return the first one
290 :param error: A python exception with possible exception embeded within
291 :return: A python exception with no exception embeded within
292 """
293 i = 0
294 while True:
295 if error.args[i:]:
296 if isinstance(error.args[i], Exception):
297 error = error.args[i]
298 i = 0
299 else:
300 i += 1
301 else:
302 break
303 return error
306def _gen_ticket(prefix=None, lg=settings.CAS_TICKET_LEN):
307 """
308 Generate a ticket with prefix ``prefix`` and length ``lg``
310 :param unicode prefix: An optional prefix (probably ST, PT, PGT or PGTIOU)
311 :param int lg: The length of the generated ticket (with the prefix)
312 :return: A randomlly generated ticket of length ``lg``
313 :rtype: unicode
314 """
315 random_part = u''.join(
316 random.choice(
317 string.ascii_letters + string.digits
318 ) for _ in range(lg - len(prefix or "") - 1)
319 )
320 if prefix is not None:
321 return u'%s-%s' % (prefix, random_part)
322 else:
323 return random_part
326def gen_lt():
327 """
328 Generate a Login Ticket
330 :return: A ticket with prefix ``settings.CAS_LOGIN_TICKET_PREFIX`` and length
331 ``settings.CAS_LT_LEN``
332 :rtype: unicode
333 """
334 return _gen_ticket(settings.CAS_LOGIN_TICKET_PREFIX, settings.CAS_LT_LEN)
337def gen_st():
338 """
339 Generate a Service Ticket
341 :return: A ticket with prefix ``settings.CAS_SERVICE_TICKET_PREFIX`` and length
342 ``settings.CAS_ST_LEN``
343 :rtype: unicode
344 """
345 return _gen_ticket(settings.CAS_SERVICE_TICKET_PREFIX, settings.CAS_ST_LEN)
348def gen_pt():
349 """
350 Generate a Proxy Ticket
352 :return: A ticket with prefix ``settings.CAS_PROXY_TICKET_PREFIX`` and length
353 ``settings.CAS_PT_LEN``
354 :rtype: unicode
355 """
356 return _gen_ticket(settings.CAS_PROXY_TICKET_PREFIX, settings.CAS_PT_LEN)
359def gen_pgt():
360 """
361 Generate a Proxy Granting Ticket
363 :return: A ticket with prefix ``settings.CAS_PROXY_GRANTING_TICKET_PREFIX`` and length
364 ``settings.CAS_PGT_LEN``
365 :rtype: unicode
366 """
367 return _gen_ticket(settings.CAS_PROXY_GRANTING_TICKET_PREFIX, settings.CAS_PGT_LEN)
370def gen_pgtiou():
371 """
372 Generate a Proxy Granting Ticket IOU
374 :return: A ticket with prefix ``settings.CAS_PROXY_GRANTING_TICKET_IOU_PREFIX`` and length
375 ``settings.CAS_PGTIOU_LEN``
376 :rtype: unicode
377 """
378 return _gen_ticket(settings.CAS_PROXY_GRANTING_TICKET_IOU_PREFIX, settings.CAS_PGTIOU_LEN)
381def gen_saml_id():
382 """
383 Generate an saml id
385 :return: A random id of length ``settings.CAS_TICKET_LEN``
386 :rtype: unicode
387 """
388 return _gen_ticket()
391def get_tuple(nuplet, index, default=None):
392 """
393 :param tuple nuplet: A tuple
394 :param int index: An index
395 :param default: An optional default value
396 :return: ``nuplet[index]`` if defined, else ``default`` (possibly ``None``)
397 """
398 if nuplet is None:
399 return default
400 try:
401 return nuplet[index]
402 except IndexError:
403 return default
406def crypt_salt_is_valid(salt):
407 """
408 Validate a salt as crypt salt
410 :param str salt: a password salt
411 :return: ``True`` if ``salt`` is a valid crypt salt on this system, ``False`` otherwise
412 :rtype: bool
413 """
414 if len(salt) < 2:
415 return False
416 else:
417 if salt[0] == '$':
418 if salt[1] == '$':
419 return False
420 else:
421 if '$' not in salt[1:]:
422 return False
423 else:
424 try:
425 hashed = crypt.crypt("", salt)
426 except OSError:
427 return False
428 if not hashed or '$' not in hashed[1:]:
429 return False
430 else:
431 return True
432 else:
433 return True
436class LdapHashUserPassword(object):
437 """
438 Class to deal with hashed password as defined at
439 https://tools.ietf.org/id/draft-stroeder-hashed-userpassword-values-01.html
440 """
442 #: valide schemes that require a salt
443 schemes_salt = {b"{SMD5}", b"{SSHA}", b"{SSHA256}", b"{SSHA384}", b"{SSHA512}", b"{CRYPT}"}
444 #: valide sschemes that require no slat
445 schemes_nosalt = {b"{MD5}", b"{SHA}", b"{SHA256}", b"{SHA384}", b"{SHA512}"}
447 #: map beetween scheme and hash function
448 _schemes_to_hash = {
449 b"{SMD5}": hashlib.md5,
450 b"{MD5}": hashlib.md5,
451 b"{SSHA}": hashlib.sha1,
452 b"{SHA}": hashlib.sha1,
453 b"{SSHA256}": hashlib.sha256,
454 b"{SHA256}": hashlib.sha256,
455 b"{SSHA384}": hashlib.sha384,
456 b"{SHA384}": hashlib.sha384,
457 b"{SSHA512}": hashlib.sha512,
458 b"{SHA512}": hashlib.sha512
459 }
461 #: map between scheme and hash length
462 _schemes_to_len = {
463 b"{SMD5}": 16,
464 b"{SSHA}": 20,
465 b"{SSHA256}": 32,
466 b"{SSHA384}": 48,
467 b"{SSHA512}": 64,
468 }
470 class BadScheme(ValueError):
471 """
472 Error raised then the hash scheme is not in
473 :attr:`LdapHashUserPassword.schemes_salt` + :attr:`LdapHashUserPassword.schemes_nosalt`
474 """
475 pass
477 class BadHash(ValueError):
478 """Error raised then the hash is too short"""
479 pass
481 class BadSalt(ValueError):
482 """Error raised then, with the scheme ``{CRYPT}``, the salt is invalid"""
483 pass
485 @classmethod
486 def _raise_bad_scheme(cls, scheme, valid, msg):
487 """
488 Raise :attr:`BadScheme` error for ``scheme``, possible valid scheme are
489 in ``valid``, the error message is ``msg``
491 :param bytes scheme: A bad scheme
492 :param list valid: A list a valid scheme
493 :param str msg: The error template message
494 :raises LdapHashUserPassword.BadScheme: always
495 """
496 valid_schemes = [s.decode() for s in valid]
497 valid_schemes.sort()
498 raise cls.BadScheme(msg % (scheme, u", ".join(valid_schemes)))
500 @classmethod
501 def _test_scheme(cls, scheme):
502 """
503 Test if a scheme is valide or raise BadScheme
505 :param bytes scheme: A scheme
506 :raises BadScheme: if ``scheme`` is not a valid scheme
507 """
508 if scheme not in cls.schemes_salt and scheme not in cls.schemes_nosalt:
509 cls._raise_bad_scheme(
510 scheme,
511 cls.schemes_salt | cls.schemes_nosalt,
512 "The scheme %r is not valid. Valide schemes are %s."
513 )
515 @classmethod
516 def _test_scheme_salt(cls, scheme):
517 """
518 Test if the scheme need a salt or raise BadScheme
520 :param bytes scheme: A scheme
521 :raises BadScheme: if ``scheme` require no salt
522 """
523 if scheme not in cls.schemes_salt:
524 cls._raise_bad_scheme(
525 scheme,
526 cls.schemes_salt,
527 "The scheme %r is only valid without a salt. Valide schemes with salt are %s."
528 )
530 @classmethod
531 def _test_scheme_nosalt(cls, scheme):
532 """
533 Test if the scheme need no salt or raise BadScheme
535 :param bytes scheme: A scheme
536 :raises BadScheme: if ``scheme` require a salt
537 """
538 if scheme not in cls.schemes_nosalt:
539 cls._raise_bad_scheme(
540 scheme,
541 cls.schemes_nosalt,
542 "The scheme %r is only valid with a salt. Valide schemes without salt are %s."
543 )
545 @classmethod
546 def hash(cls, scheme, password, salt=None, charset="utf8"):
547 """
548 Hash ``password`` with ``scheme`` using ``salt``.
549 This three variable beeing encoded in ``charset``.
551 :param bytes scheme: A valid scheme
552 :param bytes password: A byte string to hash using ``scheme``
553 :param bytes salt: An optional salt to use if ``scheme`` requires any
554 :param str charset: The encoding of ``scheme``, ``password`` and ``salt``
555 :return: The hashed password encoded with ``charset``
556 :rtype: bytes
557 """
558 scheme = scheme.upper()
559 cls._test_scheme(scheme)
560 if salt is None or salt == b"":
561 salt = b""
562 cls._test_scheme_nosalt(scheme)
563 else:
564 cls._test_scheme_salt(scheme)
565 try:
566 return scheme + base64.b64encode(
567 cls._schemes_to_hash[scheme](password + salt).digest() + salt
568 )
569 except KeyError:
570 if six.PY3:
571 password = password.decode(charset)
572 salt = salt.decode(charset)
573 if not crypt_salt_is_valid(salt):
574 raise cls.BadSalt("System crypt implementation do not support the salt %r" % salt)
575 hashed_password = crypt.crypt(password, salt)
576 if six.PY3:
577 hashed_password = hashed_password.encode(charset)
578 return scheme + hashed_password
580 @classmethod
581 def get_scheme(cls, hashed_passord):
582 """
583 Return the scheme of ``hashed_passord`` or raise :attr:`BadHash`
585 :param bytes hashed_passord: A hashed password
586 :return: The scheme used by the hashed password
587 :rtype: bytes
588 :raises BadHash: if no valid scheme is found within ``hashed_passord``
589 """
590 if not hashed_passord[0] == b'{'[0] or b'}' not in hashed_passord:
591 raise cls.BadHash("%r should start with the scheme enclosed with { }" % hashed_passord)
592 scheme = hashed_passord.split(b'}', 1)[0]
593 scheme = scheme.upper() + b"}"
594 return scheme
596 @classmethod
597 def get_salt(cls, hashed_passord):
598 """
599 Return the salt of ``hashed_passord`` possibly empty
601 :param bytes hashed_passord: A hashed password
602 :return: The salt used by the hashed password (empty if no salt is used)
603 :rtype: bytes
604 :raises BadHash: if no valid scheme is found within ``hashed_passord`` or if the
605 hashed password is too short for the scheme found.
606 """
607 scheme = cls.get_scheme(hashed_passord)
608 cls._test_scheme(scheme)
609 if scheme in cls.schemes_nosalt:
610 return b""
611 elif scheme == b'{CRYPT}':
612 if b'$' in hashed_passord: 612 ↛ 614line 612 didn't jump to line 614, because the condition on line 612 was never false
613 return b'$'.join(hashed_passord.split(b'$', 3)[:-1])[len(scheme):]
614 return hashed_passord.split(b'}', 1)[-1]
615 else:
616 try:
617 hashed_passord = base64.b64decode(hashed_passord[len(scheme):])
618 except (TypeError, binascii.Error) as error:
619 raise cls.BadHash("Bad base64: %s" % error)
620 if len(hashed_passord) < cls._schemes_to_len[scheme]:
621 raise cls.BadHash("Hash too short for the scheme %s" % scheme)
622 return hashed_passord[cls._schemes_to_len[scheme]:]
625def check_password(method, password, hashed_password, charset):
626 """
627 Check that ``password`` match `hashed_password` using ``method``,
628 assuming the encoding is ``charset``.
630 :param str method: on of ``"crypt"``, ``"ldap"``, ``"hex_md5"``, ``"hex_sha1"``,
631 ``"hex_sha224"``, ``"hex_sha256"``, ``"hex_sha384"``, ``"hex_sha512"``, ``"plain"``
632 :param password: The user inputed password
633 :type password: :obj:`str` or :obj:`unicode`
634 :param hashed_password: The hashed password as stored in the database
635 :type hashed_password: :obj:`str` or :obj:`unicode`
636 :param str charset: The used char encoding (also used internally, so it must be valid for
637 the charset used by ``password`` when it was initially )
638 :return: True if ``password`` match ``hashed_password`` using ``method``,
639 ``False`` otherwise
640 :rtype: bool
641 """
642 if not isinstance(password, six.binary_type):
643 password = password.encode(charset)
644 if not isinstance(hashed_password, six.binary_type):
645 hashed_password = hashed_password.encode(charset)
646 if method == "plain":
647 return password == hashed_password
648 elif method == "crypt":
649 if hashed_password.startswith(b'$'):
650 salt = b'$'.join(hashed_password.split(b'$', 3)[:-1])
651 elif hashed_password.startswith(b'_'): # pragma: no cover old BSD format not supported
652 salt = hashed_password[:9]
653 else:
654 salt = hashed_password[:2]
655 if six.PY3:
656 password = password.decode(charset)
657 salt = salt.decode(charset)
658 hashed_password = hashed_password.decode(charset)
659 if not crypt_salt_is_valid(salt):
660 raise ValueError("System crypt implementation do not support the salt %r" % salt)
661 crypted_password = crypt.crypt(password, salt)
662 return crypted_password == hashed_password
663 elif method == "ldap":
664 scheme = LdapHashUserPassword.get_scheme(hashed_password)
665 salt = LdapHashUserPassword.get_salt(hashed_password)
666 return LdapHashUserPassword.hash(scheme, password, salt, charset=charset) == hashed_password
667 elif (
668 method.startswith("hex_") and
669 method[4:] in {"md5", "sha1", "sha224", "sha256", "sha384", "sha512"}
670 ):
671 return getattr(
672 hashlib,
673 method[4:]
674 )(password).hexdigest().encode("ascii") == hashed_password.lower()
675 else:
676 raise ValueError("Unknown password method check %r" % method)
679def decode_version(version):
680 """
681 decode a version string following version semantic http://semver.org/ input a tuple of int.
682 It will work as long as we do not use pre release versions.
684 :param unicode version: A dotted version
685 :return: A tuple a int
686 :rtype: tuple
687 """
688 return tuple(int(sub_version) for sub_version in version.split('.'))
691def last_version():
692 """
693 Fetch the last version from pypi and return it. On successful fetch from pypi, the response
694 is cached 24h, on error, it is cached 10 min.
696 :return: the last django-cas-server version
697 :rtype: unicode
698 """
699 try:
700 last_update, version, success = last_version._cache
701 except AttributeError:
702 last_update = 0
703 version = None
704 success = False
705 cache_delta = 24 * 3600 if success else 600
706 if (time.time() - last_update) < cache_delta:
707 return version
708 else:
709 try:
710 req = requests.get(settings.CAS_NEW_VERSION_JSON_URL)
711 data = json.loads(req.text)
712 version = data["info"]["version"]
713 last_version._cache = (time.time(), version, True)
714 return version
715 except (
716 KeyError,
717 ValueError,
718 requests.exceptions.RequestException
719 ) as error: # pragma: no cover (should not happen unless pypi is not available)
720 logger.error(
721 "Unable to fetch %s: %s" % (settings.CAS_NEW_VERSION_JSON_URL, error)
722 )
723 last_version._cache = (time.time(), version, False)
726def dictfetchall(cursor):
727 "Return all rows from a django cursor as a dict"
728 columns = [col[0] for col in cursor.description]
729 return [
730 dict(zip(columns, row))
731 for row in cursor.fetchall()
732 ]
735def logout_request(ticket):
736 """
737 Forge a SLO logout request
739 :param unicode ticket: A ticket value
740 :return: A SLO XML body request
741 :rtype: unicode
742 """
743 return u"""<samlp:LogoutRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
744 ID="%(id)s" Version="2.0" IssueInstant="%(datetime)s">
745<saml:NameID xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"></saml:NameID>
746<samlp:SessionIndex>%(ticket)s</samlp:SessionIndex>
747</samlp:LogoutRequest>""" % {
748 'id': gen_saml_id(),
749 'datetime': timezone.now().isoformat(),
750 'ticket': ticket
751 }
754def regexpr_validator(value):
755 """
756 Test that ``value`` is a valid regular expression
758 :param unicode value: A regular expression to test
759 :raises ValidationError: if ``value`` is not a valid regular expression
760 """
761 try:
762 re.compile(value)
763 except re.error:
764 raise ValidationError(
765 _('"%(value)s" is not a valid regular expression'),
766 params={'value': value}
767 )