diff --git a/ordr/resources/__init__.py b/ordr/resources/__init__.py index 7b2f3da..9069530 100644 --- a/ordr/resources/__init__.py +++ b/ordr/resources/__init__.py @@ -2,7 +2,7 @@ from pyramid.security import Allow, Everyone, DENY_ALL -from .account import RegistrationResource +from .account import RegistrationResource, PasswordResetResource class RootResource: @@ -34,7 +34,8 @@ class RootResource: :raises: KeyError if child resource is not found ''' map = { - 'register': RegistrationResource + 'register': RegistrationResource, + 'forgot': PasswordResetResource, } child_class = map[key] return child_class(name=key, parent=self) diff --git a/ordr/resources/account.py b/ordr/resources/account.py index 56f377e..6f7d596 100644 --- a/ordr/resources/account.py +++ b/ordr/resources/account.py @@ -55,9 +55,46 @@ class RegistrationResource(BaseChildResource): title='Cancel', type='link', value=self.request.resource_url(self.request.root), - css_class='btn btn-secondary' + css_class='btn btn-outline-secondary' ) ), } settings.update(kwargs) return self._prepare_form(RegistrationSchema, **settings) + + +class PasswordResetTokenResource(BaseChildResource): + ''' Resource for the reset password link + + :param pyramid.request.Request request: the current request object + :param str name: the name of the resource + :param parent: the parent resouce + ''' + + nav_active = None + + def __acl__(self): + ''' access controll list for the resource ''' + return [(Allow, Everyone, 'view'), DENY_ALL] + + +class PasswordResetResource(BaseChildResource): + ''' The resource for resetting a forgotten password + + :param pyramid.request.Request request: the current request object + :param str name: the name of the resource + :param parent: the parent resouce + ''' + + nav_active = None + + def __acl__(self): + ''' access controll list for the resource ''' + return [(Allow, Everyone, 'view'), DENY_ALL] + + def __getitem__(self, key): + ''' returns a resource for a valid reset password token ''' + token = Token.retrieve(self.request, key, TokenSubject.RESET_PASSWORD) + if token is None: + raise KeyError(f'Token {key} not found') + return PasswordResetTokenResource(name=key, parent=self, model=token) diff --git a/ordr/scripts/initializedb.py b/ordr/scripts/initializedb.py index eaba1bb..6eb9650 100644 --- a/ordr/scripts/initializedb.py +++ b/ordr/scripts/initializedb.py @@ -17,7 +17,7 @@ from ..models import ( get_session_factory, get_tm_session, ) -# from ..models import Role, Token, TokenSubject, User +from ..models import Role, User def usage(argv): @@ -49,4 +49,12 @@ def main(argv=sys.argv): with transaction.manager: dbsession = get_tm_session(session_factory, transaction.manager) - # dbsession.add() + account = User( + username='Holgi', + first_name='Holger', + last_name='Frey', + email='frey@imtek.de', + role=Role.ADMIN + ) + account.set_password('test') + dbsession.add(account) diff --git a/ordr/templates/account/forgotten_password_form.jinja2 b/ordr/templates/account/forgotten_password_form.jinja2 new file mode 100644 index 0000000..7c5b80c --- /dev/null +++ b/ordr/templates/account/forgotten_password_form.jinja2 @@ -0,0 +1,44 @@ +{% extends "ordr:templates/layout.jinja2" %} + +{% block content %} +
+
+

Forgot Your Password?

+
+
+
+
+

+ Step 1: Validate Account +

+
+
+

+ Step 2: Change Password +

+
+
+

+ Step 3: Finished +

+
+
+
+
+

Please enter your mail address or your username to reset your password.

+
+
+
+
+
+
+ + +
+
+ + +
+
+
+{% endblock content %} diff --git a/ordr/templates/account/forgotten_password_verify.jinja2 b/ordr/templates/account/forgotten_password_verify.jinja2 new file mode 100644 index 0000000..db7d264 --- /dev/null +++ b/ordr/templates/account/forgotten_password_verify.jinja2 @@ -0,0 +1,35 @@ +{% extends "ordr:templates/layout.jinja2" %} + +{% block title %} Ordr | Registration {% endblock title %} + +{% block content %} +
+
+

Forgot Your Password?

+
+
+
+
+

+ Step 1: Validate Account +

+
+
+

+ Step 2: Change Password +

+
+
+

+ Step 3: Finished +

+
+
+
+
+

Verify Your Email Address

+

To continue the process, an email has been sent to you.

+

Please follow the link in the email to verify your account.

+
+
+{% endblock content %} diff --git a/ordr/templates/emails/password_reset.jinja2 b/ordr/templates/emails/password_reset.jinja2 new file mode 100755 index 0000000..3ec42c7 --- /dev/null +++ b/ordr/templates/emails/password_reset.jinja2 @@ -0,0 +1,25 @@ + + + + + [ordr] reset your password + + + +

Hi there!

+

+ To set a new password for the account "{{ user.username }}" follow this link + {{ request.resource_url(context, data.token.hash) }} +

+

The link will expire on {{ data.token.expires.strftime('%d.%m.%y at %H:%M') }}. +

+ Regards, +
+ ordr +

+

+ Please don't respont to this email! This is an automatically generated notification by the system. + +

+ + diff --git a/ordr/views/forgotten_password.py b/ordr/views/forgotten_password.py new file mode 100644 index 0000000..53e36df --- /dev/null +++ b/ordr/views/forgotten_password.py @@ -0,0 +1,103 @@ +import deform + +from pyramid.httpexceptions import HTTPFound +from pyramid.view import view_config +from sqlalchemy import func, or_ + +from ordr.models.account import User, Role, TokenSubject +from ordr.events import PasswordResetNotification + +# below this password length a warning is displayed +MIN_PW_LENGTH = 12 + + +@view_config( + context='ordr.resources.account.PasswordResetResource', + permission='view', + request_method='GET', + renderer='ordr:templates/account/forgotten_password_form.jinja2' + ) +def forgotten_password_form(context, request): + ''' show forgotten password form ''' + return {'formerror': False} + + +@view_config( + context='ordr.resources.account.PasswordResetResource', + permission='view', + request_method='POST', + renderer='ordr:templates/account/forgotten_password_form.jinja2' + ) +def forgotten_password_form_processing(context, request): + ''' process forgotten password form ''' + if 'cancel' in request.POST: + return HTTPFound(request.resource_url(request.root)) + identifier = request.POST.get('identifier', '') + account = ( + request.dbsession + .query(User) + .filter(or_( + func.lower(User.username) == identifier.lower(), + func.lower(User.email) == identifier.lower() + ) + ) + .first() + ) + if account is None or not account.is_active: + return {'formerror': True} + + # create a verify-new-account token and send email + token = account.issue_token(request, TokenSubject.RESET_PASSWORD) + notification = PasswordResetNotification( + request, + account, + {'token': token} + ) + request.registry.notify(notification) + + return HTTPFound(request.resource_url(context, 'verify')) + + +@view_config( + context='ordr.resources.account.PasswordResetResource', + name='verify', + permission='view', + request_method='GET', + renderer='ordr:templates/account/forgotten_password_verify.jinja2' + ) +def verify(context, request): + ''' show email verification text ''' + return {} + + +@view_config( + context='ordr.resources.account.PasswordResetTokenResource', + permission='view', + request_method='GET', + renderer='ordr:templates/account/forgotten_password_reset.jinja2' + ) +def reset_password_form(context, request): + ''' user is verified, show reset password form ''' + raise NotImplemented() + + +@view_config( + context='ordr.resources.account.PasswordResetTokenResource', + permission='view', + request_method='POST', + renderer='ordr:templates/account/forgotten_password_reset.jinja2' + ) +def reset_password_form_processing(context, request): + ''' user is verified, process reset password form ''' + raise NotImplemented() + + +@view_config( + context='ordr.resources.account.PasswordResetTokenResource', + permission='view', + request_method='get', + renderer='ordr:templates/account/forgotten_password_reset.jinja2' + ) +def completed(context, request): + ''' user is verified, process reset password form ''' + raise NotImplemented() diff --git a/tests/resources/account.py b/tests/resources/account.py index 4123091..1495dee 100644 --- a/tests/resources/account.py +++ b/tests/resources/account.py @@ -88,3 +88,69 @@ def test_registration_getitem_not_found(dbsession): # noqa: F811 with pytest.raises(KeyError): resource['unknown hash'] + + +def test_password_reset_token_acl(): + ''' test access controll list for PasswordResetTokenResource ''' + from pyramid.security import Allow, Everyone, DENY_ALL + from ordr.resources.account import PasswordResetTokenResource + + parent = DummyResource(request='request') + resource = PasswordResetTokenResource('name', parent) + + assert resource.__acl__() == [(Allow, Everyone, 'view'), DENY_ALL] + + +def test_password_reset_acl(): + ''' test access controll list for PasswordResetResource ''' + from pyramid.security import Allow, Everyone, DENY_ALL + from ordr.resources.account import PasswordResetResource + + parent = DummyResource(request='request') + resource = PasswordResetResource('a name', parent) + + assert resource.__acl__() == [(Allow, Everyone, 'view'), DENY_ALL] + + +def test_password_reset_getitem_found(dbsession): # noqa: F811 + ''' test '__getitem__()' method returns child resource ''' + from ordr.models.account import Role, TokenSubject + from ordr.resources.account import ( + PasswordResetResource, + PasswordResetTokenResource + ) + + request = DummyRequest(dbsession=dbsession) + + user = get_example_user(Role.NEW) + token = user.issue_token(request, TokenSubject.RESET_PASSWORD) + dbsession.add(user) + dbsession.flush() + + parent = DummyResource(request=request) + resource = PasswordResetResource('a name', parent) + result = resource[token.hash] + + assert isinstance(result, PasswordResetTokenResource) + assert result.__name__ == token.hash + assert result.__parent__ == resource + assert result.model == token + + +def test_password_reset_getitem_not_found(dbsession): # noqa: F811 + ''' test '__getitem__()' method raises KeyError ''' + from ordr.models.account import Role, TokenSubject + from ordr.resources.account import PasswordResetResource + + request = DummyRequest(dbsession=dbsession) + + user = get_example_user(Role.NEW) + user.issue_token(request, TokenSubject.RESET_PASSWORD) + dbsession.add(user) + dbsession.flush() + + parent = DummyResource(request=request) + resource = PasswordResetResource('a name', parent) + + with pytest.raises(KeyError): + resource['unknown hash'] diff --git a/tests/resources/root.py b/tests/resources/root.py index 9c6b1cb..cd9aa4f 100644 --- a/tests/resources/root.py +++ b/tests/resources/root.py @@ -2,6 +2,8 @@ import pytest +from ordr.resources.account import RegistrationResource, PasswordResetResource + def test_root_init(): ''' test RootResource initialization ''' @@ -20,16 +22,21 @@ def test_root_acl(): assert root.__acl__() == [(Allow, Everyone, 'view'), DENY_ALL] -def test_root_getitem(): +@pytest.mark.parametrize( + 'key,resource_class', [ + ('register', RegistrationResource), + ('forgot', PasswordResetResource) + ] + ) +def test_root_getitem(key, resource_class): ''' test '__getitem__()' method of RootResource ''' from ordr.resources import RootResource - from ordr.resources.account import RegistrationResource root = RootResource(None) - child = root['register'] + child = root[key] - assert isinstance(child, RegistrationResource) - assert child.__name__ == 'register' + assert isinstance(child, resource_class) + assert child.__name__ == key assert child.__parent__ == root assert child.request == root.request diff --git a/tests/views/forgotten_password.py b/tests/views/forgotten_password.py new file mode 100644 index 0000000..347193e --- /dev/null +++ b/tests/views/forgotten_password.py @@ -0,0 +1,119 @@ +import deform +import pytest + +from pyramid.httpexceptions import HTTPFound +from pyramid.testing import DummyRequest, DummyResource + +from .. import ( # noqa: F401 + app_config, + dbsession, + get_example_user, + get_post_request + ) + + +def test_forgotten_password_form(): + ''' test the view for the forgotten password form ''' + from ordr.resources.account import PasswordResetResource + from ordr.views.forgotten_password import forgotten_password_form + + request = DummyRequest() + parent = DummyResource(request=request) + context = PasswordResetResource(name=None, parent=parent) + result = forgotten_password_form(context, None) + + assert result == {'formerror': False} + + +@pytest.mark.parametrize( # noqa: F811 + 'identifier', + ['TerryGilliam', 'gilliam@example.com', 'Gilliam@Example.com'] + ) +def test_forgotten_password_processing_ok(dbsession, identifier): + ''' test the processing of the forgotten password form ''' + from ordr.models.account import Role, TokenSubject + from ordr.resources.account import PasswordResetResource + from ordr.views.forgotten_password import ( + forgotten_password_form_processing + ) + + user = get_example_user(Role.USER) + dbsession.add(user) + dbsession.flush() + + post_data = { + 'identifier': identifier, + 'send_mail': 'send_mail', + } + request = DummyRequest(dbsession=dbsession, POST=post_data) + parent = DummyResource(request=request) + context = PasswordResetResource(name=None, parent=parent) + result = forgotten_password_form_processing(context, request) + + assert isinstance(result, HTTPFound) + assert result.location == 'http://example.com//verify' + + # a token should be created + token = user.tokens[0] + assert token.subject == TokenSubject.RESET_PASSWORD + + # a verification email should be sent + # this is tested in the functional test since request.registry.notify + # doesn't know about event subscribers in the unittest + + +@pytest.mark.parametrize( # noqa: F811 + 'identifier', + ['', 'GrahamChapman', 'unknown@example.com'] + ) +def test_forgotten_password_processing_not_ok(dbsession, identifier): + ''' test error processing of the forgotten password form ''' + from ordr.models.account import Role, Token + from ordr.resources.account import PasswordResetResource + from ordr.views.forgotten_password import ( + forgotten_password_form_processing + ) + + user = get_example_user(Role.UNVALIDATED) + dbsession.add(user) + dbsession.flush() + + post_data = { + 'identifier': identifier, + 'send_mail': 'send_mail', + } + request = DummyRequest(dbsession=dbsession, POST=post_data) + parent = DummyResource(request=request) + context = PasswordResetResource(name=None, parent=parent) + result = forgotten_password_form_processing(context, request) + + assert result == {'formerror': True} + assert dbsession.query(Token).count() == 0 + + +def test_forgotten_password_processing_cancel(dbsession): + ''' test the canceling of the forgotten password form ''' + from ordr.models.account import Token + from ordr.resources.account import PasswordResetResource + from ordr.views.forgotten_password import ( + forgotten_password_form_processing + ) + + post_data = { + 'identifier': 'TerryGilliam', + 'cancel': 'cancel', + } + request = DummyRequest(dbsession=dbsession, POST=post_data) + parent = DummyResource(request=request) + context = PasswordResetResource(name=None, parent=parent) + result = forgotten_password_form_processing(context, request) + + assert isinstance(result, HTTPFound) + assert result.location == 'http://example.com//' + assert dbsession.query(Token).count() == 0 + + +def test_verify(): + from ordr.views.forgotten_password import verify + result = verify(None, None) + assert result == {}