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. 47
      ordr/views/forgotten_password.py
  10. 2
      tests/_functional/__init__.py
  11. 88
      tests/_functional/forgotten_password.py
  12. 16
      tests/resources/account.py
  13. 124
      tests/views/forgotten_password.py

13
ordr/resources/account.py

@ -5,7 +5,7 @@ import deform @@ -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): @@ -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

10
ordr/schemas/account.py

@ -44,3 +44,13 @@ class RegistrationSchema(CSRFSchema): @@ -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)
)

2
ordr/scripts/initializedb.py

@ -17,7 +17,7 @@ from ..models import ( @@ -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):

33
ordr/templates/account/forgotten_password_completed.jinja2

@ -0,0 +1,33 @@ @@ -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 @@ @@ -33,7 +33,12 @@
<form action="{{request.resource_url(context)}}" method="POST">
<div class="form-group form-row mt-3">
<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 class="form-group form-row mt-5">
<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 @@ @@ -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 @@ @@ -1,7 +1,5 @@
{% extends "ordr:templates/layout.jinja2" %}
{% block title %} Ordr | Registration {% endblock title %}
{% block content %}
<div class="row justify-content-md-center mt-3">
<div class="col-6">

2
ordr/templates/layout.jinja2

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

47
ordr/views/forgotten_password.py

@ -4,7 +4,7 @@ from pyramid.httpexceptions import HTTPFound @@ -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
@ -39,8 +39,7 @@ def forgotten_password_form_processing(context, request): @@ -39,8 +39,7 @@ def forgotten_password_form_processing(context, request):
.filter(or_(
func.lower(User.username) == identifier.lower(),
func.lower(User.email) == identifier.lower()
)
)
))
.first()
)
if account is None or not account.is_active:
@ -70,6 +69,18 @@ def verify(context, request): @@ -70,6 +69,18 @@ def verify(context, request):
return {}
@view_config(
context='ordr.resources.account.PasswordResetResource',
name='completed',
permission='view',
request_method='GET',
renderer='ordr:templates/account/forgotten_password_completed.jinja2'
)
def completed(context, request):
''' user is verified, process reset password form '''
return {}
@view_config(
context='ordr.resources.account.PasswordResetTokenResource',
permission='view',
@ -78,7 +89,8 @@ def verify(context, request): @@ -78,7 +89,8 @@ def verify(context, request):
)
def reset_password_form(context, request):
''' user is verified, show reset password form '''
raise NotImplemented()
form = context.get_reset_form()
return {'form': form}
@view_config(
@ -88,16 +100,21 @@ def reset_password_form(context, request): @@ -88,16 +100,21 @@ def reset_password_form(context, request):
renderer='ordr:templates/account/forgotten_password_reset.jinja2'
)
def reset_password_form_processing(context, request):
''' user is verified, process reset password form '''
raise NotImplemented()
''' 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}
@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()
# 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'))

2
tests/_functional/__init__.py

@ -28,6 +28,8 @@ class CustomTestApp(webtest.TestApp): @@ -28,6 +28,8 @@ 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 '''

88
tests/_functional/forgotten_password.py

@ -0,0 +1,88 @@ @@ -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(): @@ -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

124
tests/views/forgotten_password.py

@ -91,7 +91,7 @@ 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): @@ -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//'