add build in session
This commit is contained in:
parent
df4a149cd0
commit
2ed246518c
|
@ -3,3 +3,4 @@ httptools
|
|||
ujson
|
||||
uvloop
|
||||
websockets
|
||||
itsdangerous
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
312
sanic/sessions.py
Normal 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
49
tests/test_sessions.py
Normal 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"
|
Loading…
Reference in New Issue
Block a user