1# (c) 2005 Ian Bicking and contributors; written for Paste (http://pythonpaste.org)
2# Licensed under the MIT license: http://www.opensource.org/licenses/mit-license.php
3##########################################################################
4#
5# Copyright (c) 2005 Imaginary Landscape LLC and Contributors.
6#
7# Permission is hereby granted, free of charge, to any person obtaining
8# a copy of this software and associated documentation files (the
9# "Software"), to deal in the Software without restriction, including
10# without limitation the rights to use, copy, modify, merge, publish,
11# distribute, sublicense, and/or sell copies of the Software, and to
12# permit persons to whom the Software is furnished to do so, subject to
13# the following conditions:
14#
15# The above copyright notice and this permission notice shall be
16# included in all copies or substantial portions of the Software.
17#
18# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
19# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
20# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
21# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
22# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
23# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
24# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
25##########################################################################
26"""
27Implementation of cookie signing as done in `mod_auth_tkt
28<http://www.openfusion.com.au/labs/mod_auth_tkt/>`_.
29
30mod_auth_tkt is an Apache module that looks for these signed cookies
31and sets ``REMOTE_USER``, ``REMOTE_USER_TOKENS`` (a comma-separated
32list of groups) and ``REMOTE_USER_DATA`` (arbitrary string data).
33
34This module is an alternative to the ``paste.auth.cookie`` module;
35it's primary benefit is compatibility with mod_auth_tkt, which in turn
36makes it possible to use the same authentication process with
37non-Python code run under Apache.
38"""
39
40import time as time_mod
41try:
42    import hashlib
43except ImportError:
44    # mimic hashlib (will work for md5, fail for secure hashes)
45    import md5 as hashlib
46try:
47    from http.cookies import SimpleCookie
48except ImportError:
49    # Python 2
50    from Cookie import SimpleCookie
51from paste import request
52from urllib import quote as url_quote
53from urllib import unquote as url_unquote
54
55DEFAULT_DIGEST = hashlib.md5
56
57
58class AuthTicket(object):
59
60    """
61    This class represents an authentication token.  You must pass in
62    the shared secret, the userid, and the IP address.  Optionally you
63    can include tokens (a list of strings, representing role names),
64    'user_data', which is arbitrary data available for your own use in
65    later scripts.  Lastly, you can override the timestamp, cookie name,
66    whether to secure the cookie and the digest algorithm (for details
67    look at ``AuthTKTMiddleware``).
68
69    Once you provide all the arguments, use .cookie_value() to
70    generate the appropriate authentication ticket.  .cookie()
71    generates a Cookie object, the str() of which is the complete
72    cookie header to be sent.
73
74    CGI usage::
75
76        token = auth_tkt.AuthTick('sharedsecret', 'username',
77            os.environ['REMOTE_ADDR'], tokens=['admin'])
78        print('Status: 200 OK')
79        print('Content-type: text/html')
80        print(token.cookie())
81        print("")
82        ... redirect HTML ...
83
84    Webware usage::
85
86        token = auth_tkt.AuthTick('sharedsecret', 'username',
87            self.request().environ()['REMOTE_ADDR'], tokens=['admin'])
88        self.response().setCookie('auth_tkt', token.cookie_value())
89
90    Be careful not to do an HTTP redirect after login; use meta
91    refresh or Javascript -- some browsers have bugs where cookies
92    aren't saved when set on a redirect.
93    """
94
95    def __init__(self, secret, userid, ip, tokens=(), user_data='',
96                 time=None, cookie_name='auth_tkt',
97                 secure=False, digest_algo=DEFAULT_DIGEST):
98        self.secret = secret
99        self.userid = userid
100        self.ip = ip
101        if not isinstance(tokens, basestring):
102            tokens = ','.join(tokens)
103        self.tokens = tokens
104        self.user_data = user_data
105        if time is None:
106            self.time = time_mod.time()
107        else:
108            self.time = time
109        self.cookie_name = cookie_name
110        self.secure = secure
111        if isinstance(digest_algo, str):
112            # correct specification of digest from hashlib or fail
113            self.digest_algo = getattr(hashlib, digest_algo)
114        else:
115            self.digest_algo = digest_algo
116
117    def digest(self):
118        return calculate_digest(
119            self.ip, self.time, self.secret, self.userid, self.tokens,
120            self.user_data, self.digest_algo)
121
122    def cookie_value(self):
123        v = '%s%08x%s!' % (self.digest(), int(self.time), url_quote(self.userid))
124        if self.tokens:
125            v += self.tokens + '!'
126        v += self.user_data
127        return v
128
129    def cookie(self):
130        c = SimpleCookie()
131        c[self.cookie_name] = self.cookie_value().encode('base64').strip().replace('\n', '')
132        c[self.cookie_name]['path'] = '/'
133        if self.secure:
134            c[self.cookie_name]['secure'] = 'true'
135        return c
136
137
138class BadTicket(Exception):
139    """
140    Exception raised when a ticket can't be parsed.  If we get
141    far enough to determine what the expected digest should have
142    been, expected is set.  This should not be shown by default,
143    but can be useful for debugging.
144    """
145    def __init__(self, msg, expected=None):
146        self.expected = expected
147        Exception.__init__(self, msg)
148
149
150def parse_ticket(secret, ticket, ip, digest_algo=DEFAULT_DIGEST):
151    """
152    Parse the ticket, returning (timestamp, userid, tokens, user_data).
153
154    If the ticket cannot be parsed, ``BadTicket`` will be raised with
155    an explanation.
156    """
157    if isinstance(digest_algo, str):
158        # correct specification of digest from hashlib or fail
159        digest_algo = getattr(hashlib, digest_algo)
160    digest_hexa_size = digest_algo().digest_size * 2
161    ticket = ticket.strip('"')
162    digest = ticket[:digest_hexa_size]
163    try:
164        timestamp = int(ticket[digest_hexa_size:digest_hexa_size + 8], 16)
165    except ValueError as e:
166        raise BadTicket('Timestamp is not a hex integer: %s' % e)
167    try:
168        userid, data = ticket[digest_hexa_size + 8:].split('!', 1)
169    except ValueError:
170        raise BadTicket('userid is not followed by !')
171    userid = url_unquote(userid)
172    if '!' in data:
173        tokens, user_data = data.split('!', 1)
174    else:
175        # @@: Is this the right order?
176        tokens = ''
177        user_data = data
178
179    expected = calculate_digest(ip, timestamp, secret,
180                                userid, tokens, user_data,
181                                digest_algo)
182
183    if expected != digest:
184        raise BadTicket('Digest signature is not correct',
185                        expected=(expected, digest))
186
187    tokens = tokens.split(',')
188
189    return (timestamp, userid, tokens, user_data)
190
191
192# @@: Digest object constructor compatible with named ones in hashlib only
193def calculate_digest(ip, timestamp, secret, userid, tokens, user_data,
194                     digest_algo):
195    secret = maybe_encode(secret)
196    userid = maybe_encode(userid)
197    tokens = maybe_encode(tokens)
198    user_data = maybe_encode(user_data)
199    digest0 = digest_algo(
200        encode_ip_timestamp(ip, timestamp) + secret + userid + '\0'
201        + tokens + '\0' + user_data).hexdigest()
202    digest = digest_algo(digest0 + secret).hexdigest()
203    return digest
204
205
206def encode_ip_timestamp(ip, timestamp):
207    ip_chars = ''.join(map(chr, map(int, ip.split('.'))))
208    t = int(timestamp)
209    ts = ((t & 0xff000000) >> 24,
210          (t & 0xff0000) >> 16,
211          (t & 0xff00) >> 8,
212          t & 0xff)
213    ts_chars = ''.join(map(chr, ts))
214    return ip_chars + ts_chars
215
216
217def maybe_encode(s, encoding='utf8'):
218    if isinstance(s, unicode):
219        s = s.encode(encoding)
220    return s
221
222
223class AuthTKTMiddleware(object):
224
225    """
226    Middleware that checks for signed cookies that match what
227    `mod_auth_tkt <http://www.openfusion.com.au/labs/mod_auth_tkt/>`_
228    looks for (if you have mod_auth_tkt installed, you don't need this
229    middleware, since Apache will set the environmental variables for
230    you).
231
232    Arguments:
233
234    ``secret``:
235        A secret that should be shared by any instances of this application.
236        If this app is served from more than one machine, they should all
237        have the same secret.
238
239    ``cookie_name``:
240        The name of the cookie to read and write from.  Default ``auth_tkt``.
241
242    ``secure``:
243        If the cookie should be set as 'secure' (only sent over SSL) and if
244        the login must be over SSL. (Defaults to False)
245
246    ``httponly``:
247        If the cookie should be marked as HttpOnly, which means that it's
248        not accessible to JavaScript. (Defaults to False)
249
250    ``include_ip``:
251        If the cookie should include the user's IP address.  If so, then
252        if they change IPs their cookie will be invalid.
253
254    ``logout_path``:
255        The path under this middleware that should signify a logout.  The
256        page will be shown as usual, but the user will also be logged out
257        when they visit this page.
258
259    ``digest_algo``:
260        Digest algorithm specified as a name of the algorithm provided by
261        ``hashlib`` or as a compatible digest object constructor.
262        Defaults to ``md5``, as in mod_auth_tkt.  The others currently
263        compatible with mod_auth_tkt are ``sha256`` and ``sha512``.
264
265    If used with mod_auth_tkt, then these settings (except logout_path) should
266    match the analogous Apache configuration settings.
267
268    This also adds two functions to the request:
269
270    ``environ['paste.auth_tkt.set_user'](userid, tokens='', user_data='')``
271
272        This sets a cookie that logs the user in.  ``tokens`` is a
273        string (comma-separated groups) or a list of strings.
274        ``user_data`` is a string for your own use.
275
276    ``environ['paste.auth_tkt.logout_user']()``
277
278        Logs out the user.
279    """
280
281    def __init__(self, app, secret, cookie_name='auth_tkt', secure=False,
282                 include_ip=True, logout_path=None, httponly=False,
283                 no_domain_cookie=True, current_domain_cookie=True,
284                 wildcard_cookie=True, digest_algo=DEFAULT_DIGEST):
285        self.app = app
286        self.secret = secret
287        self.cookie_name = cookie_name
288        self.secure = secure
289        self.httponly = httponly
290        self.include_ip = include_ip
291        self.logout_path = logout_path
292        self.no_domain_cookie = no_domain_cookie
293        self.current_domain_cookie = current_domain_cookie
294        self.wildcard_cookie = wildcard_cookie
295        if isinstance(digest_algo, str):
296            # correct specification of digest from hashlib or fail
297            self.digest_algo = getattr(hashlib, digest_algo)
298        else:
299            self.digest_algo = digest_algo
300
301    def __call__(self, environ, start_response):
302        cookies = request.get_cookies(environ)
303        if self.cookie_name in cookies:
304            cookie_value = cookies[self.cookie_name].value
305        else:
306            cookie_value = ''
307        if cookie_value:
308            if self.include_ip:
309                remote_addr = environ['REMOTE_ADDR']
310            else:
311                # mod_auth_tkt uses this dummy value when IP is not
312                # checked:
313                remote_addr = '0.0.0.0'
314            # @@: This should handle bad signatures better:
315            # Also, timeouts should cause cookie refresh
316            try:
317                timestamp, userid, tokens, user_data = parse_ticket(
318                    self.secret, cookie_value, remote_addr, self.digest_algo)
319                tokens = ','.join(tokens)
320                environ['REMOTE_USER'] = userid
321                if environ.get('REMOTE_USER_TOKENS'):
322                    # We want to add tokens/roles to what's there:
323                    tokens = environ['REMOTE_USER_TOKENS'] + ',' + tokens
324                environ['REMOTE_USER_TOKENS'] = tokens
325                environ['REMOTE_USER_DATA'] = user_data
326                environ['AUTH_TYPE'] = 'cookie'
327            except BadTicket:
328                # bad credentials, just ignore without logging the user
329                # in or anything
330                pass
331        set_cookies = []
332
333        def set_user(userid, tokens='', user_data=''):
334            set_cookies.extend(self.set_user_cookie(
335                environ, userid, tokens, user_data))
336
337        def logout_user():
338            set_cookies.extend(self.logout_user_cookie(environ))
339
340        environ['paste.auth_tkt.set_user'] = set_user
341        environ['paste.auth_tkt.logout_user'] = logout_user
342        if self.logout_path and environ.get('PATH_INFO') == self.logout_path:
343            logout_user()
344
345        def cookie_setting_start_response(status, headers, exc_info=None):
346            headers.extend(set_cookies)
347            return start_response(status, headers, exc_info)
348
349        return self.app(environ, cookie_setting_start_response)
350
351    def set_user_cookie(self, environ, userid, tokens, user_data):
352        if not isinstance(tokens, basestring):
353            tokens = ','.join(tokens)
354        if self.include_ip:
355            remote_addr = environ['REMOTE_ADDR']
356        else:
357            remote_addr = '0.0.0.0'
358        ticket = AuthTicket(
359            self.secret,
360            userid,
361            remote_addr,
362            tokens=tokens,
363            user_data=user_data,
364            cookie_name=self.cookie_name,
365            secure=self.secure)
366        # @@: Should we set REMOTE_USER etc in the current
367        # environment right now as well?
368        cur_domain = environ.get('HTTP_HOST', environ.get('SERVER_NAME'))
369        wild_domain = '.' + cur_domain
370
371        cookie_options = ""
372        if self.secure:
373            cookie_options += "; secure"
374        if self.httponly:
375            cookie_options += "; HttpOnly"
376
377        cookies = []
378        if self.no_domain_cookie:
379            cookies.append(('Set-Cookie', '%s=%s; Path=/%s' % (
380                self.cookie_name, ticket.cookie_value(), cookie_options)))
381        if self.current_domain_cookie:
382            cookies.append(('Set-Cookie', '%s=%s; Path=/; Domain=%s%s' % (
383                self.cookie_name, ticket.cookie_value(), cur_domain,
384                cookie_options)))
385        if self.wildcard_cookie:
386            cookies.append(('Set-Cookie', '%s=%s; Path=/; Domain=%s%s' % (
387                self.cookie_name, ticket.cookie_value(), wild_domain,
388                cookie_options)))
389
390        return cookies
391
392    def logout_user_cookie(self, environ):
393        cur_domain = environ.get('HTTP_HOST', environ.get('SERVER_NAME'))
394        wild_domain = '.' + cur_domain
395        expires = 'Sat, 01-Jan-2000 12:00:00 GMT'
396        cookies = [
397            ('Set-Cookie', '%s=""; Expires="%s"; Path=/' % (self.cookie_name, expires)),
398            ('Set-Cookie', '%s=""; Expires="%s"; Path=/; Domain=%s' %
399             (self.cookie_name, expires, cur_domain)),
400            ('Set-Cookie', '%s=""; Expires="%s"; Path=/; Domain=%s' %
401             (self.cookie_name, expires, wild_domain)),
402            ]
403        return cookies
404
405
406def make_auth_tkt_middleware(
407    app,
408    global_conf,
409    secret=None,
410    cookie_name='auth_tkt',
411    secure=False,
412    include_ip=True,
413    logout_path=None):
414    """
415    Creates the `AuthTKTMiddleware
416    <class-paste.auth.auth_tkt.AuthTKTMiddleware.html>`_.
417
418    ``secret`` is requird, but can be set globally or locally.
419    """
420    from paste.deploy.converters import asbool
421    secure = asbool(secure)
422    include_ip = asbool(include_ip)
423    if secret is None:
424        secret = global_conf.get('secret')
425    if not secret:
426        raise ValueError(
427            "You must provide a 'secret' (in global or local configuration)")
428    return AuthTKTMiddleware(
429        app, secret, cookie_name, secure, include_ip, logout_path or None)
430