1# (c) 2005 Clark C. Evans
2# This module is part of the Python Paste Project and is released under
3# the MIT License: http://www.opensource.org/licenses/mit-license.php
4# This code was written with funding by http://prometheusresearch.com
5"""
6Cookie "Saved" Authentication
7
8This authentication middleware saves the current REMOTE_USER,
9REMOTE_SESSION, and any other environment variables specified in a
10cookie so that it can be retrieved during the next request without
11requiring re-authentication. This uses a session cookie on the client
12side (so it goes away when the user closes their window) and does
13server-side expiration.
14
15Following is a very simple example where a form is presented asking for
16a user name (no actual checking), and dummy session identifier (perhaps
17corresponding to a database session id) is stored in the cookie.
18
19::
20
21  >>> from paste.httpserver import serve
22  >>> from paste.fileapp import DataApp
23  >>> from paste.httpexceptions import *
24  >>> from paste.auth.cookie import AuthCookieHandler
25  >>> from paste.wsgilib import parse_querystring
26  >>> def testapp(environ, start_response):
27  ...     user = dict(parse_querystring(environ)).get('user','')
28  ...     if user:
29  ...         environ['REMOTE_USER'] = user
30  ...         environ['REMOTE_SESSION'] = 'a-session-id'
31  ...     if environ.get('REMOTE_USER'):
32  ...         page = '<html><body>Welcome %s (%s)</body></html>'
33  ...         page %= (environ['REMOTE_USER'], environ['REMOTE_SESSION'])
34  ...     else:
35  ...         page = ('<html><body><form><input name="user" />'
36  ...                 '<input type="submit" /></form></body></html>')
37  ...     return DataApp(page, content_type="text/html")(
38  ...                    environ, start_response)
39  >>> serve(AuthCookieHandler(testapp))
40  serving on...
41
42"""
43
44import hmac, base64, random, six, time, warnings
45try:
46    from hashlib import sha1
47except ImportError:
48    # NOTE: We have to use the callable with hashlib (hashlib.sha1),
49    # otherwise hmac only accepts the sha module object itself
50    import sha as sha1
51from paste.request import get_cookies
52
53def make_time(value):
54    return time.strftime("%Y%m%d%H%M", time.gmtime(value))
55_signature_size = len(hmac.new(b'x', b'x', sha1).digest())
56_header_size = _signature_size + len(make_time(time.time()))
57
58# @@: Should this be using urllib.quote?
59# build encode/decode functions to safely pack away values
60_encode = [('\\', '\\x5c'), ('"', '\\x22'),
61           ('=', '\\x3d'), (';', '\\x3b')]
62_decode = [(v, k) for (k, v) in _encode]
63_decode.reverse()
64def encode(s, sublist = _encode):
65    return six.moves.reduce((lambda a, b: a.replace(b[0], b[1])), sublist, str(s))
66decode = lambda s: encode(s, _decode)
67
68class CookieTooLarge(RuntimeError):
69    def __init__(self, content, cookie):
70        RuntimeError.__init__("Signed cookie exceeds maximum size of 4096")
71        self.content = content
72        self.cookie = cookie
73
74_all_chars = ''.join([chr(x) for x in range(0, 255)])
75def new_secret():
76    """ returns a 64 byte secret """
77    secret = ''.join(random.sample(_all_chars, 64))
78    if six.PY3:
79        secret = secret.encode('utf8')
80    return secret
81
82class AuthCookieSigner(object):
83    """
84    save/restore ``environ`` entries via digially signed cookie
85
86    This class converts content into a timed and digitally signed
87    cookie, as well as having the facility to reverse this procedure.
88    If the cookie, after the content is encoded and signed exceeds the
89    maximum length (4096), then CookieTooLarge exception is raised.
90
91    The timeout of the cookie is handled on the server side for a few
92    reasons.  First, if a 'Expires' directive is added to a cookie, then
93    the cookie becomes persistent (lasting even after the browser window
94    has closed). Second, the user's clock may be wrong (perhaps
95    intentionally). The timeout is specified in minutes; and expiration
96    date returned is rounded to one second.
97
98    Constructor Arguments:
99
100        ``secret``
101
102            This is a secret key if you want to syncronize your keys so
103            that the cookie will be good across a cluster of computers.
104            It is recommended via the HMAC specification (RFC 2104) that
105            the secret key be 64 bytes since this is the block size of
106            the hashing.  If you do not provide a secret key, a random
107            one is generated each time you create the handler; this
108            should be sufficient for most cases.
109
110        ``timeout``
111
112            This is the time (in minutes) from which the cookie is set
113            to expire.  Note that on each request a new (replacement)
114            cookie is sent, hence this is effectively a session timeout
115            parameter for your entire cluster.  If you do not provide a
116            timeout, it is set at 30 minutes.
117
118        ``maxlen``
119
120            This is the maximum size of the *signed* cookie; hence the
121            actual content signed will be somewhat less.  If the cookie
122            goes over this size, a ``CookieTooLarge`` exception is
123            raised so that unexpected handling of cookies on the client
124            side are avoided.  By default this is set at 4k (4096 bytes),
125            which is the standard cookie size limit.
126
127    """
128    def __init__(self, secret = None, timeout = None, maxlen = None):
129        self.timeout = timeout or 30
130        if isinstance(timeout, six.string_types):
131            raise ValueError(
132                "Timeout must be a number (minutes), not a string (%r)"
133                % timeout)
134        self.maxlen  = maxlen or 4096
135        self.secret = secret or new_secret()
136
137    def sign(self, content):
138        """
139        Sign the content returning a valid cookie (that does not
140        need to be escaped and quoted).  The expiration of this
141        cookie is handled server-side in the auth() function.
142        """
143        timestamp = make_time(time.time() + 60*self.timeout)
144        if six.PY3:
145            content = content.encode('utf8')
146            timestamp = timestamp.encode('utf8')
147        cookie = base64.encodestring(
148            hmac.new(self.secret, content, sha1).digest() +
149            timestamp +
150            content)
151        cookie = cookie.replace(b"/", b"_").replace(b"=", b"~")
152        cookie = cookie.replace(b'\n', b'').replace(b'\r', b'')
153        if len(cookie) > self.maxlen:
154            raise CookieTooLarge(content, cookie)
155        return cookie
156
157    def auth(self, cookie):
158        """
159        Authenticate the cooke using the signature, verify that it
160        has not expired; and return the cookie's content
161        """
162        decode = base64.decodestring(
163            cookie.replace("_", "/").replace("~", "="))
164        signature = decode[:_signature_size]
165        expires = decode[_signature_size:_header_size]
166        content = decode[_header_size:]
167        if signature == hmac.new(self.secret, content, sha1).digest():
168            if int(expires) > int(make_time(time.time())):
169                return content
170            else:
171                # This is the normal case of an expired cookie; just
172                # don't bother doing anything here.
173                pass
174        else:
175            # This case can happen if the server is restarted with a
176            # different secret; or if the user's IP address changed
177            # due to a proxy.  However, it could also be a break-in
178            # attempt -- so should it be reported?
179            pass
180
181class AuthCookieEnviron(list):
182    """
183    a list of environment keys to be saved via cookie
184
185    An instance of this object, found at ``environ['paste.auth.cookie']``
186    lists the `environ` keys that were restored from or will be added
187    to the digially signed cookie.  This object can be accessed from an
188    `environ` variable by using this module's name.
189    """
190    def __init__(self, handler, scanlist):
191        list.__init__(self, scanlist)
192        self.handler = handler
193    def append(self, value):
194        if value in self:
195            return
196        list.append(self, str(value))
197
198class AuthCookieHandler(object):
199    """
200    the actual handler that should be put in your middleware stack
201
202    This middleware uses cookies to stash-away a previously authenticated
203    user (and perhaps other variables) so that re-authentication is not
204    needed.  This does not implement sessions; and therefore N servers
205    can be syncronized to accept the same saved authentication if they
206    all use the same cookie_name and secret.
207
208    By default, this handler scans the `environ` for the REMOTE_USER
209    and REMOTE_SESSION key; if found, it is stored. It can be
210    configured to scan other `environ` keys as well -- but be careful
211    not to exceed 2-3k (so that the encoded and signed cookie does not
212    exceed 4k). You can ask it to handle other environment variables
213    by doing:
214
215       ``environ['paste.auth.cookie'].append('your.environ.variable')``
216
217
218    Constructor Arguments:
219
220        ``application``
221
222            This is the wrapped application which will have access to
223            the ``environ['REMOTE_USER']`` restored by this middleware.
224
225        ``cookie_name``
226
227            The name of the cookie used to store this content, by default
228            it is ``PASTE_AUTH_COOKIE``.
229
230        ``scanlist``
231
232            This is the initial set of ``environ`` keys to
233            save/restore to the signed cookie.  By default is consists
234            only of ``REMOTE_USER`` and ``REMOTE_SESSION``; any tuple
235            or list of environment keys will work.  However, be
236            careful, as the total saved size is limited to around 3k.
237
238        ``signer``
239
240            This is the signer object used to create the actual cookie
241            values, by default, it is ``AuthCookieSigner`` and is passed
242            the remaining arguments to this function: ``secret``,
243            ``timeout``, and ``maxlen``.
244
245    At this time, each cookie is individually signed.  To store more
246    than the 4k of data; it is possible to sub-class this object to
247    provide different ``environ_name`` and ``cookie_name``
248    """
249    environ_name = 'paste.auth.cookie'
250    cookie_name  = 'PASTE_AUTH_COOKIE'
251    signer_class = AuthCookieSigner
252    environ_class = AuthCookieEnviron
253
254    def __init__(self, application, cookie_name=None, scanlist=None,
255                 signer=None, secret=None, timeout=None, maxlen=None):
256        if not signer:
257            signer = self.signer_class(secret, timeout, maxlen)
258        self.signer = signer
259        self.scanlist = scanlist or ('REMOTE_USER','REMOTE_SESSION')
260        self.application = application
261        self.cookie_name = cookie_name or self.cookie_name
262
263    def __call__(self, environ, start_response):
264        if self.environ_name in environ:
265            raise AssertionError("AuthCookie already installed!")
266        scanlist = self.environ_class(self, self.scanlist)
267        jar = get_cookies(environ)
268        if self.cookie_name in jar:
269            content = self.signer.auth(jar[self.cookie_name].value)
270            if content:
271                for pair in content.split(";"):
272                    (k, v) = pair.split("=")
273                    k = decode(k)
274                    if k not in scanlist:
275                        scanlist.append(k)
276                    if k in environ:
277                        continue
278                    environ[k] = decode(v)
279                    if 'REMOTE_USER' == k:
280                        environ['AUTH_TYPE'] = 'cookie'
281        environ[self.environ_name] = scanlist
282        if "paste.httpexceptions" in environ:
283            warnings.warn("Since paste.httpexceptions is hooked in your "
284                "processing chain before paste.auth.cookie, if an "
285                "HTTPRedirection is raised, the cookies this module sets "
286                "will not be included in your response.\n")
287
288        def response_hook(status, response_headers, exc_info=None):
289            """
290            Scan the environment for keys specified in the scanlist,
291            pack up their values, signs the content and issues a cookie.
292            """
293            scanlist = environ.get(self.environ_name)
294            assert scanlist and isinstance(scanlist, self.environ_class)
295            content = []
296            for k in scanlist:
297                v = environ.get(k)
298                if v is not None:
299                    if type(v) is not str:
300                        raise ValueError(
301                            "The value of the environmental variable %r "
302                            "is not a str (only str is allowed; got %r)"
303                            % (k, v))
304                    content.append("%s=%s" % (encode(k), encode(v)))
305            if content:
306                content = ";".join(content)
307                content = self.signer.sign(content)
308                if six.PY3:
309                    content = content.decode('utf8')
310                cookie = '%s=%s; Path=/;' % (self.cookie_name, content)
311                if 'https' == environ['wsgi.url_scheme']:
312                    cookie += ' secure;'
313                response_headers.append(('Set-Cookie', cookie))
314            return start_response(status, response_headers, exc_info)
315        return self.application(environ, response_hook)
316
317middleware = AuthCookieHandler
318
319# Paste Deploy entry point:
320def make_auth_cookie(
321    app, global_conf,
322    # Should this get picked up from global_conf somehow?:
323    cookie_name='PASTE_AUTH_COOKIE',
324    scanlist=('REMOTE_USER', 'REMOTE_SESSION'),
325    # signer cannot be set
326    secret=None,
327    timeout=30,
328    maxlen=4096):
329    """
330    This middleware uses cookies to stash-away a previously
331    authenticated user (and perhaps other variables) so that
332    re-authentication is not needed.  This does not implement
333    sessions; and therefore N servers can be syncronized to accept the
334    same saved authentication if they all use the same cookie_name and
335    secret.
336
337    By default, this handler scans the `environ` for the REMOTE_USER
338    and REMOTE_SESSION key; if found, it is stored. It can be
339    configured to scan other `environ` keys as well -- but be careful
340    not to exceed 2-3k (so that the encoded and signed cookie does not
341    exceed 4k). You can ask it to handle other environment variables
342    by doing:
343
344       ``environ['paste.auth.cookie'].append('your.environ.variable')``
345
346    Configuration:
347
348        ``cookie_name``
349
350            The name of the cookie used to store this content, by
351            default it is ``PASTE_AUTH_COOKIE``.
352
353        ``scanlist``
354
355            This is the initial set of ``environ`` keys to
356            save/restore to the signed cookie.  By default is consists
357            only of ``REMOTE_USER`` and ``REMOTE_SESSION``; any
358            space-separated list of environment keys will work.
359            However, be careful, as the total saved size is limited to
360            around 3k.
361
362        ``secret``
363
364            The secret that will be used to sign the cookies.  If you
365            don't provide one (and none is set globally) then a random
366            secret will be created.  Each time the server is restarted
367            a new secret will then be created and all cookies will
368            become invalid!  This can be any string value.
369
370        ``timeout``
371
372            The time to keep the cookie, expressed in minutes.  This
373            is handled server-side, so a new cookie with a new timeout
374            is added to every response.
375
376        ``maxlen``
377
378            The maximum length of the cookie that is sent (default 4k,
379            which is a typical browser maximum)
380
381    """
382    if isinstance(scanlist, six.string_types):
383        scanlist = scanlist.split()
384    if secret is None and global_conf.get('secret'):
385        secret = global_conf['secret']
386    try:
387        timeout = int(timeout)
388    except ValueError:
389        raise ValueError('Bad value for timeout (must be int): %r'
390                         % timeout)
391    try:
392        maxlen = int(maxlen)
393    except ValueError:
394        raise ValueError('Bad value for maxlen (must be int): %r'
395                         % maxlen)
396    return AuthCookieHandler(
397        app, cookie_name=cookie_name, scanlist=scanlist,
398        secret=secret, timeout=timeout, maxlen=maxlen)
399
400__all__ = ['AuthCookieHandler', 'AuthCookieSigner', 'AuthCookieEnviron']
401
402if "__main__" == __name__:
403    import doctest
404    doctest.testmod(optionflags=doctest.ELLIPSIS)
405
406