Browse Source

working on forgotten password process

rework
Holger Frey 7 years ago
parent
commit
5d79bd34d6
  1. 5
      ordr/resources/__init__.py
  2. 39
      ordr/resources/account.py
  3. 12
      ordr/scripts/initializedb.py
  4. 44
      ordr/templates/account/forgotten_password_form.jinja2
  5. 35
      ordr/templates/account/forgotten_password_verify.jinja2
  6. 25
      ordr/templates/emails/password_reset.jinja2
  7. 103
      ordr/views/forgotten_password.py
  8. 66
      tests/resources/account.py
  9. 17
      tests/resources/root.py
  10. 119
      tests/views/forgotten_password.py

5
ordr/resources/__init__.py

@ -2,7 +2,7 @@
from pyramid.security import Allow, Everyone, DENY_ALL from pyramid.security import Allow, Everyone, DENY_ALL
from .account import RegistrationResource from .account import RegistrationResource, PasswordResetResource
class RootResource: class RootResource:
@ -34,7 +34,8 @@ class RootResource:
:raises: KeyError if child resource is not found :raises: KeyError if child resource is not found
''' '''
map = { map = {
'register': RegistrationResource 'register': RegistrationResource,
'forgot': PasswordResetResource,
} }
child_class = map[key] child_class = map[key]
return child_class(name=key, parent=self) return child_class(name=key, parent=self)

39
ordr/resources/account.py

@ -55,9 +55,46 @@ class RegistrationResource(BaseChildResource):
title='Cancel', title='Cancel',
type='link', type='link',
value=self.request.resource_url(self.request.root), value=self.request.resource_url(self.request.root),
css_class='btn btn-secondary' css_class='btn btn-outline-secondary'
) )
), ),
} }
settings.update(kwargs) settings.update(kwargs)
return self._prepare_form(RegistrationSchema, **settings) 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)

12
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, Token, TokenSubject, User from ..models import Role, User
def usage(argv): def usage(argv):
@ -49,4 +49,12 @@ def main(argv=sys.argv):
with transaction.manager: with transaction.manager:
dbsession = get_tm_session(session_factory, 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)

44
ordr/templates/account/forgotten_password_form.jinja2

@ -0,0 +1,44 @@
{% 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-primary">
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-secondary">
Step 3: Finished
</p>
</div>
</div>
<div class="row justify-content-md-center mt-3">
<div class="col-6 mt-3">
<p>Please enter your mail address or your username to reset your password.</p>
</div>
</div>
<div class="row justify-content-md-center">
<div class="col-6">
<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">
</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>
<button type="submit" name="cancel" class="btn btn-outline-secondary">Cancel</button>
</div>
</div>
</div>
{% endblock content %}

35
ordr/templates/account/forgotten_password_verify.jinja2

@ -0,0 +1,35 @@
{% 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">
<h1>Forgot Your Password?</h1>
</div>
</div>
<div class="row justify-content-md-center mt-3">
<div class="col-2">
<p class="text-primary">
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-secondary">
Step 3: Finished
</p>
</div>
</div>
<div class="row justify-content-md-center mt-3">
<div class="col-6">
<h3>Verify Your Email Address</h3>
<p class="mt-3">To continue the process, an email has been sent to you.</p>
<p>Please follow the link in the email to verify your account.</p>
</div>
</div>
{% endblock content %}

25
ordr/templates/emails/password_reset.jinja2

@ -0,0 +1,25 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<title>[ordr] reset your password</title>
<link href='http://fonts.googleapis.com/css?family=Anton&subset=latin,latin-ext' rel='stylesheet' type='text/css'>
</head>
<body>
<h1>Hi there!</h1>
<p>
To set a new password for the account "{{ user.username }}" follow this link
<a href="{{ request.resource_url(context, data.token.hash) }}">{{ request.resource_url(context, data.token.hash) }}</a>
</p>
<p> The link will expire on {{ data.token.expires.strftime('%d.%m.%y at %H:%M') }}.
<p class="signature">
Regards,
<br/>
<span class="brand">ordr</span>
</p>
<p class="footprint">
<small>Please don't respont to this email! This is an automatically generated notification by the system.</small>
<a href="http://distractedbysquirrels.com/" target="_blank" title="This software was originally written by Sebastian Sebald." class="icon-dbs"></a>
</p>
</body>
</html>

103
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()

66
tests/resources/account.py

@ -88,3 +88,69 @@ def test_registration_getitem_not_found(dbsession): # noqa: F811
with pytest.raises(KeyError): with pytest.raises(KeyError):
resource['unknown hash'] 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']

17
tests/resources/root.py

@ -2,6 +2,8 @@
import pytest import pytest
from ordr.resources.account import RegistrationResource, PasswordResetResource
def test_root_init(): def test_root_init():
''' test RootResource initialization ''' ''' test RootResource initialization '''
@ -20,16 +22,21 @@ def test_root_acl():
assert root.__acl__() == [(Allow, Everyone, 'view'), DENY_ALL] 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 ''' ''' test '__getitem__()' method of RootResource '''
from ordr.resources import RootResource from ordr.resources import RootResource
from ordr.resources.account import RegistrationResource
root = RootResource(None) root = RootResource(None)
child = root['register'] child = root[key]
assert isinstance(child, RegistrationResource) assert isinstance(child, resource_class)
assert child.__name__ == 'register' assert child.__name__ == key
assert child.__parent__ == root assert child.__parent__ == root
assert child.request == root.request assert child.request == root.request

119
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 == {}