Browse Source

completed password reset process

rework
Holger Frey 7 years ago
parent
commit
0a2f7a5832
  1. 13
      ordr/resources/account.py
  2. 10
      ordr/schemas/account.py
  3. 2
      ordr/scripts/initializedb.py
  4. 33
      ordr/templates/account/forgotten_password_completed.jinja2
  5. 7
      ordr/templates/account/forgotten_password_form.jinja2
  6. 32
      ordr/templates/account/forgotten_password_reset.jinja2
  7. 2
      ordr/templates/account/forgotten_password_verify.jinja2
  8. 2
      ordr/templates/layout.jinja2
  9. 55
      ordr/views/forgotten_password.py
  10. 4
      tests/_functional/__init__.py
  11. 88
      tests/_functional/forgotten_password.py
  12. 16
      tests/resources/account.py
  13. 130
      tests/views/forgotten_password.py

13
ordr/resources/account.py

@ -5,7 +5,7 @@ import deform
from pyramid.security import Allow, Everyone, DENY_ALL from pyramid.security import Allow, Everyone, DENY_ALL
from ordr.models.account import Token, TokenSubject from ordr.models.account import Token, TokenSubject
from ordr.schemas.account import RegistrationSchema from ordr.schemas.account import RegistrationSchema, ResetPasswordSchema
from .helpers import BaseChildResource from .helpers import BaseChildResource
@ -77,6 +77,17 @@ class PasswordResetTokenResource(BaseChildResource):
''' access controll list for the resource ''' ''' access controll list for the resource '''
return [(Allow, Everyone, 'view'), DENY_ALL] 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): class PasswordResetResource(BaseChildResource):
''' The resource for resetting a forgotten password ''' The resource for resetting a forgotten password

10
ordr/schemas/account.py

@ -44,3 +44,13 @@ class RegistrationSchema(CSRFSchema):
widget=deform.widget.CheckedPasswordWidget(), widget=deform.widget.CheckedPasswordWidget(),
validator=colander.Length(min=8) 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)
)

2
ordr/scripts/initializedb.py

@ -17,7 +17,7 @@ from ..models import (
get_session_factory, get_session_factory,
get_tm_session, get_tm_session,
) )
from ..models import Role, User from ..models import Role, User
def usage(argv): def usage(argv):

33
ordr/templates/account/forgotten_password_completed.jinja2

@ -0,0 +1,33 @@
{% extends "ordr:templates/layout.jinja2" %}
{% block content %}
<div class="row justify-content-md-center mt-3">
<div class="col-6">
<h1>Forgot Your Password?</h1>
</div>
</div>
<div class="row justify-content-md-center mt-3">
<div class="col-2">
<p class="text-secondary">
Step 1: Validate Account
</p>
</div>
<div class="col-2">
<p class="text-secondary">
Step 2: Change Password
</p>
</div>
<div class="col-2">
<p class="text-primary">
Step 3: Finished
</p>
</div>
</div>
<div class="row justify-content-md-center mt-3">
<div class="col-6">
<h3>Password Reset Succesfull</h3>
<p class="mt-3">Your password has been changed.</p>
<p>You can now <a href="{{ request.resource_url(request.root) }}">log in</a> again.</p>
</div>
</div>
{% endblock content %}

7
ordr/templates/account/forgotten_password_form.jinja2

@ -33,7 +33,12 @@
<form action="{{request.resource_url(context)}}" method="POST"> <form action="{{request.resource_url(context)}}" method="POST">
<div class="form-group form-row mt-3"> <div class="form-group form-row mt-3">
<input type="hidden" name="csrf_token" value="{{get_csrf_token()}}"> <input type="hidden" name="csrf_token" value="{{get_csrf_token()}}">
<input type="text" class="form-control {% if loginerror %}is-invalid{% endif %}" id="input-username" placeholder="Mail Address or Username" name="identifier" autofocus="autofocus"> <input type="text" class="form-control {% if formerror %}is-invalid{% endif %}" id="input-username" placeholder="Mail Address or Username" name="identifier" autofocus="autofocus">
{% if formerror %}
<div class="invalid-feedback">
Username or email address unknown, or account is not activated.
</div>
{% endif %}
</div> </div>
<div class="form-group form-row mt-5"> <div class="form-group form-row mt-5">
<button type="submit" name="send_mail" class="btn btn-primary mr-1">Send Reset Link</button> <button type="submit" name="send_mail" class="btn btn-primary mr-1">Send Reset Link</button>

32
ordr/templates/account/forgotten_password_reset.jinja2

@ -0,0 +1,32 @@
{% extends "ordr:templates/layout.jinja2" %}
{% block content %}
<div class="row justify-content-md-center mt-3">
<div class="col-6">
<h1>Forgot Your Password?</h1>
</div>
</div>
<div class="row justify-content-md-center mt-3">
<div class="col-2">
<p class="text-secondary">
Step 1: Validate Account
</p>
</div>
<div class="col-2">
<p class="text-primary">
Step 2: Change Password
</p>
</div>
<div class="col-2">
<p class="text-secondary">
Step 3: Finished
</p>
</div>
</div>
<div class="row justify-content-md-center mt-3">
<div class="col-6">
<h3>Change your password</h3>
<p class="mt-3">{{ form.render()|safe }}</p>
</div>
</div>
{% endblock content %}

2
ordr/templates/account/forgotten_password_verify.jinja2

@ -1,7 +1,5 @@
{% extends "ordr:templates/layout.jinja2" %} {% extends "ordr:templates/layout.jinja2" %}
{% block title %} Ordr | Registration {% endblock title %}
{% block content %} {% block content %}
<div class="row justify-content-md-center mt-3"> <div class="row justify-content-md-center mt-3">
<div class="col-6"> <div class="col-6">

2
ordr/templates/layout.jinja2

@ -64,7 +64,7 @@
{% endif %} {% endif %}
</nav> </nav>
<div class="container-fluid"> <div class="container-fluid content">
{% block content %} {% block content %}
<p>No content</p> <p>No content</p>
{% endblock content %} {% endblock content %}

55
ordr/views/forgotten_password.py

@ -4,7 +4,7 @@ from pyramid.httpexceptions import HTTPFound
from pyramid.view import view_config from pyramid.view import view_config
from sqlalchemy import func, or_ 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 from ordr.events import PasswordResetNotification
# below this password length a warning is displayed # below this password length a warning is displayed
@ -37,10 +37,9 @@ def forgotten_password_form_processing(context, request):
request.dbsession request.dbsession
.query(User) .query(User)
.filter(or_( .filter(or_(
func.lower(User.username) == identifier.lower(), func.lower(User.username) == identifier.lower(),
func.lower(User.email) == identifier.lower() func.lower(User.email) == identifier.lower()
) ))
)
.first() .first()
) )
if account is None or not account.is_active: 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 # create a verify-new-account token and send email
token = account.issue_token(request, TokenSubject.RESET_PASSWORD) token = account.issue_token(request, TokenSubject.RESET_PASSWORD)
notification = PasswordResetNotification( notification = PasswordResetNotification(
request, request,
account, account,
{'token': token} {'token': token}
) )
request.registry.notify(notification) request.registry.notify(notification)
@ -71,33 +70,51 @@ def verify(context, request):
@view_config( @view_config(
context='ordr.resources.account.PasswordResetTokenResource', context='ordr.resources.account.PasswordResetResource',
name='completed',
permission='view', permission='view',
request_method='GET', 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): def completed(context, request):
''' user is verified, show reset password form ''' ''' user is verified, process reset password form '''
raise NotImplemented() return {}
@view_config( @view_config(
context='ordr.resources.account.PasswordResetTokenResource', context='ordr.resources.account.PasswordResetTokenResource',
permission='view', permission='view',
request_method='POST', request_method='GET',
renderer='ordr:templates/account/forgotten_password_reset.jinja2' renderer='ordr:templates/account/forgotten_password_reset.jinja2'
) )
def reset_password_form_processing(context, request): def reset_password_form(context, request):
''' user is verified, process reset password form ''' ''' user is verified, show reset password form '''
raise NotImplemented() form = context.get_reset_form()
return {'form': form}
@view_config( @view_config(
context='ordr.resources.account.PasswordResetTokenResource', context='ordr.resources.account.PasswordResetTokenResource',
permission='view', permission='view',
request_method='get', request_method='POST',
renderer='ordr:templates/account/forgotten_password_reset.jinja2' renderer='ordr:templates/account/forgotten_password_reset.jinja2'
) )
def completed(context, request): def reset_password_form_processing(context, request):
''' user is verified, process reset password form ''' ''' process the password reset form '''
raise NotImplemented() 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'))

4
tests/_functional/__init__.py

@ -28,7 +28,9 @@ class CustomTestApp(webtest.TestApp):
login_form['username'] = username login_form['username'] = username
login_form['password'] = password login_form['password'] = password
login_form.submit() login_form.submit()
response = self.get('/faq')
return username in response
def logout(self): def logout(self):
''' logout ''' ''' logout '''
self.get('/logout') self.get('/logout')

88
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')

16
tests/resources/account.py

@ -101,6 +101,22 @@ def test_password_reset_token_acl():
assert resource.__acl__() == [(Allow, Everyone, 'view'), DENY_ALL] 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(): def test_password_reset_acl():
''' test access controll list for PasswordResetResource ''' ''' test access controll list for PasswordResetResource '''
from pyramid.security import Allow, Everyone, DENY_ALL from pyramid.security import Allow, Everyone, DENY_ALL

130
tests/views/forgotten_password.py

@ -26,7 +26,7 @@ def test_forgotten_password_form():
@pytest.mark.parametrize( # noqa: F811 @pytest.mark.parametrize( # noqa: F811
'identifier', 'identifier',
['TerryGilliam', 'gilliam@example.com', 'Gilliam@Example.com'] ['TerryGilliam', 'gilliam@example.com', 'Gilliam@Example.com']
) )
def test_forgotten_password_processing_ok(dbsession, identifier): 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) result = forgotten_password_form_processing(context, request)
assert isinstance(result, HTTPFound) assert isinstance(result, HTTPFound)
assert result.location == 'http://example.com//verify' assert result.location == 'http://example.com//verify'
# a token should be created # a token should be created
token = user.tokens[0] token = user.tokens[0]
@ -63,7 +63,7 @@ def test_forgotten_password_processing_ok(dbsession, identifier):
@pytest.mark.parametrize( # noqa: F811 @pytest.mark.parametrize( # noqa: F811
'identifier', 'identifier',
['', 'GrahamChapman', 'unknown@example.com'] ['', 'GrahamChapman', 'unknown@example.com']
) )
def test_forgotten_password_processing_not_ok(dbsession, identifier): 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 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 ''' ''' test the canceling of the forgotten password form '''
from ordr.models.account import Token from ordr.models.account import Token
from ordr.resources.account import PasswordResetResource from ordr.resources.account import PasswordResetResource
@ -114,6 +114,128 @@ def test_forgotten_password_processing_cancel(dbsession):
def test_verify(): def test_verify():
''' test the message view for check your email '''
from ordr.views.forgotten_password import verify from ordr.views.forgotten_password import verify
result = verify(None, None) result = verify(None, None)
assert result == {} 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//'