413 lines
16 KiB
Python
413 lines
16 KiB
Python
# (c) 2005 Clark C. Evans
|
|
# This module is part of the Python Paste Project and is released under
|
|
# the MIT License: http://www.opensource.org/licenses/mit-license.php
|
|
# This code was written with funding by http://prometheusresearch.com
|
|
"""
|
|
Cookie "Saved" Authentication
|
|
|
|
This authentication middleware saves the current REMOTE_USER,
|
|
REMOTE_SESSION, and any other environment variables specified in a
|
|
cookie so that it can be retrieved during the next request without
|
|
requiring re-authentication. This uses a session cookie on the client
|
|
side (so it goes away when the user closes their window) and does
|
|
server-side expiration.
|
|
|
|
Following is a very simple example where a form is presented asking for
|
|
a user name (no actual checking), and dummy session identifier (perhaps
|
|
corresponding to a database session id) is stored in the cookie.
|
|
|
|
::
|
|
|
|
>>> from paste.httpserver import serve
|
|
>>> from paste.fileapp import DataApp
|
|
>>> from paste.httpexceptions import *
|
|
>>> from paste.auth.cookie import AuthCookieHandler
|
|
>>> from paste.wsgilib import parse_querystring
|
|
>>> def testapp(environ, start_response):
|
|
... user = dict(parse_querystring(environ)).get('user','')
|
|
... if user:
|
|
... environ['REMOTE_USER'] = user
|
|
... environ['REMOTE_SESSION'] = 'a-session-id'
|
|
... if environ.get('REMOTE_USER'):
|
|
... page = '<html><body>Welcome %s (%s)</body></html>'
|
|
... page %= (environ['REMOTE_USER'], environ['REMOTE_SESSION'])
|
|
... else:
|
|
... page = ('<html><body><form><input name="user" />'
|
|
... '<input type="submit" /></form></body></html>')
|
|
... return DataApp(page, content_type="text/html")(
|
|
... environ, start_response)
|
|
>>> serve(AuthCookieHandler(testapp))
|
|
serving on...
|
|
|
|
"""
|
|
|
|
import hmac, base64, random, six, time, warnings
|
|
try:
|
|
from hashlib import sha1
|
|
except ImportError:
|
|
# NOTE: We have to use the callable with hashlib (hashlib.sha1),
|
|
# otherwise hmac only accepts the sha module object itself
|
|
import sha as sha1
|
|
from paste.request import get_cookies
|
|
|
|
def make_time(value):
|
|
return time.strftime("%Y%m%d%H%M", time.gmtime(value))
|
|
_signature_size = len(hmac.new(b'x', b'x', sha1).digest())
|
|
_header_size = _signature_size + len(make_time(time.time()))
|
|
|
|
# @@: Should this be using urllib.quote?
|
|
# build encode/decode functions to safely pack away values
|
|
_encode = [('\\', '\\x5c'), ('"', '\\x22'),
|
|
('=', '\\x3d'), (';', '\\x3b')]
|
|
_decode = [(v, k) for (k, v) in _encode]
|
|
_decode.reverse()
|
|
def encode(s, sublist = _encode):
|
|
return six.moves.reduce((lambda a, b: a.replace(b[0], b[1])), sublist, str(s))
|
|
decode = lambda s: encode(s, _decode)
|
|
|
|
class CookieTooLarge(RuntimeError):
|
|
def __init__(self, content, cookie):
|
|
RuntimeError.__init__("Signed cookie exceeds maximum size of 4096")
|
|
self.content = content
|
|
self.cookie = cookie
|
|
|
|
_all_chars = ''.join([chr(x) for x in range(0, 255)])
|
|
def new_secret():
|
|
""" returns a 64 byte secret """
|
|
secret = ''.join(random.sample(_all_chars, 64))
|
|
if six.PY3:
|
|
secret = secret.encode('utf8')
|
|
return secret
|
|
|
|
class AuthCookieSigner(object):
|
|
"""
|
|
save/restore ``environ`` entries via digially signed cookie
|
|
|
|
This class converts content into a timed and digitally signed
|
|
cookie, as well as having the facility to reverse this procedure.
|
|
If the cookie, after the content is encoded and signed exceeds the
|
|
maximum length (4096), then CookieTooLarge exception is raised.
|
|
|
|
The timeout of the cookie is handled on the server side for a few
|
|
reasons. First, if a 'Expires' directive is added to a cookie, then
|
|
the cookie becomes persistent (lasting even after the browser window
|
|
has closed). Second, the user's clock may be wrong (perhaps
|
|
intentionally). The timeout is specified in minutes; and expiration
|
|
date returned is rounded to one second.
|
|
|
|
Constructor Arguments:
|
|
|
|
``secret``
|
|
|
|
This is a secret key if you want to syncronize your keys so
|
|
that the cookie will be good across a cluster of computers.
|
|
It is recommended via the HMAC specification (RFC 2104) that
|
|
the secret key be 64 bytes since this is the block size of
|
|
the hashing. If you do not provide a secret key, a random
|
|
one is generated each time you create the handler; this
|
|
should be sufficient for most cases.
|
|
|
|
``timeout``
|
|
|
|
This is the time (in minutes) from which the cookie is set
|
|
to expire. Note that on each request a new (replacement)
|
|
cookie is sent, hence this is effectively a session timeout
|
|
parameter for your entire cluster. If you do not provide a
|
|
timeout, it is set at 30 minutes.
|
|
|
|
``maxlen``
|
|
|
|
This is the maximum size of the *signed* cookie; hence the
|
|
actual content signed will be somewhat less. If the cookie
|
|
goes over this size, a ``CookieTooLarge`` exception is
|
|
raised so that unexpected handling of cookies on the client
|
|
side are avoided. By default this is set at 4k (4096 bytes),
|
|
which is the standard cookie size limit.
|
|
|
|
"""
|
|
def __init__(self, secret = None, timeout = None, maxlen = None):
|
|
self.timeout = timeout or 30
|
|
if isinstance(timeout, six.string_types):
|
|
raise ValueError(
|
|
"Timeout must be a number (minutes), not a string (%r)"
|
|
% timeout)
|
|
self.maxlen = maxlen or 4096
|
|
self.secret = secret or new_secret()
|
|
|
|
def sign(self, content):
|
|
"""
|
|
Sign the content returning a valid cookie (that does not
|
|
need to be escaped and quoted). The expiration of this
|
|
cookie is handled server-side in the auth() function.
|
|
"""
|
|
timestamp = make_time(time.time() + 60*self.timeout)
|
|
if six.PY3:
|
|
content = content.encode('utf8')
|
|
timestamp = timestamp.encode('utf8')
|
|
|
|
if six.PY3:
|
|
cookie = base64.encodebytes(
|
|
hmac.new(self.secret, content, sha1).digest() +
|
|
timestamp +
|
|
content)
|
|
else:
|
|
cookie = base64.encodestring(
|
|
hmac.new(self.secret, content, sha1).digest() +
|
|
timestamp +
|
|
content)
|
|
cookie = cookie.replace(b"/", b"_").replace(b"=", b"~")
|
|
cookie = cookie.replace(b'\n', b'').replace(b'\r', b'')
|
|
if len(cookie) > self.maxlen:
|
|
raise CookieTooLarge(content, cookie)
|
|
return cookie
|
|
|
|
def auth(self, cookie):
|
|
"""
|
|
Authenticate the cooke using the signature, verify that it
|
|
has not expired; and return the cookie's content
|
|
"""
|
|
decode = base64.decodestring(
|
|
cookie.replace("_", "/").replace("~", "="))
|
|
signature = decode[:_signature_size]
|
|
expires = decode[_signature_size:_header_size]
|
|
content = decode[_header_size:]
|
|
if signature == hmac.new(self.secret, content, sha1).digest():
|
|
if int(expires) > int(make_time(time.time())):
|
|
return content
|
|
else:
|
|
# This is the normal case of an expired cookie; just
|
|
# don't bother doing anything here.
|
|
pass
|
|
else:
|
|
# This case can happen if the server is restarted with a
|
|
# different secret; or if the user's IP address changed
|
|
# due to a proxy. However, it could also be a break-in
|
|
# attempt -- so should it be reported?
|
|
pass
|
|
|
|
class AuthCookieEnviron(list):
|
|
"""
|
|
a list of environment keys to be saved via cookie
|
|
|
|
An instance of this object, found at ``environ['paste.auth.cookie']``
|
|
lists the `environ` keys that were restored from or will be added
|
|
to the digially signed cookie. This object can be accessed from an
|
|
`environ` variable by using this module's name.
|
|
"""
|
|
def __init__(self, handler, scanlist):
|
|
list.__init__(self, scanlist)
|
|
self.handler = handler
|
|
def append(self, value):
|
|
if value in self:
|
|
return
|
|
list.append(self, str(value))
|
|
|
|
class AuthCookieHandler(object):
|
|
"""
|
|
the actual handler that should be put in your middleware stack
|
|
|
|
This middleware uses cookies to stash-away a previously authenticated
|
|
user (and perhaps other variables) so that re-authentication is not
|
|
needed. This does not implement sessions; and therefore N servers
|
|
can be syncronized to accept the same saved authentication if they
|
|
all use the same cookie_name and secret.
|
|
|
|
By default, this handler scans the `environ` for the REMOTE_USER
|
|
and REMOTE_SESSION key; if found, it is stored. It can be
|
|
configured to scan other `environ` keys as well -- but be careful
|
|
not to exceed 2-3k (so that the encoded and signed cookie does not
|
|
exceed 4k). You can ask it to handle other environment variables
|
|
by doing:
|
|
|
|
``environ['paste.auth.cookie'].append('your.environ.variable')``
|
|
|
|
|
|
Constructor Arguments:
|
|
|
|
``application``
|
|
|
|
This is the wrapped application which will have access to
|
|
the ``environ['REMOTE_USER']`` restored by this middleware.
|
|
|
|
``cookie_name``
|
|
|
|
The name of the cookie used to store this content, by default
|
|
it is ``PASTE_AUTH_COOKIE``.
|
|
|
|
``scanlist``
|
|
|
|
This is the initial set of ``environ`` keys to
|
|
save/restore to the signed cookie. By default is consists
|
|
only of ``REMOTE_USER`` and ``REMOTE_SESSION``; any tuple
|
|
or list of environment keys will work. However, be
|
|
careful, as the total saved size is limited to around 3k.
|
|
|
|
``signer``
|
|
|
|
This is the signer object used to create the actual cookie
|
|
values, by default, it is ``AuthCookieSigner`` and is passed
|
|
the remaining arguments to this function: ``secret``,
|
|
``timeout``, and ``maxlen``.
|
|
|
|
At this time, each cookie is individually signed. To store more
|
|
than the 4k of data; it is possible to sub-class this object to
|
|
provide different ``environ_name`` and ``cookie_name``
|
|
"""
|
|
environ_name = 'paste.auth.cookie'
|
|
cookie_name = 'PASTE_AUTH_COOKIE'
|
|
signer_class = AuthCookieSigner
|
|
environ_class = AuthCookieEnviron
|
|
|
|
def __init__(self, application, cookie_name=None, scanlist=None,
|
|
signer=None, secret=None, timeout=None, maxlen=None):
|
|
if not signer:
|
|
signer = self.signer_class(secret, timeout, maxlen)
|
|
self.signer = signer
|
|
self.scanlist = scanlist or ('REMOTE_USER','REMOTE_SESSION')
|
|
self.application = application
|
|
self.cookie_name = cookie_name or self.cookie_name
|
|
|
|
def __call__(self, environ, start_response):
|
|
if self.environ_name in environ:
|
|
raise AssertionError("AuthCookie already installed!")
|
|
scanlist = self.environ_class(self, self.scanlist)
|
|
jar = get_cookies(environ)
|
|
if self.cookie_name in jar:
|
|
content = self.signer.auth(jar[self.cookie_name].value)
|
|
if content:
|
|
for pair in content.split(";"):
|
|
(k, v) = pair.split("=")
|
|
k = decode(k)
|
|
if k not in scanlist:
|
|
scanlist.append(k)
|
|
if k in environ:
|
|
continue
|
|
environ[k] = decode(v)
|
|
if 'REMOTE_USER' == k:
|
|
environ['AUTH_TYPE'] = 'cookie'
|
|
environ[self.environ_name] = scanlist
|
|
if "paste.httpexceptions" in environ:
|
|
warnings.warn("Since paste.httpexceptions is hooked in your "
|
|
"processing chain before paste.auth.cookie, if an "
|
|
"HTTPRedirection is raised, the cookies this module sets "
|
|
"will not be included in your response.\n")
|
|
|
|
def response_hook(status, response_headers, exc_info=None):
|
|
"""
|
|
Scan the environment for keys specified in the scanlist,
|
|
pack up their values, signs the content and issues a cookie.
|
|
"""
|
|
scanlist = environ.get(self.environ_name)
|
|
assert scanlist and isinstance(scanlist, self.environ_class)
|
|
content = []
|
|
for k in scanlist:
|
|
v = environ.get(k)
|
|
if v is not None:
|
|
if type(v) is not str:
|
|
raise ValueError(
|
|
"The value of the environmental variable %r "
|
|
"is not a str (only str is allowed; got %r)"
|
|
% (k, v))
|
|
content.append("%s=%s" % (encode(k), encode(v)))
|
|
if content:
|
|
content = ";".join(content)
|
|
content = self.signer.sign(content)
|
|
if six.PY3:
|
|
content = content.decode('utf8')
|
|
cookie = '%s=%s; Path=/;' % (self.cookie_name, content)
|
|
if 'https' == environ['wsgi.url_scheme']:
|
|
cookie += ' secure;'
|
|
response_headers.append(('Set-Cookie', cookie))
|
|
return start_response(status, response_headers, exc_info)
|
|
return self.application(environ, response_hook)
|
|
|
|
middleware = AuthCookieHandler
|
|
|
|
# Paste Deploy entry point:
|
|
def make_auth_cookie(
|
|
app, global_conf,
|
|
# Should this get picked up from global_conf somehow?:
|
|
cookie_name='PASTE_AUTH_COOKIE',
|
|
scanlist=('REMOTE_USER', 'REMOTE_SESSION'),
|
|
# signer cannot be set
|
|
secret=None,
|
|
timeout=30,
|
|
maxlen=4096):
|
|
"""
|
|
This middleware uses cookies to stash-away a previously
|
|
authenticated user (and perhaps other variables) so that
|
|
re-authentication is not needed. This does not implement
|
|
sessions; and therefore N servers can be syncronized to accept the
|
|
same saved authentication if they all use the same cookie_name and
|
|
secret.
|
|
|
|
By default, this handler scans the `environ` for the REMOTE_USER
|
|
and REMOTE_SESSION key; if found, it is stored. It can be
|
|
configured to scan other `environ` keys as well -- but be careful
|
|
not to exceed 2-3k (so that the encoded and signed cookie does not
|
|
exceed 4k). You can ask it to handle other environment variables
|
|
by doing:
|
|
|
|
``environ['paste.auth.cookie'].append('your.environ.variable')``
|
|
|
|
Configuration:
|
|
|
|
``cookie_name``
|
|
|
|
The name of the cookie used to store this content, by
|
|
default it is ``PASTE_AUTH_COOKIE``.
|
|
|
|
``scanlist``
|
|
|
|
This is the initial set of ``environ`` keys to
|
|
save/restore to the signed cookie. By default is consists
|
|
only of ``REMOTE_USER`` and ``REMOTE_SESSION``; any
|
|
space-separated list of environment keys will work.
|
|
However, be careful, as the total saved size is limited to
|
|
around 3k.
|
|
|
|
``secret``
|
|
|
|
The secret that will be used to sign the cookies. If you
|
|
don't provide one (and none is set globally) then a random
|
|
secret will be created. Each time the server is restarted
|
|
a new secret will then be created and all cookies will
|
|
become invalid! This can be any string value.
|
|
|
|
``timeout``
|
|
|
|
The time to keep the cookie, expressed in minutes. This
|
|
is handled server-side, so a new cookie with a new timeout
|
|
is added to every response.
|
|
|
|
``maxlen``
|
|
|
|
The maximum length of the cookie that is sent (default 4k,
|
|
which is a typical browser maximum)
|
|
|
|
"""
|
|
if isinstance(scanlist, six.string_types):
|
|
scanlist = scanlist.split()
|
|
if secret is None and global_conf.get('secret'):
|
|
secret = global_conf['secret']
|
|
try:
|
|
timeout = int(timeout)
|
|
except ValueError:
|
|
raise ValueError('Bad value for timeout (must be int): %r'
|
|
% timeout)
|
|
try:
|
|
maxlen = int(maxlen)
|
|
except ValueError:
|
|
raise ValueError('Bad value for maxlen (must be int): %r'
|
|
% maxlen)
|
|
return AuthCookieHandler(
|
|
app, cookie_name=cookie_name, scanlist=scanlist,
|
|
secret=secret, timeout=timeout, maxlen=maxlen)
|
|
|
|
__all__ = ['AuthCookieHandler', 'AuthCookieSigner', 'AuthCookieEnviron']
|
|
|
|
if "__main__" == __name__:
|
|
import doctest
|
|
doctest.testmod(optionflags=doctest.ELLIPSIS)
|
|
|