utils.py 12.6 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 11
# 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.
#
# (c) 2015 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 20

import random
import string
21
import json
22 23 24 25
import hashlib
import crypt
import base64
import six
Valentin Samir's avatar
Valentin Samir committed
26
from threading import Thread
Valentin Samir's avatar
Valentin Samir committed
27
from importlib import import_module
Valentin Samir's avatar
Valentin Samir committed
28 29
from six.moves import BaseHTTPServer
from six.moves.urllib.parse import urlparse, urlunparse, parse_qsl, urlencode
Valentin Samir's avatar
Valentin Samir committed
30 31


32
def context(params):
33
    """Function that add somes variable to the context before template rendering"""
34 35 36 37
    params["settings"] = settings
    return params


Valentin Samir's avatar
style  
Valentin Samir committed
38
def json_response(request, data):
39
    """Wrapper dumping `data` to a json and sending it to the user with an HttpResponse"""
40 41 42 43 44 45
    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")


46 47 48
def import_attr(path):
    """transform a python module.attr path to the attr"""
    if not isinstance(path, str):
Valentin Samir's avatar
Valentin Samir committed
49
        return path
50 51
    if "." not in path:
        ValueError("%r should be of the form `module.attr` and we just got `attr`" % path)
52
    module, attr = path.rsplit('.', 1)
53 54 55 56 57 58
    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))
59

Valentin Samir's avatar
PEP8  
Valentin Samir committed
60

61 62 63
def redirect_params(url_name, params=None):
    """Redirect to `url_name` with `params` as querystring"""
    url = reverse(url_name)
Valentin Samir's avatar
Valentin Samir committed
64
    params = urlencode(params if params else {})
65 66
    return HttpResponseRedirect(url + "?%s" % params)

Valentin Samir's avatar
PEP8  
Valentin Samir committed
67

68
def reverse_params(url_name, params=None, **kwargs):
69
    """compule the reverse url or `url_name` and add GET parameters from `params` to it"""
70 71 72 73 74
    url = reverse(url_name, **kwargs)
    params = urlencode(params if params else {})
    return url + "?%s" % params


Valentin Samir's avatar
Valentin Samir committed
75
def update_url(url, params):
Valentin Samir's avatar
Valentin Samir committed
76
    """update params in the `url` query string"""
Valentin Samir's avatar
Valentin Samir committed
77
    if not isinstance(url, bytes):
Valentin Samir's avatar
PEP8  
Valentin Samir committed
78
        url = url.encode('utf-8')
Valentin Samir's avatar
Valentin Samir committed
79 80
    for key, value in list(params.items()):
        if not isinstance(key, bytes):
Valentin Samir's avatar
Valentin Samir committed
81 82
            del params[key]
            key = key.encode('utf-8')
Valentin Samir's avatar
Valentin Samir committed
83
        if not isinstance(value, bytes):
Valentin Samir's avatar
Valentin Samir committed
84 85
            value = value.encode('utf-8')
        params[key] = value
Valentin Samir's avatar
Valentin Samir committed
86 87
    url_parts = list(urlparse(url))
    query = dict(parse_qsl(url_parts[4]))
Valentin Samir's avatar
Valentin Samir committed
88
    query.update(params)
Valentin Samir's avatar
Valentin Samir committed
89
    url_parts[4] = urlencode(query)
Valentin Samir's avatar
style  
Valentin Samir committed
90 91 92
    for i, url_part in enumerate(url_parts):
        if not isinstance(url_part, bytes):
            url_parts[i] = url_part.encode('utf-8')
Valentin Samir's avatar
Valentin Samir committed
93
    return urlunparse(url_parts).decode('utf-8')
94

Valentin Samir's avatar
PEP8  
Valentin Samir committed
95

96 97 98 99 100 101 102 103 104 105 106 107 108 109 110
def unpack_nested_exception(error):
    """If exception are stacked, return the first one"""
    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


111
def _gen_ticket(prefix, lg=settings.CAS_TICKET_LEN):
112 113 114 115 116 117
    """Generate a ticket with prefix `prefix`"""
    return '%s-%s' % (
        prefix,
        ''.join(
            random.choice(
                string.ascii_letters + string.digits
118
            ) for _ in range(lg - len(prefix) - 1)
119 120 121
        )
    )

Valentin Samir's avatar
PEP8  
Valentin Samir committed
122

123 124 125 126
def gen_lt():
    """Generate a Service Ticket"""
    return _gen_ticket(settings.CAS_LOGIN_TICKET_PREFIX, settings.CAS_LT_LEN)

Valentin Samir's avatar
PEP8  
Valentin Samir committed
127

128 129
def gen_st():
    """Generate a Service Ticket"""
130
    return _gen_ticket(settings.CAS_SERVICE_TICKET_PREFIX, settings.CAS_ST_LEN)
131

Valentin Samir's avatar
PEP8  
Valentin Samir committed
132

133 134
def gen_pt():
    """Generate a Proxy Ticket"""
135
    return _gen_ticket(settings.CAS_PROXY_TICKET_PREFIX, settings.CAS_PT_LEN)
136

Valentin Samir's avatar
PEP8  
Valentin Samir committed
137

138 139
def gen_pgt():
    """Generate a Proxy Granting Ticket"""
140
    return _gen_ticket(settings.CAS_PROXY_GRANTING_TICKET_PREFIX, settings.CAS_PGT_LEN)
141

Valentin Samir's avatar
PEP8  
Valentin Samir committed
142

143 144
def gen_pgtiou():
    """Generate a Proxy Granting Ticket IOU"""
145
    return _gen_ticket(settings.CAS_PROXY_GRANTING_TICKET_IOU_PREFIX, settings.CAS_PGTIOU_LEN)
146

147 148

def gen_saml_id():
149
    """Generate an saml id"""
150
    return _gen_ticket('_')
Valentin Samir's avatar
Valentin Samir committed
151 152 153


class PGTUrlHandler(BaseHTTPServer.BaseHTTPRequestHandler):
154
    """A simple http server that return 200 on GET and store GET parameters. Used in unit tests"""
Valentin Samir's avatar
style  
Valentin Samir committed
155 156
    PARAMS = {}

Valentin Samir's avatar
style  
Valentin Samir committed
157
    def do_GET(self):
158
        """Called on a GET request on the BaseHTTPServer"""
Valentin Samir's avatar
style  
Valentin Samir committed
159 160 161 162 163
        self.send_response(200)
        self.send_header(b"Content-type", "text/plain")
        self.end_headers()
        self.wfile.write(b"ok")
        url = urlparse(self.path)
Valentin Samir's avatar
Valentin Samir committed
164 165
        params = dict(parse_qsl(url.query))
        PGTUrlHandler.PARAMS.update(params)
Valentin Samir's avatar
style  
Valentin Samir committed
166

Valentin Samir's avatar
style  
Valentin Samir committed
167
    def log_message(self, *args):
168
        """silent any log message"""
Valentin Samir's avatar
Valentin Samir committed
169 170
        return

171 172
    @classmethod
    def run(cls):
173
        """Run a BaseHTTPServer using this class as handler"""
Valentin Samir's avatar
Valentin Samir committed
174
        server_class = BaseHTTPServer.HTTPServer
175
        httpd = server_class(("127.0.0.1", 0), cls)
Valentin Samir's avatar
style  
Valentin Samir committed
176 177
        (host, port) = httpd.socket.getsockname()

Valentin Samir's avatar
Valentin Samir committed
178
        def lauch():
179
            """routine to lauch in a background thread"""
Valentin Samir's avatar
Valentin Samir committed
180 181
            httpd.handle_request()
            httpd.server_close()
Valentin Samir's avatar
style  
Valentin Samir committed
182

Valentin Samir's avatar
Valentin Samir committed
183 184 185 186
        httpd_thread = Thread(target=lauch)
        httpd_thread.daemon = True
        httpd_thread.start()
        return (httpd_thread, host, port)
187

188

189 190 191
class PGTUrlHandler404(PGTUrlHandler):
    """A simple http server that always return 404 not found. Used in unit tests"""
    def do_GET(self):
192
        """Called on a GET request on the BaseHTTPServer"""
193 194 195 196 197 198
        self.send_response(404)
        self.send_header(b"Content-type", "text/plain")
        self.end_headers()
        self.wfile.write(b"error 404 not found")


199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226
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):
Valentin Samir's avatar
Valentin Samir committed
227
        """Error raised then the hash scheme is not in schemes_salt + schemes_nosalt"""
228 229 230
        pass

    class BadHash(ValueError):
Valentin Samir's avatar
Valentin Samir committed
231
        """Error raised then the hash is too short"""
232 233 234
        pass

    class BadSalt(ValueError):
Valentin Samir's avatar
Valentin Samir committed
235
        """Error raised then with the scheme {CRYPT} the salt is invalid"""
236 237 238 239
        pass

    @classmethod
    def _raise_bad_scheme(cls, scheme, valid, msg):
Valentin Samir's avatar
Valentin Samir committed
240 241 242 243
        """
            Raise BadScheme error for `scheme`, possible valid scheme are
            in `valid`, the error message is `msg`
        """
244
        valid_schemes = [s.decode() for s in valid]
245
        valid_schemes.sort()
246
        raise cls.BadScheme(msg % (scheme, u", ".join(valid_schemes)))
247 248 249

    @classmethod
    def _test_scheme(cls, scheme):
Valentin Samir's avatar
Valentin Samir committed
250
        """Test if a scheme is valide or raise BadScheme"""
251 252 253 254 255 256 257 258 259
        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):
Valentin Samir's avatar
Valentin Samir committed
260
        """Test if the scheme need a salt or raise BadScheme"""
261 262 263 264 265 266 267 268 269
        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):
Valentin Samir's avatar
Valentin Samir committed
270
        """Test if the scheme need no salt or raise BadScheme"""
271 272 273 274 275 276 277 278 279
        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
280 281 282 283
        """
           Hash `password` with `scheme` using `salt`.
           This three variable beeing encoded in `charset`.
        """
284 285 286 287 288 289 290 291
        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:
292 293 294
            return scheme + base64.b64encode(
                cls._schemes_to_hash[scheme](password + salt).digest() + salt
            )
295 296 297 298 299 300 301 302 303 304 305 306 307
        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):
Valentin Samir's avatar
Valentin Samir committed
308
        """Return the scheme of `hashed_passord` or raise BadHash"""
309
        if not hashed_passord[0] == b'{'[0] or b'}' not in hashed_passord:
310 311 312 313 314 315 316
            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):
Valentin Samir's avatar
Valentin Samir committed
317
        """Return the salt of `hashed_passord` possibly empty"""
318 319 320 321 322 323 324 325 326 327 328 329 330 331
        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):
Valentin Samir's avatar
Valentin Samir committed
332 333 334 335
    """
        Check that `password` match `hashed_password` using `method`,
        assuming the encoding is `charset`.
    """
336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364
    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"}
    ):
365 366 367 368
        return getattr(
            hashlib,
            method[4:]
        )(password).hexdigest().encode("ascii") == hashed_password.lower()
369 370
    else:
        raise ValueError("Unknown password method check %r" % method)