From 2ed246518c3c7eec54d03532a8ececbfcc2a2ad4 Mon Sep 17 00:00:00 2001 From: AZLisme Date: Fri, 11 Aug 2017 13:03:30 +0800 Subject: [PATCH] add build in session --- requirements.txt | 1 + sanic/app.py | 7 +- sanic/config.py | 4 + sanic/exceptions.py | 4 + sanic/request.py | 15 +- sanic/sessions.py | 312 +++++++++++++++++++++++++++++++++++++++++ tests/test_sessions.py | 49 +++++++ 7 files changed, 389 insertions(+), 3 deletions(-) create mode 100644 sanic/sessions.py create mode 100644 tests/test_sessions.py diff --git a/requirements.txt b/requirements.txt index e370b52f..ef14ff93 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,4 @@ httptools ujson uvloop websockets +itsdangerous \ No newline at end of file diff --git a/sanic/app.py b/sanic/app.py index d4ee8275..a81acc37 100644 --- a/sanic/app.py +++ b/sanic/app.py @@ -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: @@ -388,8 +390,8 @@ class Sanic: if not uri or not route: raise URLBuildError( - 'Endpoint with name `{}` was not found'.format( - view_name)) + 'Endpoint with name `{}` was not found'.format( + view_name)) if uri != '/' and uri.endswith('/'): uri = uri[:-1] @@ -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) diff --git a/sanic/config.py b/sanic/config.py index 6ffcf7a1..14cccfc4 100644 --- a/sanic/config.py +++ b/sanic/config.py @@ -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() diff --git a/sanic/exceptions.py b/sanic/exceptions.py index 0edb0562..ef095613 100644 --- a/sanic/exceptions.py +++ b/sanic/exceptions.py @@ -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 diff --git a/sanic/request.py b/sanic/request.py index 27ff011e..41f46cec 100644 --- a/sanic/request.py +++ b/sanic/request.py @@ -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: diff --git a/sanic/sessions.py b/sanic/sessions.py new file mode 100644 index 00000000..6aab304d --- /dev/null +++ b/sanic/sessions.py @@ -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 diff --git a/tests/test_sessions.py b/tests/test_sessions.py new file mode 100644 index 00000000..75f65b9b --- /dev/null +++ b/tests/test_sessions.py @@ -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"