View

Large diffs are not rendered by default.

Oops, something went wrong.
View
@@ -37,10 +37,17 @@
"Werkzeug",
"zope.interface",
],
extras_require={
"sql": [
"alchimia",
"passlib",
"bcrypt",
]
},
keywords="twisted flask werkzeug web",
license="MIT",
name="klein",
packages=["klein", "klein.test"],
packages=["klein", "klein.storage", "klein.test"],
package_dir={"": "src"},
package_data=dict(
klein=[
View
@@ -1,13 +1,22 @@
from __future__ import absolute_import, division
from ._app import Klein, handle_errors, resource, route, run, urlFor, url_for
from ._form import Field, Form, RenderableForm
from ._plating import Plating
from ._requirer import Requirer
from ._session import Authorization, SessionProcurer
from ._version import __version__ as _incremental_version
__all__ = (
"Klein",
"Plating",
'Field',
'Form',
'RenderableForm',
'SessionProcurer',
'Authorization',
'Requirer',
"__author__",
"__copyright__",
"__license__",
View
@@ -36,10 +36,12 @@ def ensureDeferred(*args, **kwagrs):
def _call(instance, f, *args, **kwargs):
if instance is not None or getattr(f, "__klein_bound__", False):
args = (instance,) + args
result = f(*args, **kwargs)
def _call(__klein_instance__, __klein_f__, *args, **kwargs):
if __klein_instance__ is not None or getattr(
__klein_f__, "__klein_bound__", False
):
args = (__klein_instance__,) + args
result = __klein_f__(*args, **kwargs)
if iscoroutine(result):
result = ensureDeferred(result)
return result
View

Large diffs are not rendered by default.

Oops, something went wrong.
View
@@ -23,7 +23,6 @@
String = Union[bytes, Text]
# Encoding/decoding header data
HEADER_NAME_ENCODING = "iso-8859-1"
View
@@ -0,0 +1,374 @@
from typing import Any, TYPE_CHECKING
import attr
from constantly import NamedConstant, Names
from zope.interface import Attribute, Interface
from ._typing import ifmethod
if TYPE_CHECKING:
from twisted.internet.defer import Deferred
from twisted.python.components import Componentized
from typing import Dict, Iterable, List, Text, Type, Sequence
from twisted.web.iweb import IRequest
from zope.interface.interfaces import IInterface
from ..interfaces import IRequestLifecycle as _FwdLifecycle
Deferred, Text, Componentized, Sequence, IRequest, List, Type
Iterable, IInterface, _FwdLifecycle, Dict
class NoSuchSession(Exception):
"""
No such session could be found.
"""
class TooLateForCookies(Exception):
"""
It's too late to set a cookie.
"""
class TransactionEnded(Exception):
"""
Exception raised when.
"""
class ISessionStore(Interface):
"""
Backing storage for sessions.
"""
@ifmethod
def newSession(isConfidential, authenticatedBy):
# type: (bool, SessionMechanism) -> Deferred
"""
Create a new L{ISession}.
@return: a new session with a new identifier.
@rtype: L{Deferred} firing with L{ISession}.
"""
@ifmethod
def loadSession(identifier, isConfidential, authenticatedBy):
# type: (Text, bool, SessionMechanism) -> Deferred
"""
Load a session given the given identifier and security properties.
As an optimization for session stores where the back-end can generate
session identifiers when the presented one is not found in the same
round-trip to a data store, this method may return a L{Session} object
with an C{identifier} attribute that does not match C{identifier}.
However, please keep in mind when implementing L{ISessionStore} that
this behavior is only necessary for requests where C{authenticatedBy}
is L{SessionMechanism.Cookie}; an unauthenticated
L{SessionMechanism.Header} session is from an API client and its
session should be valid already.
@return: an existing session with the given identifier.
@rtype: L{Deferred} firing with L{ISession} or failing with
L{NoSuchSession}.
"""
@ifmethod
def sentInsecurely(identifiers):
# type: (Sequence[Text]) -> None
"""
The transport layer has detected that the given identifiers have been
sent over an unauthenticated transport.
"""
class ISimpleAccountBinding(Interface):
"""
Data-store agnostic account / session binding manipulation API for "simple"
accounts - i.e. those using username, password, and email address as a
method to authenticate a user.
This goes into a user-authentication-capable L{ISession} object's C{data}
attribute as a component.
"""
@ifmethod
def bindIfCredentialsMatch(username, password):
# type: (Text, Text) -> None
"""
Attach the session this is a component of to an account with the given
username and password, if the given username and password correctly
authenticate a principal.
"""
@ifmethod
def boundAccounts():
# type: () -> Deferred
"""
Retrieve the accounts currently associated with the session this is a
component of.
@return: L{Deferred} firing with a L{list} of L{ISimpleAccount}.
"""
@ifmethod
def unbindThisSession():
# type: () -> None
"""
Disassociate the session this is a component of from any accounts it's
logged in to.
"""
@ifmethod
def createAccount(username, email, password):
# type: (Text, Text, Text) -> None
"""
Create a new account with the given username, email and password.
"""
class ISimpleAccount(Interface):
"""
Data-store agnostic account interface.
"""
username = Attribute(
"""
Unicode username.
"""
)
accountID = Attribute(
"""
Unicode account-ID.
"""
)
def bindSession(self, session):
# type: (ISession) -> None
"""
Bind the given session to this account; i.e. authorize the given
session to act on behalf of this account.
"""
def changePassword(self, newPassword):
# type: (Text) -> None
"""
Change the password of this account.
"""
class ISessionProcurer(Interface):
"""
An L{ISessionProcurer} wraps an L{ISessionStore} and can procure sessions
that store, given HTTP request objects.
"""
def procureSession(self, request, forceInsecure=False):
# type: (IRequest, bool, bool) -> Deferred
"""
Retrieve a session using whatever technique is necessary.
If the request already identifies an existing session in the store,
retrieve it. If not, create a new session and retrieve that.
@param request: The request to procure a session from.
@type request: L{twisted.web.server.Request}
@param forceInsecure: Even if the request was transmitted securely
(i.e. over HTTPS), retrieve the session that would be used by the
same browser if it were sending an insecure (i.e. over HTTP)
request; by default, this is False, and the session's security will
match that of the request.
@type forceInsecure: L{bool}
@raise TooLateForCookies: if the request bound to this procurer has
already sent the headers and therefore we can no longer set a
cookie, and we need to set a cookie.
@return: a new or loaded session from this the a L{Deferred} that fires
with an L{ISession} provider.
@rtype: L{Session}
"""
class SessionMechanism(Names):
"""
Mechanisms which can be used to identify and authenticate a session.
@cvar Cookie: The Cookie session mechanism involves looking up the session
identifier via an HTTP cookie. Session objects retrieved via this
mechanism may be vulnerable to U{CSRF attacks
<https://www.owasp.org/index.php/Cross-Site_Request_Forgery_(CSRF)>}
and therefore must have CSRF protections applied to them.
@cvar Header: The Header mechanism retrieves the session identifier via a
separate header such as C{"X-Auth-Token"}. Since a different-origin
site in a browser can easily send a form submission including cookies,
but I{can't} easily put stuff into other arbitrary headers, this does
not require additional protections.
"""
Cookie = NamedConstant()
Header = NamedConstant()
class ISession(Interface):
"""
An L{ISession} provider contains an identifier for the session, information
about how the session was negotiated with the client software, and
"""
identifier = Attribute(
"""
L{unicode} identifying a session.
This value should be:
1. I{unique} - no two sessions have the same identifier
2. I{unpredictable} - no one but the receipient of the session
should be able to guess what it is
3. I{opaque} - it should contain no interesting information
"""
)
isConfidential = Attribute(
"""
A L{bool} indicating whether this session mechanism transmitted over an
encrypted transport, i.e., HTTPS. If C{True}, this means that this
session can be used for sensitive information; otherwise, the
information contained in it should be considered to be available to
attackers.
"""
)
authenticatedBy = Attribute(
"""
A L{SessionMechanism} indicating what mechanism was used to
authenticate this session.
"""
)
@ifmethod
def authorize(interfaces):
# type: (Iterable[IInterface]) -> Deferred
"""
Retrieve other objects from this session.
This method is how you can retrieve application-specific objects from
the general-purpose session; define interfaces for each facet of
something accessible to a session, then pass it here and to the
L{ISessionStore} implementation you're using.
@param interfaces: A list of interfaces.
@type interfaces: L{iterable} of
L{zope.interface.interfaces.IInterface}
@return: all of the providers that could be retrieved from the session.
@rtype: L{Deferred} firing with L{dict} mapping
L{zope.interface.interfaces.IInterface} to providers of each
interface.
"""
class IDependencyInjector(Interface):
"""
An injector for a given dependency.
"""
@ifmethod
def injectValue(instance, request, routeParams):
# type: (Any, IRequest, Dict[str, Any]) -> Any
"""
Return a value to be injected into the parameter name specified by the
IRequiredParameter. This may return a Deferred, or an object, or an
object directly providing the relevant interface.
@param instance: The instance to which the Klein router processing this
request is bound.
@param request: The request being processed.
@param routeParams: A (read-only) copy of the the arguments passed to
the route by the layer below dependency injection (for example, URL
parameters).
"""
@ifmethod
def finalize():
# type: () -> None
"""
Finalize this injector before allowing the route to be created.
Finalization generally includes:
- attaching any hooks to the request lifecycle object that need to
be run before/after each request
- attaching any finalized component objects to the
injectionComponents originally passed along to the
IRequiredParameter that created this IDependencyInjector.
"""
class IRequiredParameter(Interface):
"""
A declaration that a given Python parameter is required to satisfy a given
dependency at request-handling time.
"""
@ifmethod
def registerInjector(injectionComponents, parameterName, lifecycle):
# type: (Componentized, str, _FwdLifecycle) -> IDependencyInjector
"""
Register the given injector at method-decoration time, informing it of
its Python parameter name.
@note: this happens at I{route definition} time, after all other
injectors have been registered by
L{IRequiredParameter.registerInjector}.
@param lifecycle: An L{IRequestLifecycle} provider which contains hooks
that will be run before and after each request. If this injector
has shared per-request dependencies that need to be executed before
or after the request is processed, this method should attach them
to those lists. These hooks are supplied here rather than relying
on C{injectValue} to run the requisite logic each time so that
DependencyInjectors may cooperate on logic that needs to be
duplicated, such as provisioning a session.
"""
class IRequestLifecycle(Interface):
"""
Interface for adding hooks to the phases of a request's lifecycle.
"""
@attr.s
class EarlyExit(Exception):
"""
An L{EarlyExit} may be raised by any of the code that runs in the
before-request dependency injection code path when using
L{klein.Requirer.require}.
@ivar alternateReturnValue: The return value which should instead be
supplied as the route's response.
@type alternateReturnValue: Any type that's acceptable to return from a
Klein route.
"""
alternateReturnValue = attr.ib(type=Any)
View
@@ -35,13 +35,14 @@ class MessageState(object):
"""
cachedBody = attrib(
type=Optional[bytes],
validator=optional(instance_of(bytes)), default=None, init=False
) # type: Optional[bytes]
)
fountExhausted = attrib(
type=bool,
validator=instance_of(bool), default=False, init=False
) # type: bool
)
def validateBody(instance, attribute, body):
View
@@ -7,7 +7,7 @@
from functools import partial
from json import dumps
from operator import setitem
from typing import Any, Tuple, cast
from typing import Any, Callable, Tuple, cast
import attr
@@ -18,7 +18,7 @@
from twisted.web.template import Element, TagLoader
from ._app import _call
from ._decorators import bindable, modified
from ._decorators import bindable, modified, originalName
# https://github.com/python/mypy/issues/224
ATOM_TYPES = (
@@ -125,7 +125,8 @@ class PlatedElement(Element):
renderers.
"""
def __init__(self, slot_data, preloaded, boundInstance, presentationSlots):
def __init__(self, slot_data, preloaded, boundInstance, presentationSlots,
renderers):
"""
@param slot_data: A dictionary mapping names to values.
@@ -134,6 +135,7 @@ def __init__(self, slot_data, preloaded, boundInstance, presentationSlots):
self.slot_data = slot_data
self._boundInstance = boundInstance
self._presentationSlots = presentationSlots
self._renderers = renderers
super(PlatedElement, self).__init__(
loader=TagLoader(preloaded.fillSlots(
**{k: _extra_types(v) for k, v in slot_data.items()}
@@ -155,6 +157,14 @@ def lookupRenderMethod(self, name):
"""
@return: a renderer.
"""
if name in self._renderers:
wrapped = self._renderers[name]
@modified("plated render wrapper", wrapped)
def renderWrapper(request, tag, *args, **kw):
return _call(self._boundInstance, wrapped,
request, tag, *args, **kw)
return renderWrapper
if ":" not in name:
raise MissingRenderMethod(self, name)
slot, type = name.split(":", 1)
@@ -188,6 +198,17 @@ def __init__(self, defaults=None, tags=None,
self._defaults = {} if defaults is None else defaults
self._loader = TagLoader(tags)
self._presentationSlots = {self.CONTENT} | set(presentation_slots)
self._renderers = {}
def render(self, renderer):
"""
Add a renderer to this L{Plating} object that can be used in the
top-level template.
"""
self._renderers[text_type(originalName(renderer))] = renderer
return renderer
def routed(self, routing, tags):
"""
@@ -232,6 +253,7 @@ def _elementify(self, instance, to_fill_with):
loaded = loaded.clone()
return PlatedElement(slot_data=slot_data,
preloaded=loaded,
renderers=self._renderers,
boundInstance=instance,
presentationSlots=self._presentationSlots)
@@ -246,9 +268,9 @@ class _Widget(object):
instance's L{Plating._elementify} to construct a
L{PlatedElement}.
"""
_plating = attr.ib()
_function = attr.ib()
_instance = attr.ib()
_plating = attr.ib(type='Plating')
_function = attr.ib(type=Callable[..., Any])
_instance = attr.ib(type=object)
def __call__(self, *args, **kwargs):
return self._function(*args, **kwargs)
View
@@ -0,0 +1,164 @@
from typing import Any, Callable, List, TYPE_CHECKING
import attr
from twisted.internet.defer import inlineCallbacks, returnValue
from twisted.python.components import Componentized
from zope.interface import implementer
from ._app import _call
from ._decorators import bindable, modified
from .interfaces import EarlyExit, IRequestLifecycle
if TYPE_CHECKING:
from typing import Dict, Tuple, Sequence
from twisted.web.iweb import IRequest
from twisted.internet.defer import Deferred
from zope.interface.interfaces import IInterface
from .interfaces import IDependencyInjector, IRequiredParameter
IDependencyInjector, IRequiredParameter, IRequest, Dict, Tuple
Deferred, IInterface, Sequence
@implementer(IRequestLifecycle)
@attr.s
class RequestLifecycle(object):
"""
Before and after hooks.
"""
_before = attr.ib(type=List, default=attr.Factory(list))
_after = attr.ib(type=List, default=attr.Factory(list))
def addBeforeHook(self, beforeHook, requires=(), provides=()):
# type: (Callable, Sequence[IInterface], Sequence[IInterface]) -> None
"""
Add a hook that promises to supply the given interfaces as components
on the request, and requires the given requirements.
"""
# TODO: topological requirements sort
self._before.append(beforeHook)
def addAfterHook(self, afterHook):
# type: (Callable) -> None
"""
Add a hook that will execute after the request has completed.
"""
self._after.append(afterHook)
@inlineCallbacks
def runBeforeHooks(self, instance, request):
# type: (Any, IRequest) -> Deferred
"""
Execute all the "before" hooks.
@param instance: The instance bound to the Klein route.
@param request: The IRequest being processed.
"""
for hook in self._before:
yield _call(instance, hook, request)
@inlineCallbacks
def runAfterHooks(self, instance, request, result):
# type: (Any, IRequest, Any) -> Deferred
"""
Execute all "after" hooks.
@param instance: The instance bound to the Klein route.
@param request: The IRequest being processed.
@param result: The result produced by the route.
"""
for hook in self._after:
yield _call(instance, hook, request, result)
_routeDecorator = Any # a decorator like @route
_routeT = Any # a thing decorated by a decorator like @route
_prerequisiteCallback = Callable[[IRequestLifecycle], None]
@attr.s
class Requirer(object):
"""
Dependency injection for required parameters.
"""
_prerequisites = attr.ib(
type=List[_prerequisiteCallback],
default=attr.Factory(list)
)
def prerequisite(
self,
providesComponents, # type: Sequence[IInterface]
requiresComponents=() # type: Sequence[IInterface]
):
# type: (...) -> Callable[[Callable], Callable]
"""
Prerequisite.
"""
def decorator(prerequisiteMethod):
# type: (Callable) -> Callable
def oneHook(lifecycle):
# type: (IRequestLifecycle) -> None
lifecycle.addBeforeHook(
prerequisiteMethod, requires=requiresComponents,
provides=providesComponents
)
self._prerequisites.append(oneHook)
return prerequisiteMethod
return decorator
def require(self, routeDecorator, **requiredParameters):
# type: (_routeT, **IRequiredParameter) -> _routeDecorator
"""
Inject the given dependencies while running the given route.
"""
def decorator(functionWithRequirements):
# type: (Any) -> Callable
injectionComponents = Componentized()
lifecycle = RequestLifecycle()
injectionComponents.setComponent(IRequestLifecycle, lifecycle)
injectors = {} # type: Dict[str, IDependencyInjector]
for parameterName, required in requiredParameters.items():
injectors[parameterName] = required.registerInjector(
injectionComponents, parameterName, lifecycle
)
for prereq in self._prerequisites:
prereq(lifecycle)
for v in injectors.values():
v.finalize()
@modified("dependency-injecting route", functionWithRequirements)
@bindable
@inlineCallbacks
def router(instance, request, *args, **routeParams):
# type: (Any, IRequest, *Any, **Any) -> Any
injected = routeParams.copy()
try:
yield lifecycle.runBeforeHooks(instance, request)
for (k, injector) in injectors.items():
injected[k] = yield injector.injectValue(
instance, request, routeParams
)
except EarlyExit as ee:
returnValue(ee.alternateReturnValue)
result = yield _call(instance, functionWithRequirements,
request, *args, **injected)
lifecycle.runAfterHooks(instance, request, result)
returnValue(result)
functionWithRequirements.injectionComponents = injectionComponents
routeDecorator(router)
return functionWithRequirements
return decorator
View
@@ -0,0 +1,258 @@
from typing import Any, Callable, Optional as _Optional, TYPE_CHECKING, Union
import attr
from twisted.internet.defer import inlineCallbacks, returnValue
from twisted.python.reflect import qual
from twisted.web.http import UNAUTHORIZED
from twisted.web.resource import Resource
from zope.interface import implementer
from zope.interface.interfaces import IInterface
from .interfaces import (
EarlyExit, IDependencyInjector, IRequestLifecycle, IRequiredParameter,
ISession, ISessionProcurer, ISessionStore, NoSuchSession, SessionMechanism,
TooLateForCookies
)
if TYPE_CHECKING:
from twisted.web.iweb import IRequest
from twisted.python.components import Componentized
from mypy_extensions import KwArg, VarArg, Arg
from typing import TypeVar, Awaitable, Dict
T = TypeVar('T')
(IRequest, Arg, KwArg, VarArg, Callable, Dict, IInterface, Awaitable,
Componentized, IRequestLifecycle)
else:
Arg = KwArg = lambda t, *x: t
@implementer(ISessionProcurer)
@attr.s
class SessionProcurer(object):
"""
A L{SessionProcurer} procures a session from a request and a store.
@ivar _store: The session store to procure a session from.
@type _store: L{klein.interfaces.ISessionStore}
@ivar _maxAge: The maximum age (in seconds) of the session cookie.
@type _maxAge: L{int}
@ivar _secureCookie: The name of the cookie to use for sessions protected
with TLS (i.e. HTTPS).
@type _secureCookie: L{bytes}
@ivar _insecureCookie: The name of the cookie to use for sessions I{not}
protected with TLS (i.e. HTTP).
@type _insecureCookie: L{bytes}
@ivar _cookieDomain: If set, the domain name to restrict the session cookie
to.
@type _cookieDomain: L{None} or L{bytes}
@ivar _cookiePath: If set, the URL path to restrict the session cookie to.
@type _cookiePath: L{bytes}
@ivar _secureTokenHeader: The name of the HTTPS header to try to extract a
session token from; API clients should use this header, rather than a
cookie.
@type _secureTokenHeader: L{bytes}
@ivar _insecureTokenHeader: The name of the HTTP header to try to extract a
session token from; API clients should use this header, rather than a
cookie.
@type _insecureTokenHeader: L{bytes}
@ivar _setCookieOnGET: Automatically request that the session store create
a session if one is not already associated with the request and the
request is a GET.
@type _setCookieOnGET: L{bool}
"""
_store = attr.ib(type=ISessionStore)
_maxAge = attr.ib(type=int, default=3600)
_secureCookie = attr.ib(type=bytes, default=b"Klein-Secure-Session")
_insecureCookie = attr.ib(type=bytes, default=b"Klein-INSECURE-Session")
_cookieDomain = attr.ib(type=_Optional[bytes], default=None)
_cookiePath = attr.ib(type=bytes, default=b"/")
_secureTokenHeader = attr.ib(type=bytes, default=b"X-Auth-Token")
_insecureTokenHeader = attr.ib(type=bytes,
default=b"X-INSECURE-Auth-Token")
_setCookieOnGET = attr.ib(type=bool, default=True)
@inlineCallbacks
def procureSession(self, request, forceInsecure=False):
# type: (IRequest, bool) -> Any
alreadyProcured = request.getComponent(ISession)
if alreadyProcured is not None:
returnValue(alreadyProcured)
if request.isSecure():
if forceInsecure:
tokenHeader = self._insecureTokenHeader
cookieName = self._insecureCookie
sentSecurely = False
else:
tokenHeader = self._secureTokenHeader
cookieName = self._secureCookie
sentSecurely = True
else:
# Have we inadvertently disclosed a secure token over an insecure
# transport, for example, due to a buggy client?
allPossibleSentTokens = (
sum([request.requestHeaders.getRawHeaders(header, [])
for header in [self._secureTokenHeader,
self._insecureTokenHeader]], []) +
[it for it in [request.getCookie(cookie)
for cookie in [self._secureCookie,
self._insecureCookie]] if it]
)
# Does it seem like this check is expensive? It sure is! Don't want
# to do it? Turn on your dang HTTPS!
yield self._store.sentInsecurely(allPossibleSentTokens)
tokenHeader = self._insecureTokenHeader
cookieName = self._insecureCookie
sentSecurely = False
# Fun future feature: honeypot that does this over HTTPS, but sets
# isSecure() to return false because it serves up a cert for the
# wrong hostname or an invalid cert, to keep API clients honest
# about chain validation.
sessionID = request.getHeader(tokenHeader)
if sessionID is not None:
mechanism = SessionMechanism.Header
else:
mechanism = SessionMechanism.Cookie
sessionID = request.getCookie(cookieName)
if sessionID is not None:
sessionID = sessionID.decode('ascii')
try:
session = yield self._store.loadSession(
sessionID, sentSecurely, mechanism
)
except NoSuchSession:
if mechanism == SessionMechanism.Header:
raise
sessionID = None
if sessionID is None:
if request.method != b'GET' or not self._setCookieOnGET:
# If we don't have a session ID at all, and we're not allowed
# to set a cookie on the client, don't waste session-store
# resources by allocating one.
returnValue(None)
if request.startedWriting:
# At this point, if the mechanism is Header, we either have
# a valid session or we bailed after NoSuchSession above.
raise TooLateForCookies(
"You tried initializing a cookie-based session too"
" late in the request pipeline; the headers"
" were already sent."
)
session = yield self._store.newSession(sentSecurely, mechanism)
if (
sessionID != session.identifier and
request.method == b'GET' and
self._setCookieOnGET
):
# sessionID is the input session ID from the request;
# session.identifier is the created or loaded session from the
# session store. This cookie is set when setCookiesOnGET is
# allowed, either when the session store has informed of us of a
# changed session identifier or when a new session has been created
# (sessionID is None)
if request.startedWriting:
raise TooLateForCookies(
"You tried changing a session ID to a new session ID too"
" late in the request pipeline; the headers were already"
" sent."
)
request.addCookie(
cookieName, session.identifier, max_age=self._maxAge,
domain=self._cookieDomain, path=self._cookiePath,
secure=sentSecurely, httpOnly=True,
)
if sentSecurely or not request.isSecure():
# Do not cache the insecure session on the secure request, thanks.
request.setComponent(ISession, session)
returnValue(session)
_procureProcurerType = Union[
Callable[[Any], ISessionProcurer],
Callable[[], ISessionProcurer]
]
_kleinRenderable = Any
_routeCallable = Any
_kleinCallable = Callable[..., _kleinRenderable]
_kleinDecorator = Callable[[_kleinCallable], _kleinCallable]
_requirerResult = Callable[[Arg(_routeCallable, 'route'), KwArg(Any)],
Callable[[_kleinCallable], _kleinCallable]]
class AuthorizationDenied(Resource, object):
def __init__(self, interface):
# type: (IInterface) -> None
self._interface = interface
super(AuthorizationDenied, self).__init__()
def render(self, request):
# type: (IRequest) -> bytes
request.setResponseCode(UNAUTHORIZED)
return "{} DENIED".format(qual(self._interface)).encode('utf-8')
@implementer(IDependencyInjector, IRequiredParameter)
@attr.s
class Authorization(object):
"""
Authorize.
"""
_interface = attr.ib(type=IInterface)
_required = attr.ib(type=bool, default=True)
@classmethod
def optional(cls, interface):
# type: (IInterface) -> Authorization
"""
Make a requirement passed to L{Authorizer.require}.
"""
return cls(interface, required=False)
def registerInjector(self, injectionComponents, parameterName, lifecycle):
# type: (Componentized, str, IRequestLifecycle) -> IDependencyInjector
"""
Register this authorization to inject a parameter.
"""
return self
@inlineCallbacks
def injectValue(self, instance, request, routeParams):
# type: (Any, IRequest, Dict[str, Any]) -> Any
"""
Inject a value by asking the request's session.
"""
# TODO: this could be optimized to do fewer calls to 'authorize' by
# collecting all the interfaces that are necessary and then using
# addBeforeHook; the interface would not need to change.
provider = ((yield ISession(request).authorize([self._interface]))
.get(self._interface))
if self._required and provider is None:
raise EarlyExit(AuthorizationDenied(self._interface))
# TODO: CSRF protection should probably go here
returnValue(provider)
def finalize(self):
# type: () -> None
"""
Nothing to finalize when registering.
"""
View
@@ -0,0 +1,22 @@
"""
Workaround for the bridge between zope.interface until
https://github.com/python/mypy/issues/3960 can be resolved.
"""
from typing import Any, TYPE_CHECKING, Type, TypeVar, cast
from zope.interface.interfaces import IInterface
if TYPE_CHECKING:
IInterface, Type, Any
T = TypeVar("T")
def zcast(interface, provider):
# type: (Type[T], Any) -> T
"""
Combine ZI's run-time type checking with mypy-time type checking.
"""
if not cast(Any, interface).providedBy(provider):
raise NotImplementedError()
return cast(T, provider)
View
@@ -1,6 +1,70 @@
from ._interfaces import IKleinRequest
from typing import TYPE_CHECKING
from ._interfaces import (
IKleinRequest,
)
from ._isession import (
EarlyExit,
IDependencyInjector as _IDependencyInjector,
IRequestLifecycle as _IRequestLifecycle,
IRequiredParameter as _IRequiredParameter,
ISession as _ISession,
ISessionProcurer as _ISessionProcurer,
ISessionStore as _ISessionStore,
ISimpleAccount as _ISimpleAccount,
ISimpleAccountBinding as _ISimpleAccountBinding,
NoSuchSession,
SessionMechanism,
TooLateForCookies,
TransactionEnded,
)
if TYPE_CHECKING:
from ._storage.memory import MemorySessionStore, MemorySession
from ._storage.sql import (SessionStore, SQLAccount, IPTrackingProcurer,
AccountSessionBinding)
from ._session import SessionProcurer, Authorization
from ._form import Field, RenderableFormParam, FieldInjector
from ._requirer import RequestLifecycle
from typing import Union
ISessionStore = Union[_ISessionStore, MemorySessionStore,
SessionStore]
ISessionProcurer = Union[_ISessionProcurer, SessionProcurer,
IPTrackingProcurer]
ISession = Union[_ISession, MemorySession]
ISimpleAccount = Union[_ISimpleAccount, SQLAccount]
ISimpleAccountBinding = Union[_ISimpleAccountBinding,
AccountSessionBinding]
IDependencyInjector = Union[_IDependencyInjector, Authorization,
RenderableFormParam, FieldInjector]
IRequiredParameter = Union[_IRequiredParameter, Authorization, Field,
RenderableFormParam]
IRequestLifecycle = Union[_IRequestLifecycle, RequestLifecycle]
else:
ISession = _ISession
ISessionStore = _ISessionStore
ISimpleAccount = _ISimpleAccount
ISessionProcurer = _ISessionProcurer
ISimpleAccountBinding = _ISimpleAccountBinding
IDependencyInjector = _IDependencyInjector
IRequiredParameter = _IRequiredParameter
IRequestLifecycle = _IRequestLifecycle
__all__ = (
"IKleinRequest",
"NoSuchSession",
"TooLateForCookies",
"TransactionEnded",
"ISessionStore",
"ISimpleAccountBinding",
"ISimpleAccount",
"ISessionProcurer",
"IDependencyInjector",
"IRequiredParameter",
"IRequestLifecycle",
"EarlyExit",
"SessionMechanism",
"ISession",
)
View
No changes.
View
@@ -0,0 +1,49 @@
from typing import TYPE_CHECKING
from zope.interface import Attribute, Interface
from .._typing import ifmethod
if TYPE_CHECKING:
from twisted.internet.defer import Deferred
from ..interfaces import ISessionStore, ISession
from ._sql_generic import Transaction
ISession, ISessionStore, Deferred, Transaction
class ISQLAuthorizer(Interface):
"""
An add-on for an L{AlchimiaDataStore} that can populate data on an Alchimia
session.
"""
authorizationInterface = Attribute(
"""
The interface or class for which a session can be authorized by this
L{ISQLAuthorizer}.
"""
)
@ifmethod
def authorizationForSession(sessionStore, transaction, session):
# type: (ISessionStore, Transaction, ISession) -> Deferred
"""
Get a data object that the session has access to.
If necessary, load related data first.
@param sessionStore: the store where the session is stored.
@type sessionStore: L{ISessionStore}
@param transaction: The transaction that loaded the session.
@type transaction: L{klein.storage.sql.Transaction}
@param session: The session that said this data will be attached to.
@type session: L{ISession}
@return: the object the session is authorized to access
@rtype: a providier of C{self.authorizationInterface}, or a L{Deferred}
firing the same.
"""
# session_store is a _sql.SessionStore but it's not documented as such.
View
@@ -0,0 +1,149 @@
# -*- test-case-name: klein.test.test_memory -*-
from binascii import hexlify
from os import urandom
from typing import Any, Callable, Dict, List, TYPE_CHECKING, Text, cast
import attr
from attr import Factory
from twisted.internet.defer import Deferred, fail, succeed
from twisted.python.components import Componentized
from zope.interface import implementer
from zope.interface.interfaces import IInterface
from klein import SessionProcurer
from klein.interfaces import (
ISession, ISessionStore, NoSuchSession, SessionMechanism
)
if TYPE_CHECKING:
List, Deferred, IInterface, Any, Callable, Dict, SessionMechanism
_authCB = Callable[[IInterface, ISession, Componentized], Any]
@implementer(ISession)
@attr.s
class MemorySession(object):
"""
An in-memory session.
"""
identifier = attr.ib(type=Text)
isConfidential = attr.ib(type=bool)
authenticatedBy = attr.ib(type=SessionMechanism)
_authorizationCallback = attr.ib(type=_authCB)
_components = attr.ib(default=Factory(Componentized),
type=Componentized)
def authorize(self, interfaces):
# type: (List[IInterface]) -> Deferred
"""
Authorize each interface by calling back to the session store's
authorization callback.
"""
result = {}
for interface in interfaces:
authCB = cast(_authCB, self._authorizationCallback)
result[interface] = authCB(interface, self, self._components)
return succeed(result)
class _MemoryAuthorizerFunction(object):
"""
Type shadow for function with the given attribute.
"""
__memoryAuthInterface__ = None # type: IInterface
def __call__(self, interface, session, data):
# type: (IInterface, ISession, Componentized) -> Any
"""
Return a provider of the given interface.
"""
_authFn = Callable[[IInterface, ISession, Componentized], Any]
def declareMemoryAuthorizer(forInterface):
# type: (IInterface) -> Callable[[Callable], _MemoryAuthorizerFunction]
"""
Declare that the decorated function is an authorizer usable with a memory
session store.
"""
def decorate(decoratee):
# type: (_authFn) -> _MemoryAuthorizerFunction
decoratee = cast(_MemoryAuthorizerFunction, decoratee)
decoratee.__memoryAuthInterface__ = forInterface
return decoratee
return decorate
@implementer(ISessionStore)
@attr.s
class MemorySessionStore(object):
authorizationCallback = attr.ib(
type=_authFn,
default=lambda interface, session, data: None
)
_secureStorage = attr.ib(type=Dict[str, Any],
default=cast(Dict[str, Any], Factory(dict)))
_insecureStorage = attr.ib(type=Dict[str, Any],
default=cast(Dict[str, Any], Factory(dict)))
@classmethod
def fromAuthorizers(cls, authorizers):
# type: (List[_MemoryAuthorizerFunction]) -> MemorySessionStore
"""
Create a L{MemorySessionStore} from a collection of callbacks which can
do authorization.
"""
interfaceToCallable = {}
for authorizer in authorizers:
specifiedInterface = authorizer.__memoryAuthInterface__
interfaceToCallable[specifiedInterface] = authorizer
def authorizationCallback(interface, session, data):
# type: (IInterface, ISession, Componentized) -> Any
return interfaceToCallable[interface](interface, session, data)
return cls(authorizationCallback)
def procurer(self):
# type: () -> SessionProcurer
return SessionProcurer(self)
def _storage(self, isConfidential):
# type: (bool) -> Dict[str, Any]
"""
Return the storage appropriate to the isConfidential flag.
"""
if isConfidential:
return self._secureStorage
else:
return self._insecureStorage
def newSession(self, isConfidential, authenticatedBy):
# type: (bool, SessionMechanism) -> Deferred
storage = self._storage(isConfidential)
identifier = hexlify(urandom(32)).decode('ascii')
session = MemorySession(identifier, isConfidential, authenticatedBy,
cast(_authFn, self.authorizationCallback))
storage[identifier] = session
return succeed(session)
def loadSession(self, identifier, isConfidential, authenticatedBy):
# type: (str, bool, SessionMechanism) -> Deferred
storage = self._storage(isConfidential)
if identifier in storage:
result = storage[identifier]
if isConfidential != result.isConfidential:
storage.pop(identifier)
return fail(NoSuchSession(identifier))
return succeed(result)
else:
return fail(NoSuchSession(identifier))
def sentInsecurely(self, tokens):
# type: (List[str]) -> None
return
View
@@ -0,0 +1,89 @@
from functools import partial
from typing import Any, Callable, Optional, TYPE_CHECKING, Text, Tuple
from unicodedata import normalize
from passlib.context import CryptContext
from twisted.internet.defer import Deferred, inlineCallbacks, returnValue
from twisted.internet.threads import deferToThread
if TYPE_CHECKING:
Text, Callable, Deferred, Optional, Tuple, Any
passlibContextWithGoodDefaults = partial(CryptContext, schemes=['bcrypt'])
def _verifyAndUpdate(secret, hash, ctxFactory=passlibContextWithGoodDefaults):
# type: (Text, Text, Callable[[], CryptContext]) -> Deferred
"""
Asynchronous wrapper for L{CryptContext.verify_and_update}.
"""
@deferToThread
def theWork():
# type: () -> Tuple[bool, Optional[str]]
return ctxFactory().verify_and_update(secret, hash)
return theWork
def _hashSecret(secret, ctxFactory=passlibContextWithGoodDefaults):
# type: (Text, Callable[[], CryptContext]) -> Deferred
"""
Asynchronous wrapper for L{CryptContext.hash}.
"""
@deferToThread
def theWork():
# type: () -> str
return ctxFactory().hash(secret)
return theWork
@inlineCallbacks
def checkAndReset(storedPasswordText, providedPasswordText, resetter):
# type: (Text, Text, Callable[[str], Any]) -> Any
"""
Check the given stored password text against the given provided password
text.
@param storedPasswordText: opaque (text) from the account database.
@type storedPasswordText: L{unicode}
@param providedPasswordText: the plain-text password provided by the
user.
@type providedPasswordText: L{unicode}
@return: L{Deferred} firing with C{True} if the password matches and
C{False} if the password does not match.
"""
providedPasswordText = normalize('NFD', providedPasswordText)
valid, newHash = yield _verifyAndUpdate(providedPasswordText,
storedPasswordText)
if valid:
# Password migration! Does our passlib context have an awesome *new*
# hash it wants to give us? Store it.
if newHash is not None:
if isinstance(newHash, bytes):
newHash = newHash.decode("charmap")
yield resetter(newHash)
returnValue(True)
else:
returnValue(False)
@inlineCallbacks
def computeKeyText(passwordText):
# type: (Text) -> Any
"""
Compute some text to store for a given plain-text password.
@param passwordText: The text of a new password, as entered by a user.
@return: a L{Deferred} firing with L{unicode}.
"""
normalized = normalize('NFD', passwordText)
hashed = yield _hashSecret(normalized)
if isinstance(hashed, bytes):
hashed = hashed.decode("charmap")
return hashed
View

Large diffs are not rendered by default.

Oops, something went wrong.
View
@@ -0,0 +1,373 @@
"""
Generic SQL data storage stuff; the substrate for session-storage stuff.
"""
from collections import deque
from sys import exc_info
from typing import Any, Optional, TYPE_CHECKING, Text, TypeVar
from alchimia import TWISTED_STRATEGY
import attr
from attr import Factory
from sqlalchemy import create_engine
from twisted.internet.defer import (Deferred, gatherResults, inlineCallbacks,
returnValue, succeed)
from zope.interface import Interface, implementer
from ..interfaces import TransactionEnded
_sqlAlchemyConnection = Any
_sqlAlchemyTransaction = Any
COMMITTING = "committing"
COMMITTED = "committed"
COMMIT_FAILED = "commit failed"
ROLLING_BACK = "rolling back"
ROLLED_BACK = "rolled back"
ROLLBACK_FAILED = "rollback failed"
if TYPE_CHECKING:
T = TypeVar('T')
from twisted.internet.interfaces import IReactorThreads
IReactorThreads
from typing import Iterable
Iterable
from typing import Callable
Callable
from twisted.web.iweb import IRequest
IRequest
@attr.s
class Transaction(object):
"""
Wrapper around a SQLAlchemy connection which is invalidated when the
transaction is committed or rolled back.
"""
_connection = attr.ib(type=_sqlAlchemyConnection)
_transaction = attr.ib(type=_sqlAlchemyTransaction)
_parent = attr.ib(type='Optional[Transaction]', default=None)
_stopped = attr.ib(type=Text, default=u"")
_completeDeferred = attr.ib(type=Deferred, default=Factory(Deferred))
def _checkStopped(self):
# type: () -> None
"""
Raise an exception if the transaction has been stopped for any reason.
"""
if self._stopped:
raise TransactionEnded(self._stopped)
if self._parent is not None:
self._parent._checkStopped()
def execute(self, statement, *multiparams, **params):
# type: (Any, *Any, **Any) -> Deferred
"""
Execute a statement unless this transaction has been stopped, otherwise
raise L{TransactionEnded}.
"""
self._checkStopped()
return self._connection.execute(statement, *multiparams, **params)
def commit(self):
# type: () -> Deferred
"""
Commit this transaction.
"""
self._checkStopped()
self._stopped = COMMITTING
return self._transaction.commit().addCallbacks(
(lambda commitResult: self._finishWith(COMMITTED)),
(lambda commitFailure: self._finishWith(COMMIT_FAILED))
)
def rollback(self):
# type: () -> Deferred
"""
Roll this transaction back.
"""
self._checkStopped()
self._stopped = ROLLING_BACK
return self._transaction.rollback().addCallbacks(
(lambda commitResult: self._finishWith(ROLLED_BACK)),
(lambda commitFailure: self._finishWith(ROLLBACK_FAILED))
)
def _finishWith(self, stopStatus):
# type: (Text) -> None
"""
Complete this transaction.
"""
self._stopped = stopStatus
self._completeDeferred.callback(stopStatus)
@inlineCallbacks
def savepoint(self):
# type: () -> Deferred
"""
Create a L{Savepoint} which can be treated as a sub-transaction.
@note: As long as this L{Savepoint} has not been rolled back or
committed, this transaction's C{execute} method will execute within
the context of that savepoint.
"""
returnValue(Transaction(
self._connection, (yield self._connection.begin_nested()),
self
))
def subtransact(self, logic):
# type: (Callable[[Transaction], Deferred]) -> Deferred
"""
Run the given C{logic} in a subtransaction.
"""
return Transactor(self.savepoint).transact(logic)
def maybeCommit(self):
# type: () -> Deferred
"""
Commit this transaction if it hasn't been finished (committed or rolled
back) yet; otherwise, do nothing.
"""
if self._stopped:
return succeed(None)
return self.commit()
def maybeRollback(self):
# type: () -> Deferred
"""
Roll this transaction back if it hasn't been finished (committed or
rolled back) yet; otherwise, do nothing.
"""
if self._stopped:
return succeed(None)
return self.rollback()
@attr.s
class Transactor(object):
"""
A context manager that represents the lifecycle of a transaction when
paired with application code.
"""
_newTransaction = attr.ib(type='Callable[[], Deferred]')
_transaction = attr.ib(type=Optional[Transaction], default=None)
@inlineCallbacks
def __aenter__(self):
# type: () -> Deferred
"""
Start a transaction.
"""
self._transaction = yield self._newTransaction() # type: ignore
# ^ https://github.com/python/mypy/issues/4688
returnValue(self._transaction)
@inlineCallbacks
def __aexit__(self, exc_type, exc_value, traceback):
# type: (type, Exception, Any) -> Deferred
"""
End a transaction.
"""
assert self._transaction is not None
if exc_type is None:
yield self._transaction.commit()
else:
yield self._transaction.rollback()
self._transaction = None
@inlineCallbacks
def transact(self, logic):
# type: (Callable) -> Deferred
"""
Run the given logic within this L{TransactionContext}, starting and
stopping as usual.
"""
try:
transaction = yield self.__aenter__()
result = yield logic(transaction)
finally:
yield self.__aexit__(*exc_info())
returnValue(result)
@attr.s(hash=False)
class DataStore(object):
"""
L{DataStore} is a generic storage object that connect to an SQL
database, run transactions, and manage schema metadata.
"""
_engine = attr.ib(type=_sqlAlchemyConnection)
_freeConnections = attr.ib(default=Factory(deque), type=deque)
@inlineCallbacks
def newTransaction(self):
# type: () -> Deferred
"""
Create a new Klein transaction.
"""
alchimiaConnection = (
self._freeConnections.popleft() if self._freeConnections
else (yield self._engine.connect())
)
alchimiaTransaction = yield alchimiaConnection.begin()
kleinTransaction = Transaction(alchimiaConnection, alchimiaTransaction)
@kleinTransaction._completeDeferred.addBoth
def recycleTransaction(anything):
# type: (T) -> T
self._freeConnections.append(alchimiaConnection)
return anything
returnValue(kleinTransaction)
def transact(self, callable):
# type: (Callable[[Transaction], Any]) -> Any
"""
Run the given C{callable} within a transaction.
@param callable: A callable object that encapsulates application logic
that needs to run in a transaction.
@type callable: callable taking a L{Transaction} and returning a
L{Deferred}.
@return: a L{Deferred} firing with the result of C{callable}
@rtype: L{Deferred} that fires when the transaction is complete, or
fails when the transaction is rolled back.
"""
return Transactor(self.newTransaction).transact(callable)
@classmethod
def open(cls, reactor, dbURL):
# type: (IReactorThreads, Text) -> DataStore
"""
Open an L{DataStore}.
@param reactor: the reactor that this store should be opened on.
@type reactor: L{IReactorThreads}
@param dbURL: the SQLAlchemy database URI to connect to.
@type dbURL: L{str}
"""
return cls(create_engine(dbURL, reactor=reactor,
strategy=TWISTED_STRATEGY))
class ITransactionRequestAssociator(Interface):
"""
Associates transactions with requests.
"""
@implementer(ITransactionRequestAssociator)
@attr.s
class TransactionRequestAssociator(object):
"""
Does the thing the interface says.
"""
_map = attr.ib(type=dict, default=Factory(dict))
committing = attr.ib(type=bool, default=False)
@inlineCallbacks
def transactionForStore(self, dataStore):
# type: (DataStore) -> Deferred
"""
Get a transaction for the given datastore.
"""
if dataStore in self._map:
returnValue(self._map[dataStore])
txn = yield dataStore.newTransaction()
self._map[dataStore] = txn
returnValue(txn)
def commitAll(self):
# type: () -> Deferred
"""
Commit all associated transactions.
"""
self.committing = True
return gatherResults([value.maybeCommit()
for value in self._map.values()])
@inlineCallbacks
def requestBoundTransaction(request, dataStore):
# type: (IRequest, DataStore) -> Deferred
"""
Retrieve a transaction that is bound to the lifecycle of the given request.
There are three use-cases for this lifecycle:
1. 'normal CRUD' - a request begins, a transaction is associated with
it, and the transaction completes when the request completes. The
appropriate time to commit the transaction is the moment before the
first byte goes out to the client. The appropriate moment to
interpose this commit is in `Request.write`, at the moment where
it's about to call channel.writeHeaders, since the HTTP status code
should be an indicator of whether the transaction succeeded or
failed.
2. 'just the session please' - a request begins, a transaction is
associated with it in order to discover the session, and the
application code in question isn't actually using the database.
(Ideally as expressed through "the dependency-declaration decorator,
such as @authorized, did not indicate that a transaction will be
required").
3. 'fancy API stuff' - a request begins, a transaction is associated
with it in order to discover the session, the application code needs
to then do I{something} with that transaction in-line with the
session discovery, but then needs to commit in order to relinquish
all database locks while doing some potentially slow external API
calls, then start a I{new} transaction later in the request flow.
"""
assoc = request.getComponent(ITransactionRequestAssociator)
if assoc is None:
assoc = TransactionRequestAssociator()
request.setComponent(ITransactionRequestAssociator, assoc)
def finishCommit(result):
# type: (Any) -> Deferred
return assoc.commitAll()
request.notifyFinish().addBoth(finishCommit)
# originalWrite = request.write
# buffer = []
# def committed(result):
# for buf in buffer:
# if buf is None:
# originalFinish()
# else:
# originalWrite(buf)
# def maybeWrite(data):
# if request.startedWriting:
# return originalWrite(data)
# buffer.append(data)
# if assoc.committing:
# return
# assoc.commitAll().addBoth(committed)
# def maybeFinish():
# if not request.startedWriting:
# buffer.append(None)
# else:
# originalFinish()
# originalFinish = request.finish
# request.write = maybeWrite
# request.finish = maybeFinish
txn = yield assoc.transactionForStore(dataStore)
return txn
View
@@ -0,0 +1,14 @@
from typing import TYPE_CHECKING, Union
from ._istorage import ISQLAuthorizer as _ISQLAuthorizer
if TYPE_CHECKING:
from ._sql import SimpleSQLAuthorizer
ISQLAuthorizer = Union[_ISQLAuthorizer, SimpleSQLAuthorizer]
else:
ISQLAuthorizer = _ISQLAuthorizer
__all__ = [
'ISQLAuthorizer'
]
View
@@ -0,0 +1,7 @@
from ._memory import MemorySessionStore, declareMemoryAuthorizer
__all__ = [
'declareMemoryAuthorizer',
'MemorySessionStore',
]
View
@@ -0,0 +1,19 @@
from ._sql import (
SessionSchema, authorizerFor, procurerFromDataStore
)
from ._sql_generic import (
DataStore, Transaction
)
__all__ = [
"procurerFromDataStore",
"authorizerFor",
"SessionSchema",
"DataStore",
"Transaction",
]
if __name__ == '__main__':
import sys
sys.stdout.write(SessionSchema.withMetadata().migrationSQL())
View
@@ -0,0 +1,260 @@
from typing import List, TYPE_CHECKING, Text
import attr
from treq import content
from treq.testing import StubTreq
from twisted.trial.unittest import SynchronousTestCase
from klein import Field, Form, Klein, Requirer, SessionProcurer
from klein.interfaces import ISession, ISessionStore, SessionMechanism
from klein.storage.memory import MemorySessionStore
if TYPE_CHECKING:
from typing import Dict, Union
from twisted.web.iweb import IRequest
IRequest, Text, Union, Dict
def strdict(adict):
# type: (Dict[Union[bytes, Text], Union[bytes, Text]]) -> Dict[str, str]
"""
Workaround for a bug in Treq and Twisted where cookie jars cannot
consistently be text or bytes, but I{must} be native C{str}s on both Python
versions.
@type adict: A dictionary which might have bytes or strs or unicodes in it.
@return: A dictionary with only strs in it.
"""
strs = {}
def strify(s):
# type: (Union[bytes, Text]) -> str
if isinstance(s, str):
return s
elif isinstance(s, bytes):
return s.decode('utf-8')
else:
return s.encode('utf-8')
for k, v in adict.items():
strs[strify(k)] = strify(v)
return strs
@attr.s(hash=False)
class TestObject(object):
sessionStore = attr.ib(type=ISessionStore)
calls = attr.ib(attr.Factory(list), type=List)
router = Klein()
requirer = Requirer()
@requirer.prerequisite([ISession])
def procureASession(self, request):
# type: (IRequest) -> ISession
return (SessionProcurer(self.sessionStore,
secureTokenHeader=b'X-Test-Session')
.procureSession(request))
@requirer.require(
router.route("/handle", methods=['POST']),
name=Field.text(), value=Field.integer(),
)
def handler(self, request, name, value):
# type: (IRequest, Text, Text) -> bytes
self.calls.append((name, value))
return b'yay'
@requirer.require(
router.route("/render", methods=['GET']),
form=Form.rendererFor(handler, action=u'/handle')
)
def renderer(self, request, form):
# type: (IRequest, Form) -> Form
return form
class TestForms(SynchronousTestCase):
"""
Tests for L{klein.Form} and associated tools.
"""
def test_handling(self):
# type: () -> None
"""
A handler for a Form with Fields receives those fields as input, as
passed by an HTTP client.
"""
mem = MemorySessionStore()
session = self.successResultOf(
mem.newSession(True, SessionMechanism.Header)
)
to = TestObject(mem)
stub = StubTreq(to.router.resource())
response = self.successResultOf(stub.post(
'https://localhost/handle',
data=dict(name='hello', value='1234', ignoreme='extraneous'),
headers={b'X-Test-Session': session.identifier}
))
self.assertEqual(response.code, 200)
self.assertEqual(self.successResultOf(content(response)), b'yay')
self.assertEqual(to.calls, [(u'hello', 1234)])
def test_handlingGET(self):
# type: () -> None
"""
A GET handler for a Form with Fields receives query parameters matching
those field names as input.
"""
router = Klein()
requirer = Requirer()
calls = []
@requirer.require(router.route("/getme", methods=['GET']),
name=Field.text(), value=Field.integer())
def justGet(request, name, value):
# type: (IRequest, str, int) -> bytes
calls.append((name, value))
return b'got'
stub = StubTreq(router.resource())
response = self.successResultOf(stub.get(
b"https://localhost/getme?name=hello,%20big+world&value=4321"
))
self.assertEqual(response.code, 200)
self.assertEqual(self.successResultOf(content(response)), b'got')
self.assertEqual(calls, [(u'hello, big world', 4321)])
def test_handlingJSON(self):
# type: () -> None
"""
A handler for a form with Fields receives those fields as input, as
passed by an HTTP client that submits a JSON POST body.
"""
mem = MemorySessionStore()
session = self.successResultOf(
mem.newSession(True, SessionMechanism.Header)
)
to = TestObject(mem)
stub = StubTreq(to.router.resource())
response = self.successResultOf(stub.post(
'https://localhost/handle',
json=dict(name='hello', value='1234', ignoreme='extraneous'),
headers={b'X-Test-Session': session.identifier}
))
self.assertEqual(response.code, 200)
self.assertEqual(self.successResultOf(content(response)), b'yay')
self.assertEqual(to.calls, [(u'hello', 1234)])
def test_rendering(self):
# type: () -> None
"""
When a route requires form fields, it renders a form with those fields.
"""
mem = MemorySessionStore()
session = self.successResultOf(
mem.newSession(True, SessionMechanism.Header)
)
stub = StubTreq(TestObject(mem).router.resource())
response = self.successResultOf(stub.get(
'https://localhost/render',
headers={b'X-Test-Session': session.identifier}
))
self.assertEqual(response.code, 200)
self.assertIn(response.headers.getRawHeaders(b"content-type")[0],
b"text/html")
def test_renderingWithNoSessionYet(self):
# type: () -> None
"""
When a route is rendered with no session, it sets a cookie to establish
a new session.
"""
mem = MemorySessionStore()
stub = StubTreq(TestObject(mem).router.resource())
response = self.successResultOf(stub.get('https://localhost/render'))
setCookie = response.cookies()['Klein-Secure-Session']
self.assertIn(
u'value="{}"'
.format(setCookie),
self.successResultOf(content(response)).decode("utf-8")
)
def test_protectionFromCSRF(self):
# type: () -> None
"""
An unauthenticated, CSRF-protected form will return a 403 Forbidden
status code.
"""
mem = MemorySessionStore()
to = TestObject(mem)
stub = StubTreq(to.router.resource())
response = self.successResultOf(stub.post(
'https://localhost/handle',
data=dict(name='hello', value='1234')
))
self.assertEqual(to.calls, [])
self.assertEqual(response.code, 403)
self.assertIn(b'CSRF', self.successResultOf(content(response)))
def test_cookieNoToken(self):
# type: () -> None
"""
A cookie-authenticated, CSRF-protected form will return a 403 Forbidden
status code when a CSRF protection token is not supplied.
"""
mem = MemorySessionStore()
session = self.successResultOf(
mem.newSession(True, SessionMechanism.Cookie)
)
to = TestObject(mem)
stub = StubTreq(to.router.resource())
response = self.successResultOf(stub.post(
'https://localhost/handle',
data=dict(name='hello', value='1234', ignoreme='extraneous'),
cookies=strdict({"Klein-Secure-Session": session.identifier})
))
self.assertEqual(to.calls, [])
self.assertEqual(response.code, 403)
self.assertIn(b'CSRF', self.successResultOf(content(response)))
def test_cookieWithToken(self):
# type: () -> None
"""
A cookie-authenticated, CRSF-protected form will call the form as
expected.
"""
mem = MemorySessionStore()
session = self.successResultOf(
mem.newSession(True, SessionMechanism.Cookie)
)
to = TestObject(mem)
stub = StubTreq(to.router.resource())
response = self.successResultOf(stub.post(
'https://localhost/handle',
data=dict(name='hello', value='1234', ignoreme='extraneous',
__csrf_protection__=session.identifier),
cookies=strdict({"Klein-Secure-Session": session.identifier})
))
self.assertEqual(to.calls, [('hello', 1234)])
self.assertEqual(response.code, 200)
self.assertIn(b'yay', self.successResultOf(content(response)))
View
@@ -0,0 +1,61 @@
from typing import Any
from twisted.trial.unittest import SynchronousTestCase
from zope.interface import Interface
from zope.interface.verify import verifyObject
from klein.interfaces import ISession, ISessionStore, SessionMechanism
from klein.storage.memory import MemorySessionStore, declareMemoryAuthorizer
Any
class MemoryTests(SynchronousTestCase):
"""
Tests for memory-based session storage.
"""
def test_interfaceCompliance(self):
# type: () -> None
"""
Verify that the session store complies with the relevant interfaces.
"""
store = MemorySessionStore()
verifyObject(ISessionStore, store)
verifyObject(
ISession, self.successResultOf(
store.newSession(True, SessionMechanism.Header)
)
)
def test_simpleAuthorization(self):
# type: () -> None
"""
L{MemorySessionStore.fromAuthorizers} takes a set of functions
decorated with L{declareMemoryAuthorizer} and constructs a session
store that can authorize for those interfaces.
"""
class IFoo(Interface):
pass
class IBar(Interface):
pass
@declareMemoryAuthorizer(IFoo)
def fooMe(interface, session, componentized):
# type: (Any, Any, Any) -> int
return 1
@declareMemoryAuthorizer(IBar)
def barMe(interface, session, componentized):
# type: (Any, Any, Any) -> int
return 2
store = MemorySessionStore.fromAuthorizers([fooMe, barMe])
session = self.successResultOf(
store.newSession(False, SessionMechanism.Cookie)
)
self.assertEqual(self.successResultOf(session.authorize([IBar, IFoo])),
{IFoo: 1, IBar: 2})
View
@@ -94,8 +94,8 @@ class DeferredValue(object):
@param deferred: The L{Deferred} representing the value.
"""
value = attr.ib()
deferred = attr.ib(attr.Factory(Deferred))
value = attr.ib() # type: object
deferred = attr.ib(attr.Factory(Deferred)) # type: Deferred
def resolve(self):
"""
@@ -269,7 +269,8 @@ def injectPlatingElements(value):
return PlatedElement(slot_data=value,
preloaded=tags.html(),
boundInstance=None,
presentationSlots={})
presentationSlots={},
renderers={})
else:
return value
View
@@ -50,6 +50,7 @@ deps =
Werkzeug==0.14.1
zope.interface==4.4.3
{trial,coverage}: treq==17.8.0
{trial,coverage}: hypothesis==3.50.0
{trial,coverage}: idna==2.6
{trial,coverage}-py{27,py2}: mock==2.0.0
@@ -159,11 +160,12 @@ skip_install = True
deps =
mypy==0.570
mypy_extensions==0.3.0
commands =
"{toxinidir}/.travis/environment"
"{toxinidir}/.travis/mypy" --config-file="{toxinidir}/tox.ini" {posargs:src}
"{toxinidir}/.travis/mypy" --config-file="{toxinidir}/tox.ini" {posargs:loginspike.py src}
[mypy]