diff --git a/ordr2/models/account.py b/ordr2/models/account.py index c77554e..28b1ccb 100644 --- a/ordr2/models/account.py +++ b/ordr2/models/account.py @@ -164,8 +164,7 @@ class User(Base): :rtype: (str) unique hash to access the token ''' - token = Token.issue(request, self, subject, payload) - return token.hash + return Token.issue(request, self, subject, payload) def __str__(self): ''' string representation ''' diff --git a/ordr2/schemas/account.py b/ordr2/schemas/account.py index 51f5987..aedb17b 100644 --- a/ordr2/schemas/account.py +++ b/ordr2/schemas/account.py @@ -47,3 +47,21 @@ class RegistrationSchema(CSRFSchema): } settings.update(override) return super().as_form(request, **settings) + + +class ResetPasswordSchema(CSRFSchema): + ''' reset a password ''' + + password = colander.SchemaNode( + colander.String(), + widget=deform.widget.CheckedPasswordWidget() + ) + + @classmethod + def as_form(cls, request, **override): + settings = { + 'buttons': ('Create Account', 'Cancel'), + 'css_class': 'form reset-password' + } + settings.update(override) + return super().as_form(request, **settings) diff --git a/ordr2/templates/account/password_resetted.jinja2 b/ordr2/templates/account/password_resetted.jinja2 new file mode 100644 index 0000000..07441cd --- /dev/null +++ b/ordr2/templates/account/password_resetted.jinja2 @@ -0,0 +1,37 @@ +{% extends "ordr2:templates/layout.jinja2" %} + +{% block title %} Ordr | Reset Password {% endblock title %} + +{% block content %} +
+
+
+ +

Password Reset Successful

+

You can now log on with your new password

+ +
+ +
+ +
+ +
+
+
+ +
+ +
+
+
+
+
+ +
+
+
+ +
+
+{% endblock content %} diff --git a/ordr2/templates/account/reset_password.jinja2 b/ordr2/templates/account/reset_password.jinja2 new file mode 100644 index 0000000..6569735 --- /dev/null +++ b/ordr2/templates/account/reset_password.jinja2 @@ -0,0 +1,15 @@ +{% extends "ordr2:templates/layout.jinja2" %} + +{% block title %} Ordr | Reset Password {% endblock title %} + +{% block content %} +
+
+
+ +

Reset Your Password

+ {{ form.render()|safe }} + +
+
+{% endblock content %} diff --git a/ordr2/templates/emails/password_reset.jinja2 b/ordr2/templates/emails/password_reset.jinja2 new file mode 100755 index 0000000..a55c276 --- /dev/null +++ b/ordr2/templates/emails/password_reset.jinja2 @@ -0,0 +1,25 @@ + + + + + [ordr] reset your password + + + +

Hi there!

+

+ To set a new password for the account "{{ user.user_name }}" 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/ordr2/views/account.py b/ordr2/views/account.py index 71f6aff..ecdb99c 100644 --- a/ordr2/views/account.py +++ b/ordr2/views/account.py @@ -9,7 +9,7 @@ from sqlalchemy import or_ from ordr2.events import CompleteRegistration, PasswordReset from ordr2.models.account import User, Role, TokenSubject -from ordr2.schemas.account import RegistrationSchema +from ordr2.schemas.account import RegistrationSchema, ResetPasswordSchema PROPOSED_PASSWORD_LENGTH = 12 @@ -20,7 +20,6 @@ PROPOSED_PASSWORD_LENGTH = 12 @view_config( context='ordr2:resources.account.AccountResource', name='login', - request_method='POST', permission='login', renderer='ordr2:templates/account/login.jinja2' ) @@ -195,3 +194,50 @@ def forgot_password_form_processing(context, request): def forgot_password_email_sent(context, request): ''' password reset link was sent ''' return {} + + +@view_config( + context='ordr2:resources.account.ForgottenPasswordToken', + request_method='GET', + permission='reset password', + renderer='ordr2:templates/account/reset_password.jinja2' + ) +def reset_password_form(context, request): + form = ResetPasswordSchema.as_form(request) + return {'form': form} + + +@view_config( + context='ordr2:resources.account.ForgottenPasswordToken', + request_method='POST', + permission='reset password', + renderer='ordr2:templates/account/reset_password.jinja2' + ) +def reset_password_form_processing(context, request): + if 'Cancel' in request.POST: + return HTTPFound(request.resource_url(request.root)) + + # validate the form data + form = ResetPasswordSchema.as_form(request) + data = request.POST.items() + try: + appstruct = form.validate(data) + except deform.ValidationFailure as e: + return {'form': form} + + # set the new password + context.model.owner.set_password(appstruct['password']) + + # delete the token + request.dbsession.delete(context.model) + + # issue a warning on a short password + if len(appstruct['password']) < PROPOSED_PASSWORD_LENGTH: + request.session.flash( + 'warning', + 'You should really consider a longer password' + ) + + request.session.flash('OK', 'Your password was changed' ) + return HTTPFound(request.resource_url(context.__parent__, 'login')) + diff --git a/tests/_functional/login-logout.py b/tests/_functional/login_logout.py similarity index 94% rename from tests/_functional/login-logout.py rename to tests/_functional/login_logout.py index 09c5ff2..d4b2c5a 100644 --- a/tests/_functional/login-logout.py +++ b/tests/_functional/login_logout.py @@ -25,15 +25,6 @@ def assert_user_login_failed(response, username): # tests for login and logout of users -def test_account_login_only_by_post(testapp): - ''' test that the login view is not accessibal via get ''' - testapp.reset() - - response = testapp.get('/account/login', status=404) - - assert response.status.startswith('404') - - def test_account_login_for_active_users(testapp): ''' check if user login works ''' testapp.reset() diff --git a/tests/_functional/reset_password.py b/tests/_functional/reset_password.py new file mode 100644 index 0000000..dc4a105 --- /dev/null +++ b/tests/_functional/reset_password.py @@ -0,0 +1,50 @@ +''' tests for the login, logout, registration and account settings''' + +import pytest + +from pyramid_mailer import get_mailer + +from . import testapp, get_token_url +from .. import get_user + + +@pytest.mark.xfail +def test_reset_password(testapp): + ''' test the complete reset password process ''' + + # submit the registration form + response = testapp.get('/account/forgot-password') + form = response.forms[1] + form['username_or_email'] = 'TerryGilliam' + response = form.submit() + assert response.location == 'http://localhost/account/forgot-password-email' + + response = response.follow() + assert 'Password Reset Link' 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) + response = testapp.get(token_link) + form = response.forms[1] + form['password'] = 'Nudge Nudge' + form['password-confirm'] = 'Nudge Nudge' + response = form.submit() + assert response.location == 'http://localhost/account/login' + + response = response.follow() + assert 'consider a longer password' in response + assert 'Your password was changed' in response + + form = response.forms[1] + form['username'] = 'TerryGilliam' + form['password'] = 'Nudge Nudge' + response = form.submit().follow() + assert '' in response + + + + diff --git a/tests/resources/account.py b/tests/resources/account.py index 2365bf3..d4aa5d0 100644 --- a/tests/resources/account.py +++ b/tests/resources/account.py @@ -147,3 +147,5 @@ def test_account_resource_getitem_token_expired(dbsession): with pytest.raises(KeyError) as excinfo: resource = account[token.hash] assert f'Token {token.hash} has expired' in str(excinfo.value) + + diff --git a/tests/views/account.py b/tests/views/account.py index 17b4b6e..64baa3b 100644 --- a/tests/views/account.py +++ b/tests/views/account.py @@ -285,3 +285,68 @@ def test_forgot_password_email_sent(): result = forgot_password_email_sent(None, None) assert result == {} + + +def test_reset_password_form(): + ''' reset password form display ''' + from ordr2.views.account import reset_password_form + + request = DummyRequest() + result = reset_password_form(None, request) + + assert isinstance(result['form'], deform.Form) + + +def reset_password_form_processing_ok(): + ''' reset password form processing is ok ''' + from ordr2.models.account import TokenSubject + from ordr2.views.account import reset_password_form_processing + + account = get_user('user') + token = user.issue_token(request, TokenSubject.RESET_PASSWORD) + dbsession.add(account) + dbsession.flush() + context = DummyResource(model=token) + request = DummyRequest( + dbsession=dbsession, + POST={'password': 'Nudge', 'password-confirmation': 'Nudge'} + ) + result = reset_password_form_processing(context, request) + + assert isinstance(result, HTTPFound) + assert result.location == 'http://example.com/account/login' + assert account.check_password('Nudge') + assert dbsession.query(Token).count() == 0 + assert dbsession.query(User).count() == 1 + + +def reset_password_form_processing_cancel(): + ''' reset password form processing is canceled ''' + from ordr2.views.account import reset_password_form_processing + + request = DummyRequest(dbsession=dbsession, POST={'Cancel': 'cancel'}) + result = reset_password_form_processing(None, request) + + assert isinstance(result, HTTPFound) + assert result.location == 'http://example.com//' + + +@pytest.mark.parametrize( + 'pw, confirm', [ + ('', ''), + ('no', 'match'), + ('one is empty', ''), + ('', 'one is empty'), + ] + ) +def reset_password_form_processing_invalid(pw, confirm): + ''' validation error in reset password form ''' + from ordr2.views.account import reset_password_form_processing + + request = DummyRequest( + dbsession=dbsession, + POST={'password': pw, 'password-confirmation': confirm} + ) + result = reset_password_form_processing(context, request) + + assert isinstance(result['form'], deform.Form)