diff --git a/ordr2/models/users.py b/ordr2/models/users.py index f6ff726..a7915b3 100644 --- a/ordr2/models/users.py +++ b/ordr2/models/users.py @@ -3,6 +3,7 @@ import enum from datetime import datetime +from passlib.context import CryptContext from sqlalchemy import ( Column, Date, @@ -14,6 +15,12 @@ from sqlalchemy import ( from .meta import Base +#: create a crypt context for password hashes +#: configured in :mod:`ordr2.security.includeme()` +#: this is not in :mod:`ordr2.security` to avoid circular imports +passlib_context = CryptContext() + + class Role(enum.Enum): ''' roles of user accounts ''' @@ -87,6 +94,31 @@ class User(Base): ''' is true if the user has an active role ''' return self.role in (Role.USER, Role.PURCHASER, Role.ADMIN) + def set_password(self, password): + ''' hashes a password using :mod:`ordr2.security.passlib_context` ''' + self.password_hash = passlib_context.hash(password) + + def check_password(self, password): + ''' checks a password against a stored password hash + + if the password algorithm is considered deprecated, the stored hash + will be updated using the current algorithm + ''' + ok, new_hash = passlib_context.verify_and_update( + password, + self.password_hash + ) + + if not ok: + # password does not match, return False + return False + elif new_hash: + # algorithm is deprecated, update hash with new algorithm + self.password_hash = new_hash + + # password match, return True + return True + def __str__(self): ''' string representation ''' return str(self.username) diff --git a/ordr2/scripts/initializedb.py b/ordr2/scripts/initializedb.py index b5287db..cee3c72 100644 --- a/ordr2/scripts/initializedb.py +++ b/ordr2/scripts/initializedb.py @@ -17,7 +17,7 @@ from ..models import ( get_session_factory, get_tm_session, ) -#from ..models import MyModel +# from ..models import MyModel def usage(argv): @@ -43,5 +43,5 @@ def main(argv=sys.argv): with transaction.manager: dbsession = get_tm_session(session_factory, transaction.manager) - model = MyModel(name='one', value=1) - dbsession.add(model) + # model = MyModel(name='one', value=1) + # dbsession.add(model) diff --git a/ordr2/security.py b/ordr2/security.py index 88ebfb1..04e7bea 100644 --- a/ordr2/security.py +++ b/ordr2/security.py @@ -1,15 +1,10 @@ ''' User Authentication and Authorization ''' -from passlib.context import CryptContext from pyramid.authentication import AuthTktAuthenticationPolicy from pyramid.authorization import ACLAuthorizationPolicy from pyramid.security import Authenticated, Everyone -from .models import User - - -#: create a crypt context for password hashes, configured in :func:`includeme()` -passlib_context = CryptContext() +from ordr2.models.users import User, passlib_context class AuthenticationPolicy(AuthTktAuthenticationPolicy): @@ -54,7 +49,7 @@ def get_user(request): def includeme(config): - ''' initializing authentication and authorization for the Pyramid app + ''' initializing authentication, authorization and password hash settings Activate this setup using ``config.include('ordr2.security')``. ''' @@ -70,5 +65,6 @@ def includeme(config): ) config.set_authentication_policy(authn_policy) config.set_authorization_policy(ACLAuthorizationPolicy()) - config.add_request_method(get_user, 'user', reify=True) + # attach the get_user function returned by get_user_closure() + config.add_request_method(get_user, 'user', reify=True) diff --git a/tests/models/users.py b/tests/models/users.py index c86d931..ff67d53 100644 --- a/tests/models/users.py +++ b/tests/models/users.py @@ -80,6 +80,52 @@ def test_user_is_active(role_name, is_active): assert user.is_active == is_active +def test_user_set_password(): + ''' test password hash generation ''' + from ordr2.models.users import User, passlib_context + + passlib_context.update(schemes=['argon2', 'bcrypt']) + user = User(password_hash=None) + password = 'Fish Slapping Dance' + user.set_password(password) + + assert user.password_hash.startswith('$argon2') + assert password not in user.password_hash + + +@pytest.mark.parametrize( + 'password', [ + 'Fish Slapping Dance', + pytest.mark.xfail('Argument Clinic') + ] + ) +def test_user_check_password_ok(password): + ''' test password check ''' + from ordr2.models.users import User, passlib_context + + passlib_context.update(schemes=['argon2', 'bcrypt'], deprecated='auto') + user = User(password_hash=None) + user.set_password('Fish Slapping Dance') + + assert user.check_password(password) + + +def test_user_check_password_deprecated_hash(): + ''' test password check updates deprecated hash with new algorithm ''' + from ordr2.models.users import User + from ordr2.security import passlib_context + + passlib_context.update(schemes=['argon2', 'bcrypt'], deprecated='auto') + password = 'Fish Slapping Dance' + bcrypt_hash = passlib_context.hash(password, scheme='bcrypt') + user = User(password_hash=bcrypt_hash) + + assert user.check_password(password) + assert user.password_hash != bcrypt_hash + assert user.password_hash.startswith('$argon2') + + + def test_user_string_representation(): ''' test the string representation of the user ''' from ordr2.models.users import User, Role