Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

SQL session & authentication information storage #283

Draft
wants to merge 17 commits into
base: trunk
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,17 @@
"Werkzeug",
"zope.interface",
],
extra_requires={
"sql": [
"alchimia",
"passlib",
"bcrypt",
],
},
keywords="twisted flask werkzeug web",
license="MIT",
name="klein",
packages=["klein", "klein.storage", "klein.test"],
packages=["klein", "klein.storage", "klein.test", "klein.storage.test"],
package_dir={"": "src"},
package_data=dict(
klein=["py.typed"],
Expand Down
49 changes: 49 additions & 0 deletions src/klein/interfaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,55 @@
)


if TYPE_CHECKING: # pragma: no cover
from typing import Union

from ._dihttp import RequestComponent, RequestURL
from ._form import Field, FieldInjector, RenderableFormParam
from ._isession import IRequestLifecycleT as _IRequestLifecycleT
from ._session import Authorization, SessionProcurer
from ._storage.memory import MemorySession, MemorySessionStore
from ._storage.sql import (
AccountSessionBinding,
IPTrackingProcurer,
SessionStore,
SQLAccount,
)

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,
RequestURL,
RequestComponent,
]
IRequiredParameter = Union[
_IRequiredParameter,
Authorization,
Field,
RenderableFormParam,
RequestURL,
RequestComponent,
]
IRequestLifecycle = _IRequestLifecycleT
else:
ISession = _ISession
ISessionStore = _ISessionStore
ISimpleAccount = _ISimpleAccount
ISessionProcurer = _ISessionProcurer
ISimpleAccountBinding = _ISimpleAccountBinding
IDependencyInjector = _IDependencyInjector
IRequiredParameter = _IRequiredParameter
IRequestLifecycle = _IRequestLifecycle

__all__ = (
"EarlyExit",
"IDependencyInjector",
Expand Down
51 changes: 51 additions & 0 deletions src/klein/storage/_istorage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
from typing import TYPE_CHECKING

from zope.interface import Attribute, Interface

from .._typing import ifmethod


if TYPE_CHECKING: # pragma: no cover
from twisted.internet.defer import Deferred

from ..interfaces import ISession, ISessionStore
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.
93 changes: 93 additions & 0 deletions src/klein/storage/_security.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
from functools import partial
from typing import TYPE_CHECKING, Any, Callable, Optional, 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: # pragma: no cover
str, 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
Loading
Loading