Browse Source

added password reset system

funding-tag
Holger Frey 5 years ago
parent
commit
34af777aa6
  1. 3
      .gitignore
  2. 6
      development.ini
  3. BIN
      ordr3.sqlite
  4. 2
      ordr3/adapters.py
  5. 50
      ordr3/events.py
  6. 14
      ordr3/repo.py
  7. 35
      ordr3/schemas/account.py
  8. 8
      ordr3/services.py
  9. 30
      ordr3/templates/account/forgotten_password.jinja2
  10. 33
      ordr3/templates/account/password_reseted.jinja2
  11. 27
      ordr3/templates/account/reset_link_sent.jinja2
  12. 28
      ordr3/templates/account/reset_password_form.jinja2
  13. 12
      ordr3/templates/emails/password_reset.jinja2
  14. 120
      ordr3/views/account.py
  15. 2
      tests/test_services.py

3
.gitignore vendored

@ -1,6 +1,9 @@
# current database, edited all the time # current database, edited all the time
ordr3.sqlite ordr3.sqlite
# development mail folder
mail/
# ---> Python # ---> Python
# Byte-compiled / optimized / DLL files # Byte-compiled / optimized / DLL files
__pycache__/ __pycache__/

6
development.ini

@ -13,6 +13,7 @@ pyramid.debug_routematch = false
pyramid.default_locale_name = en pyramid.default_locale_name = en
pyramid.includes = pyramid.includes =
pyramid_debugtoolbar pyramid_debugtoolbar
pyramid_mailer.debug
sqlalchemy.url = sqlite:///%(here)s/ordr3.sqlite sqlalchemy.url = sqlite:///%(here)s/ordr3.sqlite
@ -25,6 +26,11 @@ session.auto_csrf = true
static_views.cache_max_age = 0 static_views.cache_max_age = 0
# email delivery
mail.host = localhost
mail.port = 2525
mail.default_sender = ordr@example.com
# By default, the toolbar only appears for clients from IP addresses # By default, the toolbar only appears for clients from IP addresses
# '127.0.0.1' and '::1'. # '127.0.0.1' and '::1'.
# debugtoolbar.hosts = 127.0.0.1 ::1 # debugtoolbar.hosts = 127.0.0.1 ::1

BIN
ordr3.sqlite

Binary file not shown.

2
ordr3/adapters.py

@ -86,7 +86,7 @@ reset_token_table = Table(
metadata, metadata,
Column("token", Text, primary_key=True), Column("token", Text, primary_key=True),
Column("user_id", Integer, nullable=False), Column("user_id", Integer, nullable=False),
Column("valid_unitl", DateTime, nullable=False), Column("valid_until", DateTime, nullable=False),
) )

50
ordr3/events.py

@ -1,6 +1,8 @@
from collections import namedtuple from collections import namedtuple
from pyramid.events import subscriber from pyramid.events import subscriber
from pyramid.renderers import render
from pyramid_mailer.message import Message
SerializableFlashMessage = namedtuple( SerializableFlashMessage = namedtuple(
"SerializableFlashMessage", ["text", "more"] "SerializableFlashMessage", ["text", "more"]
@ -32,6 +34,39 @@ class FlashMessage(Ordr3Event):
return cls("error", text, more) return cls("error", text, more)
class EmailNotification(Ordr3Event):
""" base class for user notifications """
subject = None
template = None
def __init__(self, user, data=None):
super().__init__()
self.user = user
self.data = data
class AccountActivationEmail(EmailNotification):
""" user notification for account activation """
subject = "[ordr] Your account was activated"
template = "ordr3:templates/emails/activation.jinja2"
class OrderStatusChangeEmail(EmailNotification):
""" user notification for order status change """
subject = "[ordr] Order Status Change"
template = "ordr3:templates/emails/order.jinja2"
class PasswordResetEmail(EmailNotification):
""" user notification for password reset link """
subject = "[ordr] Password Reset"
template = "ordr3:templates/emails/password_reset.jinja2"
@subscriber(FlashMessage) @subscriber(FlashMessage)
def handle_flash_message_event(event): def handle_flash_message_event(event):
if event.request is None: if event.request is None:
@ -42,6 +77,21 @@ def handle_flash_message_event(event):
session.flash(message, event.channel, allow_duplicate=False) session.flash(message, event.channel, allow_duplicate=False)
@subscriber(EmailNotification)
def notify_user(event):
""" notify a user about an event """
body = render(
event.template, {"user": event.user, "data": event.data}, event.request
)
message = Message(
subject=event.subject,
sender=event.request.registry.settings["mail.default_sender"],
recipients=[event.user.email],
body=body,
)
event.request.mailer.send(message)
def emit(request, event): def emit(request, event):
event.request = request event.request = request
request.registry.notify(event) request.registry.notify(event)

14
ordr3/repo.py

@ -69,7 +69,7 @@ class AbstractOrderRepository(abc.ABC):
""" deletes a password reset token """ """ deletes a password reset token """
@abc.abstractmethod @abc.abstractmethod
def clean_stale_reset_tokens(self): def clear_stale_reset_tokens(self):
""" removes invalid reset tokens """ """ removes invalid reset tokens """
@ -175,9 +175,9 @@ class SqlAlchemyRepository(AbstractOrderRepository):
""" get a passowrd reset token from the database""" """ get a passowrd reset token from the database"""
try: try:
return ( return (
self.session.query(models.PassworResetToken) self.session.query(models.PasswordResetToken)
.filter( .filter(
func.lower(models.PassworResetToken.token) func.lower(models.PasswordResetToken.token)
== func.lower(reference) == func.lower(reference)
) )
.one() .one()
@ -185,8 +185,8 @@ class SqlAlchemyRepository(AbstractOrderRepository):
except NoResultFound as exc: except NoResultFound as exc:
raise RepoItemNotFound from exc raise RepoItemNotFound from exc
def clean_stale_reset_tokens(self): def clear_stale_reset_tokens(self):
""" removes invalid reset tokens """ """ removes invalid reset tokens """
self.session.delete(models.PassworResetToken).filter( self.session.query(models.PasswordResetToken).filter(
models.PassworResetToken.valid_unit < datetime.utcnow() models.PasswordResetToken.valid_until < datetime.utcnow()
) ).delete()

35
ordr3/schemas/account.py

@ -79,3 +79,38 @@ class RegistrationSchema(CSRFSchema):
} }
settings.update(override) settings.update(override)
return super().as_form(request, **settings) return super().as_form(request, **settings)
class ForgottenPasswordSchema(CSRFSchema):
email_or_username = colander.SchemaNode(colander.String())
@classmethod
def as_form(cls, request, **override):
settings = {
"buttons": ("Send Reset Link", "Cancel"),
"css_class": "form-horizontal registration",
}
settings.update(override)
return super().as_form(request, **settings)
class ResetPasswordSchema(CSRFSchema):
new_password = colander.SchemaNode(
colander.String(),
widget=deform.widget.PasswordWidget(template="viewable_password.pt"),
)
@classmethod
def as_form(cls, request, token, **override):
url = request.resource_url(
request.context, request.view_name, query={"t": token}
)
settings = {
"buttons": ("Reset Password", "Cancel"),
"css_class": "form-horizontal registration",
"url": url,
}
settings.update(override)
return super().as_form(request, **settings)

8
ordr3/services.py

@ -1,3 +1,4 @@
import uuid
import hashlib import hashlib
from datetime import datetime from datetime import datetime
from collections import namedtuple from collections import namedtuple
@ -151,6 +152,13 @@ def _check_have_i_been_pwned(password_hash, event_queue):
return False return False
def create_token_for_user(repo, user):
reference = str(uuid.uuid4())
token = models.PasswordResetToken(reference, user.id)
repo.add_reset_token(token)
return token
def get_user_from_reset_token(repo, identifier): def get_user_from_reset_token(repo, identifier):
try: try:
token = repo.get_reset_token(identifier) token = repo.get_reset_token(identifier)

30
ordr3/templates/account/forgotten_password.jinja2

@ -0,0 +1,30 @@
{% extends "ordr3:templates/layout_small.jinja2" %}
{% block subtitle %} Forgot your Password? {% endblock subtitle %}
{% block content %}
<div class="container">
<div class="row o3-registration-card">
<div class="col"></div>
<div class="col">
<div class="card">
<div class="card-header bg-dark text-light">
<h2 class="card-title text-center pt-1">Ordr</h5>
</div>
<div class="card-body">
<h6 class="card-subtitle mb-2 text-muted text-center mt-1 mb-4">Forgot your Password?</h6>
<p class="mb-4">
Please provide your username or email address,
you will receive an email with a one-time usable
link to reset your password.
</p>
{{form.render()|safe}}
</div>
</div>
</div>
<div class="col"></div>
</div>
</div>
{% endblock content %}

33
ordr3/templates/account/password_reseted.jinja2

@ -0,0 +1,33 @@
{% extends "ordr3:templates/layout_small.jinja2" %}
{% block subtitle %} Password reseted {% endblock subtitle %}
{% block content %}
<div class="container">
<div class="row o3-registration-card">
<div class="col"></div>
<div class="col">
<div class="card">
<div class="card-header bg-dark text-light">
<h2 class="card-title text-center pt-1">Ordr</h5>
</div>
<div class="card-body">
<h6 class="card-subtitle mb-2 text-muted text-center mt-1 mb-4">Password reseted</h6>
{% for message in request.session.pop_flash("warning") %}
<div class="alert alert-warning" role="alert">
<p class="h6">{{message[0]}}</p>
<p class="small">{{message[1]|safe}}</p>
</div>
{% endfor %}
<p>You changed your Password.<p>
<p>You can now <a href="/login">try to login</a> in again.</p>
</div>
</div>
</div>
<div class="col"></div>
</div>
</div>
{% endblock content %}

27
ordr3/templates/account/reset_link_sent.jinja2

@ -0,0 +1,27 @@
{% extends "ordr3:templates/layout_small.jinja2" %}
{% block subtitle %} Reset Link Sent {% endblock subtitle %}
{% block content %}
<div class="container">
<div class="row o3-registration-card">
<div class="col"></div>
<div class="col">
<div class="card">
<div class="card-header bg-dark text-light">
<h2 class="card-title text-center pt-1">Ordr</h5>
</div>
<div class="card-body">
<h6 class="card-subtitle mb-2 text-muted text-center mt-1 mb-4">Password Reset Email</h6>
<p>An email for the password reset was sent to the address you provided.<p>
<p>Please follow the link in the email to reset your password.</p>
<p>The provided link is valid for one hour.</p>
</div>
</div>
</div>
<div class="col"></div>
</div>
</div>
{% endblock content %}

28
ordr3/templates/account/reset_password_form.jinja2

@ -0,0 +1,28 @@
{% extends "ordr3:templates/layout_small.jinja2" %}
{% block subtitle %} Reset your Password {% endblock subtitle %}
{% block content %}
<div class="container">
<div class="row o3-registration-card">
<div class="col"></div>
<div class="col">
<div class="card">
<div class="card-header bg-dark text-light">
<h2 class="card-title text-center pt-1">Ordr</h5>
</div>
<div class="card-body">
<h6 class="card-subtitle mb-2 text-muted text-center mt-1 mb-4">Reset your Password</h6>
<p class="mb-4">
You can now set a new password:
</p>
{{form.render()|safe}}
</div>
</div>
</div>
<div class="col"></div>
</div>
</div>
{% endblock content %}

12
ordr3/templates/emails/password_reset.jinja2

@ -0,0 +1,12 @@
Dear {{ user.first_name }},
If you forgot your password, you can set a new one by visiting this link:
{{ request.resource_url(request.root, 'reset', query={"t": data}) }}
The provided link is valid for one hour.
Regards,
The Ordr System
--
Please don't respont to this email! This is an automatically generated notification.

120
ordr3/views/account.py

@ -7,7 +7,9 @@ from pyramid.security import forget, remember
from pyramid.httpexceptions import HTTPFound from pyramid.httpexceptions import HTTPFound
from .. import models, security, services from .. import models, security, services
from ..schemas.account import RegistrationSchema from ..repo import RepoItemNotFound
from ..events import PasswordResetEmail
from ..schemas import account
@view_config( @view_config(
@ -63,7 +65,7 @@ def logout(context, request):
renderer="ordr3:templates/account/registration.jinja2", renderer="ordr3:templates/account/registration.jinja2",
) )
def registration(context, request): def registration(context, request):
form = RegistrationSchema.as_form(request) form = account.RegistrationSchema.as_form(request)
return {"form": form} return {"form": form}
@ -78,14 +80,14 @@ def register_new_user(context, request):
if "Cancel" in request.POST: if "Cancel" in request.POST:
return HTTPFound(request.resource_path(request.root)) return HTTPFound(request.resource_path(request.root))
form = RegistrationSchema.as_form(request) form = account.RegistrationSchema.as_form(request)
data = request.POST.items() data = request.POST.items()
try: try:
appstruct = form.validate(data) appstruct = form.validate(data)
except deform.ValidationFailure: except deform.ValidationFailure:
return {"form": form} return {"form": form}
account = models.User( new_user = models.User(
id=None, id=None,
password=None, password=None,
username=appstruct["user_name"], username=appstruct["user_name"],
@ -94,8 +96,8 @@ def register_new_user(context, request):
email=appstruct["email"], email=appstruct["email"],
role=models.UserRole.NEW, role=models.UserRole.NEW,
) )
services.set_new_password(account, appstruct["password"], request) services.set_new_password(new_user, appstruct["password"], request)
request.repo.add_user(account) request.repo.add_user(new_user)
return HTTPFound(request.resource_path(request.root, "registered")) return HTTPFound(request.resource_path(request.root, "registered"))
@ -118,3 +120,109 @@ def registration_complete(context, request):
) )
def breached_password(context, request): def breached_password(context, request):
return {} return {}
@view_config(
context="ordr3:resources.Root",
name="forgot",
permission="registration",
request_method="GET",
renderer="ordr3:templates/account/forgotten_password.jinja2",
)
def forgotten_password(context, request):
form = account.ForgottenPasswordSchema.as_form(request)
return {"form": form}
@view_config(
context="ordr3:resources.Root",
name="forgot",
permission="registration",
request_method="POST",
renderer="ordr3:templates/account/forgotten_password.jinja2",
)
def send_reset_link(context, request):
if "Cancel" in request.POST:
return HTTPFound(request.resource_path(request.root))
provided_identifier = request.POST.get("email_or_username")
try:
user = request.repo.get_user_by_username(provided_identifier)
except RepoItemNotFound:
try:
user = request.repo.get_user_by_email(provided_identifier)
except RepoItemNotFound:
user = None
if user is not None and user.is_active:
token = services.create_token_for_user(request.repo, user)
request.emit(PasswordResetEmail(user, token.token))
return HTTPFound(request.resource_path(request.root, "sent"))
@view_config(
context="ordr3:resources.Root",
name="sent",
permission="view",
renderer="ordr3:templates/account/reset_link_sent.jinja2",
)
def reset_link_sent(context, request):
return {}
@view_config(
context="ordr3:resources.Root",
name="reset",
permission="view",
request_method="GET",
renderer="ordr3:templates/account/reset_password_form.jinja2",
)
def reset_password_form(context, request):
token = request.GET.get("t")
user = services.get_user_from_reset_token(request.repo, token)
if user is None:
return HTTPFound(request.resource_path(request.root))
form = account.ResetPasswordSchema.as_form(request, token)
return {"form": form}
@view_config(
context="ordr3:resources.Root",
name="reset",
permission="view",
request_method="POST",
renderer="ordr3:templates/account/reset_password_form.jinja2",
)
def reset_password(context, request):
if "Cancel" in request.POST:
return HTTPFound(request.resource_path(request.root))
token = request.GET.get("t")
user = services.get_user_from_reset_token(request.repo, token)
if user is None:
return HTTPFound(request.resource_path(request.root))
form = account.ResetPasswordSchema.as_form(request, token)
data = request.POST.items()
try:
appstruct = form.validate(data)
except deform.ValidationFailure:
return {"form": form}
services.set_new_password(user, appstruct["new_password"], request)
request.repo.delete_reset_token(request.repo.get_reset_token(token))
request.repo.clear_stale_reset_tokens()
return HTTPFound(request.resource_path(request.root, "reseted"))
@view_config(
context="ordr3:resources.Root",
name="reseted",
permission="view",
renderer="ordr3:templates/account/password_reseted.jinja2",
)
def password_reseted(context, request):
return {}
# http://localhost:6543/reset?t=69e24c08-1cb2-4656-987a-4791175f3368

2
tests/test_services.py

@ -62,7 +62,7 @@ class FakeOrderRepository(AbstractOrderRepository):
""" add an password reset token """ """ add an password reset token """
return next(t for t in self._tokens if t.token == reference) return next(t for t in self._tokens if t.token == reference)
def clean_stale_reset_tokens(self): def clear_stale_reset_tokens(self):
""" removes invalid reset tokens """ """ removes invalid reset tokens """
now = datetime.utcnow() now = datetime.utcnow()
self._tokens = {t for t in self._tokens if t.valid_until > now} self._tokens = {t for t in self._tokens if t.valid_until > now}

Loading…
Cancel
Save