add build in session

This commit is contained in:
AZLisme 2017-08-11 13:03:30 +08:00
parent df4a149cd0
commit 2ed246518c
7 changed files with 389 additions and 3 deletions

View File

@ -3,3 +3,4 @@ httptools
ujson
uvloop
websockets
itsdangerous

View File

@ -18,11 +18,13 @@ from sanic.log import log
from sanic.response import HTTPResponse, StreamingHTTPResponse
from sanic.router import Router
from sanic.server import serve, serve_multiple, HttpProtocol, Signal
from sanic.sessions import SecureCookieSessionInterface
from sanic.static import register as static_register
from sanic.testing import SanicTestClient
from sanic.views import CompositionView
from sanic.websocket import WebSocketProtocol, ConnectionClosed
session_interface = SecureCookieSessionInterface()
class Sanic:
@ -525,6 +527,7 @@ class Sanic:
# -------------------------------------------- #
# Response Middleware
# -------------------------------------------- #
session_interface.save_session(self, request.session, response)
try:
response = await self._run_response_middleware(request,
response)

View File

@ -3,6 +3,7 @@ import sys
import syslog
import platform
import types
from datetime import timedelta
from sanic.log import DefaultFilter
@ -129,6 +130,9 @@ class Config(dict):
self.WEBSOCKET_MAX_SIZE = 2 ** 20 # 1 megabytes
self.WEBSOCKET_MAX_QUEUE = 32
self.GRACEFUL_SHUTDOWN_TIMEOUT = 15.0 # 15 sec
self.SECRET_KEY = "secret_key"
self.SESSION_COOKIE_NAME = "session"
self.PERMANENT_SESSION_LIFETIME = timedelta(days=31)
if load_env:
self.load_environment_vars()

View File

@ -244,6 +244,10 @@ class Unauthorized(SanicException):
}
class BadSignature(SanicException):
pass
def abort(status_code, message=None):
"""
Raise an exception based on SanicException. Returns the HTTP response

View File

@ -18,7 +18,9 @@ except ImportError:
from sanic.exceptions import InvalidUsage
from sanic.log import log
from sanic.sessions import SecureCookieSessionInterface
session_interface = SecureCookieSessionInterface()
DEFAULT_HTTP_CONTENT_TYPE = "application/octet-stream"
# HTTP/1.1: https://www.w3.org/Protocols/rfc2616/rfc2616-sec7.html#sec7.2.1
@ -45,13 +47,15 @@ class Request(dict):
__slots__ = (
'app', 'headers', 'version', 'method', '_cookies', 'transport',
'body', 'parsed_json', 'parsed_args', 'parsed_form', 'parsed_files',
'_ip', '_parsed_url', 'uri_template', 'stream', '_remote_addr'
'_ip', '_parsed_url', 'uri_template', 'stream', '_remote_addr',
'_session'
)
def __init__(self, url_bytes, headers, version, method, transport):
# TODO: Content-Encoding detection
self._parsed_url = parse_url(url_bytes)
self.app = None
self._session = None
self.headers = headers
self.version = version
@ -68,6 +72,15 @@ class Request(dict):
self._cookies = None
self.stream = None
@property
def session(self):
if self._session is None:
if self.app is None:
# TODO: 找到更合适的错误
raise RuntimeError("fetch session before app is set")
self._session = session_interface.open_session(self.app, self)
return self._session
@property
def json(self):
if self.parsed_json is None:

312
sanic/sessions.py Normal file
View File

@ -0,0 +1,312 @@
# -*- encoding: utf-8 -*-
import hashlib
from itsdangerous import URLSafeTimedSerializer
import ujson as json
from sanic.exceptions import BadSignature
def total_seconds(td):
"""Returns the total seconds from a timedelta object.
:param timedelta td: the timedelta to be converted in seconds
:returns: number of seconds
:rtype: int
"""
return td.days * 60 * 60 * 24 + td.seconds
class SessionMixin(object):
"""Expands a basic dictionary with an accessors that are expected
by Flask extensions and users for the session.
"""
def _get_permanent(self):
return self.get('_permanent', False)
def _set_permanent(self, value):
self['_permanent'] = bool(value)
#: this reflects the ``'_permanent'`` key in the dict.
permanent = property(_get_permanent, _set_permanent)
del _get_permanent, _set_permanent
#: some session backends can tell you if a session is new, but that is
#: not necessarily guaranteed. Use with caution. The default mixin
#: implementation just hardcodes ``False`` in.
new = False
#: for some backends this will always be ``True``, but some backends will
#: default this to false and detect changes in the dictionary for as
#: long as changes do not happen on mutable structures in the session.
#: The default mixin implementation just hardcodes ``True`` in.
modified = True
#: the accessed variable indicates whether or not the session object has
#: been accessed in that request. This allows flask to append a `Vary:
#: Cookie` header to the response if the session is being accessed. This
#: allows caching proxy servers, like Varnish, to use both the URL and the
#: session cookie as keys when caching pages, preventing multiple users
#: from being served the same cache.
accessed = True
class SecureCookieSession(dict, SessionMixin):
"""Base class for sessions based on signed cookies."""
def __init__(self, initial=None):
if initial is None:
super(SecureCookieSession, self).__init__()
else:
super(SecureCookieSession, self).__init__(initial)
self.modified = False
self.accessed = False
def __getitem__(self, key):
self.accessed = True
return super(SecureCookieSession, self).__getitem__(key)
def __setitem__(self, key, value):
self.modified = True
return super(SecureCookieSession, self).__setitem__(key, value)
def get(self, key, default=None):
self.accessed = True
return super(SecureCookieSession, self).get(key, default)
def setdefault(self, key, default=None):
self.accessed = True
return super(SecureCookieSession, self).setdefault(key, default)
class NullSession(SecureCookieSession):
"""Class used to generate nicer error messages if sessions are not
available. Will still allow read-only access to the empty session
but fail on setting.
"""
def _fail(self, *args, **kwargs):
raise RuntimeError('The session is unavailable because no secret '
'key was set. Set the secret_key on the '
'application to something unique and secret.')
__setitem__ = __delitem__ = clear = pop = popitem = \
update = setdefault = _fail
del _fail
class SessionInterface(object):
null_session_class = NullSession
pickle_based = False
def make_null_session(self, app):
"""Creates a null session which acts as a replacement object if the
real session support could not be loaded due to a configuration
error. This mainly aids the user experience because the job of the
null session is to still support lookup without complaining but
modifications are answered with a helpful error message of what
failed.
This creates an instance of :attr:`null_session_class` by default.
"""
return self.null_session_class()
def is_null_session(self, obj):
"""Checks if a given object is a null session. Null sessions are
not asked to be saved.
This checks if the object is an instance of :attr:`null_session_class`
by default.
"""
return isinstance(obj, self.null_session_class)
def get_cookie_domain(self, app):
"""Returns the domain that should be set for the session cookie.
Uses ``SESSION_COOKIE_DOMAIN`` if it is configured, otherwise
falls back to detecting the domain based on ``SERVER_NAME``.
Once detected (or if not set at all), ``SESSION_COOKIE_DOMAIN`` is
updated to avoid re-running the logic.
"""
rv = app.config.get('SESSION_COOKIE_DOMAIN')
# set explicitly, or cached from SERVER_NAME detection
# if False, return None
if rv is not None:
return rv if rv else None
rv = app.config.get('SERVER_NAME')
# server name not set, cache False to return none next time
if not rv:
app.config['SESSION_COOKIE_DOMAIN'] = False
return None
# chop off the port which is usually not supported by browsers
# remove any leading '.' since we'll add that later
rv = rv.rsplit(':', 1)[0].lstrip('.')
if '.' not in rv:
# Chrome doesn't allow names without a '.'
# this should only come up with localhost
# hack around this by not setting the name, and show a warning
warnings.warn(
'"{rv}" is not a valid cookie domain, it must contain a ".".'
' Add an entry to your hosts file, for example'
' "{rv}.localdomain", and use that instead.'.format(rv=rv)
)
app.config['SESSION_COOKIE_DOMAIN'] = False
return None
# ip = is_ip(rv)
# if ip:
# warnings.warn(
# 'The session cookie domain is an IP address. This may not work'
# ' as intended in some browsers. Add an entry to your hosts'
# ' file, for example "localhost.localdomain", and use that'
# ' instead.'
# )
# if this is not an ip and app is mounted at the root, allow subdomain
# matching by adding a '.' prefix
if self.get_cookie_path(app) == '/' and not ip:
rv = '.' + rv
app.config['SESSION_COOKIE_DOMAIN'] = rv
return rv
def get_cookie_path(self, app):
"""Returns the path for which the cookie should be valid. The
default implementation uses the value from the ``SESSION_COOKIE_PATH``
config var if it's set, and falls back to ``APPLICATION_ROOT`` or
uses ``/`` if it's ``None``.
"""
return app.config.get('SESSION_COOKIE_PATH') \
or app.config.get('APPLICATION_ROOT', '/')
def get_cookie_httponly(self, app):
"""Returns True if the session cookie should be httponly. This
currently just returns the value of the ``SESSION_COOKIE_HTTPONLY``
config var.
"""
return app.config.get('SESSION_COOKIE_HTTPONLY', True)
def get_cookie_secure(self, app):
"""Returns True if the cookie should be secure. This currently
just returns the value of the ``SESSION_COOKIE_SECURE`` setting.
"""
return app.config.get('SESSION_COOKIE_SECURE', True)
def get_expiration_time(self, app, session):
"""A helper method that returns an expiration date for the session
or ``None`` if the session is linked to the browser session. The
default implementation returns now + the permanent session
lifetime configured on the application.
"""
if session.permanent:
return datetime.utcnow() + app.config.PERMANENT_SESSION_LIFETIME
def should_set_cookie(self, app, session):
"""Used by session backends to determine if a ``Set-Cookie`` header
should be set for this session cookie for this response. If the session
has been modified, the cookie is set. If the session is permanent and
the ``SESSION_REFRESH_EACH_REQUEST`` config is true, the cookie is
always set.
This check is usually skipped if the session was deleted.
.. versionadded:: 0.11
"""
return session.modified or (
session.permanent and app.config.get('SESSION_REFRESH_EACH_REQUEST', False)
)
def open_session(self, app, request):
"""This method has to be implemented and must either return ``None``
in case the loading failed because of a configuration error or an
instance of a session object which implements a dictionary like
interface + the methods and attributes on :class:`SessionMixin`.
"""
raise NotImplementedError()
def save_session(self, app, session, response):
"""This is called for actual sessions returned by :meth:`open_session`
at the end of the request. This is still called during a request
context so if you absolutely need access to the request you can do
that.
"""
raise NotImplementedError()
session_json_serializer = json
class SecureCookieSessionInterface(SessionInterface):
"""The default session interface that stores sessions in signed cookies
through the :mod:`itsdangerous` module.
"""
#: the salt that should be applied on top of the secret key for the
#: signing of cookie based sessions.
salt = 'cookie-session'
#: the hash function to use for the signature. The default is sha1
digest_method = staticmethod(hashlib.sha1)
#: the name of the itsdangerous supported key derivation. The default
#: is hmac.
key_derivation = 'hmac'
#: A python serializer for the payload. The default is a compact
#: JSON derived serializer with support for some extra Python types
#: such as datetime objects or tuples.
serializer = session_json_serializer
session_class = SecureCookieSession
def get_signing_serializer(self, app):
if not app.config.SECRET_KEY:
return None
signer_kwargs = dict(
key_derivation=self.key_derivation,
digest_method=self.digest_method
)
return URLSafeTimedSerializer(app.config.SECRET_KEY, salt=self.salt,
serializer=self.serializer,
signer_kwargs=signer_kwargs)
def open_session(self, app, request):
s = self.get_signing_serializer(app)
if s is None:
return None
val = request.cookies.get(app.config.SESSION_COOKIE_NAME)
if not val:
return self.session_class()
max_age = total_seconds(app.config.PERMANENT_SESSION_LIFETIME)
try:
data = s.loads(val, max_age=max_age)
return self.session_class(data)
except BadSignature:
return self.session_class()
def save_session(self, app, session, response):
domain = self.get_cookie_domain(app)
path = self.get_cookie_path(app)
# If the session is modified to be empty, remove the cookie.
# If the session is empty, return without setting the cookie.
if not session:
if session.modified:
response.delete_cookie(
app.config.SESSION_COOKIE_NAME,
domain=domain,
path=path
)
return
if not self.should_set_cookie(app, session):
return
httponly = self.get_cookie_httponly(app)
secure = self.get_cookie_secure(app)
expires = self.get_expiration_time(app, session)
val = self.get_signing_serializer(app).dumps(dict(session))
response.cookies[app.config.SESSION_COOKIE_NAME] = val
# response.cookies[app.config.SESSION_COOKIE_NAME]["expires"] = expires
# response.cookies[app.config.SESSION_COOKIE_NAME]["httponly"] = httponly
# response.cookies[app.config.SESSION_COOKIE_NAME]["domain"] = domain
# response.cookies[app.config.SESSION_COOKIE_NAME]["path"] = path
# response.cookies[app.config.SESSION_COOKIE_NAME]["secure"] = secure

49
tests/test_sessions.py Normal file
View File

@ -0,0 +1,49 @@
# -*- encoding: utf-8 -*-
from sanic.sessions import SecureCookieSessionInterface
from sanic import Sanic
from sanic.views import HTTPMethodView
from sanic.response import text
from sanic.request import Request
from http.cookies import SimpleCookie
def test_sessions():
app = Sanic("test_sessions")
class DummyView(HTTPMethodView):
async def get(self, request):
"""method to check session value"""
value = request.session.get('value')
if value is None:
return text("No Value")
else:
return text("Value is: {}".format(value))
async def post(self, request: Request):
"""method to set session value"""
value = request.args.get('value')
if value is None:
return text("no args value")
else:
request.session['value'] = value
return text("set value to {}".format(value))
async def delete(self, request: Request):
"""method to delete session value"""
del request.session['value']
return text('deleled value.')
app.add_route(DummyView.as_view(), '/')
client = app.test_client
_, response = client.get('/')
assert response.text == "No Value"
_, response = client.post('/?value=123')
# TODO: TestClient not support cookie, need fix.
header = response.headers.get('Set-Cookie')
response_cookies = SimpleCookie()
response_cookies.load(header)
assert response.text == "set value to 123"
assert 'session' in response_cookies
_, response = client.get(cookies=response_cookies)
assert response.text == "Value is: 123"