diff --git a/ordr/resources/account.py b/ordr/resources/account.py index 6f7d596..d1b38d9 100644 --- a/ordr/resources/account.py +++ b/ordr/resources/account.py @@ -5,7 +5,7 @@ import deform from pyramid.security import Allow, Everyone, DENY_ALL from ordr.models.account import Token, TokenSubject -from ordr.schemas.account import RegistrationSchema +from ordr.schemas.account import RegistrationSchema, ResetPasswordSchema from .helpers import BaseChildResource @@ -77,6 +77,17 @@ class PasswordResetTokenResource(BaseChildResource): ''' access controll list for the resource ''' return [(Allow, Everyone, 'view'), DENY_ALL] + def get_reset_form(self, **kwargs): + ''' returns password reset form ''' + settings = { + 'buttons': ( + deform.Button(name='change', title='Set New Password'), + deform.Button(name='cancel', title='Cancel'), + ) + } + settings.update(kwargs) + return self._prepare_form(ResetPasswordSchema, **settings) + class PasswordResetResource(BaseChildResource): ''' The resource for resetting a forgotten password diff --git a/ordr/schemas/account.py b/ordr/schemas/account.py index 56f2730..215bd18 100644 --- a/ordr/schemas/account.py +++ b/ordr/schemas/account.py @@ -44,3 +44,13 @@ class RegistrationSchema(CSRFSchema): widget=deform.widget.CheckedPasswordWidget(), validator=colander.Length(min=8) ) + + +class ResetPasswordSchema(CSRFSchema): + ''' reset a forgotten password registration ''' + + password = colander.SchemaNode( + colander.String(), + widget=deform.widget.CheckedPasswordWidget(), + validator=colander.Length(min=8) + ) diff --git a/ordr/scripts/initializedb.py b/ordr/scripts/initializedb.py index 6eb9650..e7c6492 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, User +from ..models import Role, User def usage(argv): diff --git a/ordr/templates/account/forgotten_password_completed.jinja2 b/ordr/templates/account/forgotten_password_completed.jinja2 new file mode 100644 index 0000000..196e65e --- /dev/null +++ b/ordr/templates/account/forgotten_password_completed.jinja2 @@ -0,0 +1,33 @@ +{% extends "ordr:templates/layout.jinja2" %} + +{% block content %} +
+
+

Forgot Your Password?

+
+
+
+
+

+ Step 1: Validate Account +

+
+
+

+ Step 2: Change Password +

+
+
+

+ Step 3: Finished +

+
+
+
+
+

Password Reset Succesfull

+

Your password has been changed.

+

You can now log in again.

+
+
+{% endblock content %} diff --git a/ordr/templates/account/forgotten_password_form.jinja2 b/ordr/templates/account/forgotten_password_form.jinja2 index 7c5b80c..32f90a8 100644 --- a/ordr/templates/account/forgotten_password_form.jinja2 +++ b/ordr/templates/account/forgotten_password_form.jinja2 @@ -33,7 +33,12 @@
- + + {% if formerror %} +
+ Username or email address unknown, or account is not activated. +
+ {% endif %}
diff --git a/ordr/templates/account/forgotten_password_reset.jinja2 b/ordr/templates/account/forgotten_password_reset.jinja2 new file mode 100644 index 0000000..3f4b60a --- /dev/null +++ b/ordr/templates/account/forgotten_password_reset.jinja2 @@ -0,0 +1,32 @@ +{% extends "ordr:templates/layout.jinja2" %} + +{% block content %} +
+
+

Forgot Your Password?

+
+
+
+
+

+ Step 1: Validate Account +

+
+
+

+ Step 2: Change Password +

+
+
+

+ Step 3: Finished +

+
+
+
+
+

Change your password

+

{{ form.render()|safe }}

+
+
+{% endblock content %} diff --git a/ordr/templates/account/forgotten_password_verify.jinja2 b/ordr/templates/account/forgotten_password_verify.jinja2 index db7d264..8bc8e98 100644 --- a/ordr/templates/account/forgotten_password_verify.jinja2 +++ b/ordr/templates/account/forgotten_password_verify.jinja2 @@ -1,7 +1,5 @@ {% extends "ordr:templates/layout.jinja2" %} -{% block title %} Ordr | Registration {% endblock title %} - {% block content %}
diff --git a/ordr/templates/layout.jinja2 b/ordr/templates/layout.jinja2 index 391a260..ff44b19 100644 --- a/ordr/templates/layout.jinja2 +++ b/ordr/templates/layout.jinja2 @@ -64,7 +64,7 @@ {% endif %} -
+
{% block content %}

No content

{% endblock content %} diff --git a/ordr/views/forgotten_password.py b/ordr/views/forgotten_password.py index 53e36df..50c4515 100644 --- a/ordr/views/forgotten_password.py +++ b/ordr/views/forgotten_password.py @@ -4,7 +4,7 @@ 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.models.account import User, TokenSubject from ordr.events import PasswordResetNotification # below this password length a warning is displayed @@ -37,10 +37,9 @@ def forgotten_password_form_processing(context, request): request.dbsession .query(User) .filter(or_( - func.lower(User.username) == identifier.lower(), + func.lower(User.username) == identifier.lower(), func.lower(User.email) == identifier.lower() - ) - ) + )) .first() ) if account is None or not account.is_active: @@ -49,8 +48,8 @@ def forgotten_password_form_processing(context, request): # create a verify-new-account token and send email token = account.issue_token(request, TokenSubject.RESET_PASSWORD) notification = PasswordResetNotification( - request, - account, + request, + account, {'token': token} ) request.registry.notify(notification) @@ -71,33 +70,51 @@ def verify(context, request): @view_config( - context='ordr.resources.account.PasswordResetTokenResource', + context='ordr.resources.account.PasswordResetResource', + name='completed', permission='view', request_method='GET', - renderer='ordr:templates/account/forgotten_password_reset.jinja2' + renderer='ordr:templates/account/forgotten_password_completed.jinja2' ) -def reset_password_form(context, request): - ''' user is verified, show reset password form ''' - raise NotImplemented() +def completed(context, request): + ''' user is verified, process reset password form ''' + return {} @view_config( context='ordr.resources.account.PasswordResetTokenResource', permission='view', - request_method='POST', + request_method='GET', renderer='ordr:templates/account/forgotten_password_reset.jinja2' ) -def reset_password_form_processing(context, request): - ''' user is verified, process reset password form ''' - raise NotImplemented() +def reset_password_form(context, request): + ''' user is verified, show reset password form ''' + form = context.get_reset_form() + return {'form': form} @view_config( context='ordr.resources.account.PasswordResetTokenResource', permission='view', - request_method='get', + request_method='POST', renderer='ordr:templates/account/forgotten_password_reset.jinja2' ) -def completed(context, request): - ''' user is verified, process reset password form ''' - raise NotImplemented() +def reset_password_form_processing(context, request): + ''' process the password reset form ''' + if 'change' not in request.POST: + return HTTPFound(request.resource_url(request.root)) + + form = context.get_reset_form() + data = request.POST.items() + try: + appstruct = form.validate(data) + except deform.ValidationFailure as e: + return {'form': form} + + # set new password + token = context.model + account = token.owner + account.set_password(appstruct['password']) + request.dbsession.delete(token) + + return HTTPFound(request.resource_url(context.__parent__, 'completed')) diff --git a/tests/_functional/__init__.py b/tests/_functional/__init__.py index 8b67b6b..fba9808 100644 --- a/tests/_functional/__init__.py +++ b/tests/_functional/__init__.py @@ -28,7 +28,9 @@ class CustomTestApp(webtest.TestApp): login_form['username'] = username login_form['password'] = password login_form.submit() - + response = self.get('/faq') + return username in response + def logout(self): ''' logout ''' self.get('/logout') diff --git a/tests/_functional/forgotten_password.py b/tests/_functional/forgotten_password.py new file mode 100644 index 0000000..dede8b2 --- /dev/null +++ b/tests/_functional/forgotten_password.py @@ -0,0 +1,88 @@ +''' functional tests for ordr2.views.forgotten_password ''' + +from pyramid_mailer import get_mailer + +from . import testappsetup, testapp, get_token_url # noqa: F401 + + +def test_forgot_password_process(testapp): # noqa: F811 + ''' test the forgot password form ''' + response = testapp.get('/forgot') + active_nav = response.html.find('li', class_='active') + active_step = response.html.find('p', class_='text-primary') + assert active_nav is None + assert 'Step 1: Validate Account' in active_step.text + assert 'Forgot Your Password?' in response + assert 'unknown username or email' not in response + + # fill out this form with invalid data + form = response.form + form['identifier'] = 'unknown identifier' + response = form.submit(name='send_mail') + active_nav = response.html.find('li', class_='active') + active_step = response.html.find('p', class_='text-primary') + assert active_nav is None + assert 'Step 1: Validate Account' in active_step.text + assert 'Forgot Your Password?' in response + assert 'Username or email address unknown' in response + + # fill out this form with valid data + form = response.form + form['identifier'] = 'TerryGilliam' + response = form.submit(name='send_mail') + assert response.location == 'http://localhost/forgot/verify' + + response = response.follow() + active_nav = response.html.find('li', class_='active') + active_step = response.html.find('p', class_='text-primary') + assert active_nav is None + assert 'Step 1: Validate Account' in active_step.text + assert 'Verify Your Email Address' in response + + # click the email verification token + mailer = get_mailer(testapp.app.registry) + email = mailer.outbox[-1] + assert email.subject == '[ordr] Password Reset' + + token_link = get_token_url(email, prefix='/forgot/') + response = testapp.get(token_link) + active_nav = response.html.find('li', class_='active') + active_step = response.html.find('p', class_='text-primary') + assert active_nav is None + assert 'Step 2: Change Password' in active_step.text + assert 'Forgot Your Password?' in response + assert 'do not match' not in response + + # fill out the change password form with invalid data + form = response.form + form['password'] = 'some passwords' + form['password-confirm'] = 'that do not match' + response = form.submit(name='change') + active_nav = response.html.find('li', class_='active') + active_step = response.html.find('p', class_='text-primary') + assert active_nav is None + assert 'Step 2: Change Password' in active_step.text + assert 'Forgot Your Password?' in response + assert 'Password did not match confirm' in response + + # fill out the change password form with valid data + form = response.form + form['password'] = 'Lost in La Mancha' + form['password-confirm'] = 'Lost in La Mancha' + response = form.submit(name='change') + assert response.location == 'http://localhost/forgot/completed' + + response = response.follow() + active_nav = response.html.find('li', class_='active') + active_step = response.html.find('p', class_='text-primary') + content = response.html.find('div', class_='content') + assert active_nav is None + assert 'Step 3: Finished' in active_step.text + assert 'Forgot Your Password?' in response + assert 'Password Reset Succesfull' in response + assert content.a['href'] == 'http://localhost/' + assert content.a.text == 'log in' + + # old password should not work but the new one + assert not testapp.login('TerryGilliam', 'Terry') + assert testapp.login('TerryGilliam', 'Lost in La Mancha') diff --git a/tests/resources/account.py b/tests/resources/account.py index 1495dee..6c84675 100644 --- a/tests/resources/account.py +++ b/tests/resources/account.py @@ -101,6 +101,22 @@ def test_password_reset_token_acl(): assert resource.__acl__() == [(Allow, Everyone, 'view'), DENY_ALL] +def test_password_reset_token_get_reset_form(): + ''' test the setup of the password reset form''' + from ordr.resources.account import PasswordResetTokenResource + import deform + + request = DummyRequest() + parent = DummyResource(request=request) + resource = PasswordResetTokenResource('some name', parent) + form = resource.get_reset_form() + + assert isinstance(form, deform.Form) + assert len(form.buttons) == 2 + assert form.buttons[0].title == 'Set New Password' + assert form.buttons[1].title == 'Cancel' + + def test_password_reset_acl(): ''' test access controll list for PasswordResetResource ''' from pyramid.security import Allow, Everyone, DENY_ALL diff --git a/tests/views/forgotten_password.py b/tests/views/forgotten_password.py index 347193e..f1076de 100644 --- a/tests/views/forgotten_password.py +++ b/tests/views/forgotten_password.py @@ -26,7 +26,7 @@ def test_forgotten_password_form(): @pytest.mark.parametrize( # noqa: F811 - 'identifier', + 'identifier', ['TerryGilliam', 'gilliam@example.com', 'Gilliam@Example.com'] ) def test_forgotten_password_processing_ok(dbsession, identifier): @@ -51,7 +51,7 @@ def test_forgotten_password_processing_ok(dbsession, identifier): result = forgotten_password_form_processing(context, request) assert isinstance(result, HTTPFound) - assert result.location == 'http://example.com//verify' + assert result.location == 'http://example.com//verify' # a token should be created token = user.tokens[0] @@ -63,7 +63,7 @@ def test_forgotten_password_processing_ok(dbsession, identifier): @pytest.mark.parametrize( # noqa: F811 - 'identifier', + 'identifier', ['', 'GrahamChapman', 'unknown@example.com'] ) def test_forgotten_password_processing_not_ok(dbsession, identifier): @@ -91,7 +91,7 @@ def test_forgotten_password_processing_not_ok(dbsession, identifier): assert dbsession.query(Token).count() == 0 -def test_forgotten_password_processing_cancel(dbsession): +def test_forgotten_password_processing_cancel(dbsession): # noqa: F811 ''' test the canceling of the forgotten password form ''' from ordr.models.account import Token from ordr.resources.account import PasswordResetResource @@ -114,6 +114,128 @@ def test_forgotten_password_processing_cancel(dbsession): def test_verify(): + ''' test the message view for check your email ''' from ordr.views.forgotten_password import verify result = verify(None, None) assert result == {} + + +def test_completed(): + ''' test the view for a completed reset process ''' + from ordr.views.forgotten_password import completed + result = completed(None, None) + assert result == {} + + +def test_reset_password_form(): + ''' test reset password form view ''' + from ordr.resources.account import PasswordResetTokenResource + from ordr.schemas.account import ResetPasswordSchema + from ordr.views.forgotten_password import reset_password_form + + request = DummyRequest() + parent = DummyResource(request=request) + context = PasswordResetTokenResource(name=None, parent=parent) + result = reset_password_form(context, None) + form = result['form'] + + assert isinstance(form, deform.Form) + assert isinstance(form.schema, ResetPasswordSchema) + + +def test_reset_password_form_processing_valid(dbsession): # noqa: F811 + ''' test reset password form processing ''' + from ordr.models.account import User, Role, Token, TokenSubject + from ordr.resources.account import PasswordResetTokenResource + from ordr.views.forgotten_password import reset_password_form_processing + + data = { + '__start__': 'password:mapping', + 'password': 'Lost in La Mancha', + 'password-confirm': 'Lost in La Mancha', + '__end__': 'password:mapping', + 'change': 'Set New Password' + } + request = get_post_request(dbsession, data) + + user = get_example_user(Role.USER) + dbsession.add(user) + user.issue_token(request, TokenSubject.RESET_PASSWORD) + dbsession.flush() + token = dbsession.query(Token).first() + + parent = DummyResource(request=request) + context = PasswordResetTokenResource(name=None, parent=parent, model=token) + result = reset_password_form_processing(context, request) + + # return value of function call + assert isinstance(result, HTTPFound) + assert result.location == 'http://example.com/completed' + + # password of the user should be updated + user = dbsession.query(User).filter_by(username='TerryGilliam').first() + assert user.check_password('Lost in La Mancha') + + token_count = dbsession.query(Token).count() + assert token_count == 0 + + +def test_reset_password_form_processing_invalid_data(dbsession): # noqa: F811 + ''' test reset password form processing ''' + from ordr.models.account import Role, Token, TokenSubject + from ordr.resources.account import PasswordResetTokenResource + from ordr.schemas.account import ResetPasswordSchema + from ordr.views.forgotten_password import reset_password_form_processing + + data = { + '__start__': 'password:mapping', + 'password': 'does not match', + 'password-confirm': 'the confirmation', + '__end__': 'password:mapping', + 'change': 'Set New Password' + } + request = get_post_request(dbsession, data) + + user = get_example_user(Role.USER) + dbsession.add(user) + user.issue_token(request, TokenSubject.RESET_PASSWORD) + dbsession.flush() + token = dbsession.query(Token).first() + + parent = DummyResource(request=request) + context = PasswordResetTokenResource(name=None, parent=parent, model=token) + result = reset_password_form_processing(context, request) + + form = result['form'] + + assert isinstance(form, deform.Form) + assert isinstance(form.schema, ResetPasswordSchema) + + +def test_reset_password_form_processing_cancel(dbsession): # noqa: F811 + ''' test reset password form processing ''' + from ordr.models.account import Role, Token, TokenSubject + from ordr.resources.account import PasswordResetTokenResource + from ordr.views.forgotten_password import reset_password_form_processing + + data = { + '__start__': 'password:mapping', + 'password': 'Lost in La Mancha', + 'password-confirm': 'Lost in La Mancha', + '__end__': 'password:mapping', + 'cancel': 'Cancel' + } + request = get_post_request(dbsession, data) + + user = get_example_user(Role.USER) + dbsession.add(user) + user.issue_token(request, TokenSubject.RESET_PASSWORD) + dbsession.flush() + token = dbsession.query(Token).first() + + parent = DummyResource(request=request) + context = PasswordResetTokenResource(name=None, parent=parent, model=token) + result = reset_password_form_processing(context, request) + + assert isinstance(result, HTTPFound) + assert result.location == 'http://example.com//'