| @@ -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) |
| @@ -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 |
| @@ -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. | ||
| """ |
| @@ -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) |
| @@ -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", | ||
| ) |
| @@ -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. |
| @@ -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 |
| @@ -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 |
| @@ -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 |
| @@ -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' | ||
| ] |
| @@ -0,0 +1,7 @@ | ||
| from ._memory import MemorySessionStore, declareMemoryAuthorizer | ||
| __all__ = [ | ||
| 'declareMemoryAuthorizer', | ||
| 'MemorySessionStore', | ||
| ] |
| @@ -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()) |
| @@ -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))) |
| @@ -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}) |