diff --git a/.gitignore b/.gitignore
index 62d1801..4772a86 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,9 @@
# current database, edited all the time
ordr3.sqlite
+# development mail folder
+mail/
+
# ---> Python
# Byte-compiled / optimized / DLL files
__pycache__/
diff --git a/development.ini b/development.ini
index f7ac672..03a509f 100644
--- a/development.ini
+++ b/development.ini
@@ -13,6 +13,7 @@ pyramid.debug_routematch = false
pyramid.default_locale_name = en
pyramid.includes =
pyramid_debugtoolbar
+ pyramid_mailer.debug
sqlalchemy.url = sqlite:///%(here)s/ordr3.sqlite
@@ -25,6 +26,11 @@ session.auto_csrf = true
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
# '127.0.0.1' and '::1'.
# debugtoolbar.hosts = 127.0.0.1 ::1
diff --git a/ordr3.sqlite b/ordr3.sqlite
deleted file mode 100644
index bbcbfb7..0000000
Binary files a/ordr3.sqlite and /dev/null differ
diff --git a/ordr3/adapters.py b/ordr3/adapters.py
index 3591240..d38e406 100644
--- a/ordr3/adapters.py
+++ b/ordr3/adapters.py
@@ -86,7 +86,7 @@ reset_token_table = Table(
metadata,
Column("token", Text, primary_key=True),
Column("user_id", Integer, nullable=False),
- Column("valid_unitl", DateTime, nullable=False),
+ Column("valid_until", DateTime, nullable=False),
)
diff --git a/ordr3/events.py b/ordr3/events.py
index b4f5769..f405e8d 100644
--- a/ordr3/events.py
+++ b/ordr3/events.py
@@ -1,6 +1,8 @@
from collections import namedtuple
from pyramid.events import subscriber
+from pyramid.renderers import render
+from pyramid_mailer.message import Message
SerializableFlashMessage = namedtuple(
"SerializableFlashMessage", ["text", "more"]
@@ -32,6 +34,39 @@ class FlashMessage(Ordr3Event):
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)
def handle_flash_message_event(event):
if event.request is None:
@@ -42,6 +77,21 @@ def handle_flash_message_event(event):
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):
event.request = request
request.registry.notify(event)
diff --git a/ordr3/repo.py b/ordr3/repo.py
index d603df6..3e94211 100644
--- a/ordr3/repo.py
+++ b/ordr3/repo.py
@@ -69,7 +69,7 @@ class AbstractOrderRepository(abc.ABC):
""" deletes a password reset token """
@abc.abstractmethod
- def clean_stale_reset_tokens(self):
+ def clear_stale_reset_tokens(self):
""" removes invalid reset tokens """
@@ -175,9 +175,9 @@ class SqlAlchemyRepository(AbstractOrderRepository):
""" get a passowrd reset token from the database"""
try:
return (
- self.session.query(models.PassworResetToken)
+ self.session.query(models.PasswordResetToken)
.filter(
- func.lower(models.PassworResetToken.token)
+ func.lower(models.PasswordResetToken.token)
== func.lower(reference)
)
.one()
@@ -185,8 +185,8 @@ class SqlAlchemyRepository(AbstractOrderRepository):
except NoResultFound as exc:
raise RepoItemNotFound from exc
- def clean_stale_reset_tokens(self):
+ def clear_stale_reset_tokens(self):
""" removes invalid reset tokens """
- self.session.delete(models.PassworResetToken).filter(
- models.PassworResetToken.valid_unit < datetime.utcnow()
- )
+ self.session.query(models.PasswordResetToken).filter(
+ models.PasswordResetToken.valid_until < datetime.utcnow()
+ ).delete()
diff --git a/ordr3/schemas/account.py b/ordr3/schemas/account.py
index 4195847..3942889 100644
--- a/ordr3/schemas/account.py
+++ b/ordr3/schemas/account.py
@@ -79,3 +79,38 @@ class RegistrationSchema(CSRFSchema):
}
settings.update(override)
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)
diff --git a/ordr3/services.py b/ordr3/services.py
index b1365ea..ed793c4 100644
--- a/ordr3/services.py
+++ b/ordr3/services.py
@@ -1,3 +1,4 @@
+import uuid
import hashlib
from datetime import datetime
from collections import namedtuple
@@ -151,6 +152,13 @@ def _check_have_i_been_pwned(password_hash, event_queue):
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):
try:
token = repo.get_reset_token(identifier)
diff --git a/ordr3/templates/account/forgotten_password.jinja2 b/ordr3/templates/account/forgotten_password.jinja2
new file mode 100644
index 0000000..25e968c
--- /dev/null
+++ b/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 %}
+
+
+
+
+
+
+
+
+
Forgot your Password?
+
+ Please provide your username or email address,
+ you will receive an email with a one-time usable
+ link to reset your password.
+
+ {{form.render()|safe}}
+
+
+
+
+
+
+
+{% endblock content %}
diff --git a/ordr3/templates/account/password_reseted.jinja2 b/ordr3/templates/account/password_reseted.jinja2
new file mode 100644
index 0000000..95b2a71
--- /dev/null
+++ b/ordr3/templates/account/password_reseted.jinja2
@@ -0,0 +1,33 @@
+{% extends "ordr3:templates/layout_small.jinja2" %}
+
+{% block subtitle %} Password reseted {% endblock subtitle %}
+
+{% block content %}
+
+
+
+
+
+
+
+
+
Password reseted
+ {% for message in request.session.pop_flash("warning") %}
+
+
{{message[0]}}
+
{{message[1]|safe}}
+
+ {% endfor %}
+
+
You changed your Password.
+
You can now try to login in again.
+
+
+
+
+
+
+
+{% endblock content %}
diff --git a/ordr3/templates/account/reset_link_sent.jinja2 b/ordr3/templates/account/reset_link_sent.jinja2
new file mode 100644
index 0000000..4f8eace
--- /dev/null
+++ b/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 %}
+
+
+
+
+
+
+
+
+
Password Reset Email
+
An email for the password reset was sent to the address you provided.
+
Please follow the link in the email to reset your password.
+
The provided link is valid for one hour.
+
+
+
+
+
+
+
+{% endblock content %}
diff --git a/ordr3/templates/account/reset_password_form.jinja2 b/ordr3/templates/account/reset_password_form.jinja2
new file mode 100644
index 0000000..58377fa
--- /dev/null
+++ b/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 %}
+
+
+
+
+
+
+
+
+
Reset your Password
+
+ You can now set a new password:
+
+ {{form.render()|safe}}
+
+
+
+
+
+
+
+{% endblock content %}
diff --git a/ordr3/templates/emails/password_reset.jinja2 b/ordr3/templates/emails/password_reset.jinja2
new file mode 100644
index 0000000..bb55dfc
--- /dev/null
+++ b/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.
diff --git a/ordr3/views/account.py b/ordr3/views/account.py
index df8e27d..ce551fb 100644
--- a/ordr3/views/account.py
+++ b/ordr3/views/account.py
@@ -7,7 +7,9 @@ from pyramid.security import forget, remember
from pyramid.httpexceptions import HTTPFound
from .. import models, security, services
-from ..schemas.account import RegistrationSchema
+from ..repo import RepoItemNotFound
+from ..events import PasswordResetEmail
+from ..schemas import account
@view_config(
@@ -63,7 +65,7 @@ def logout(context, request):
renderer="ordr3:templates/account/registration.jinja2",
)
def registration(context, request):
- form = RegistrationSchema.as_form(request)
+ form = account.RegistrationSchema.as_form(request)
return {"form": form}
@@ -78,14 +80,14 @@ def register_new_user(context, request):
if "Cancel" in request.POST:
return HTTPFound(request.resource_path(request.root))
- form = RegistrationSchema.as_form(request)
+ form = account.RegistrationSchema.as_form(request)
data = request.POST.items()
try:
appstruct = form.validate(data)
except deform.ValidationFailure:
return {"form": form}
- account = models.User(
+ new_user = models.User(
id=None,
password=None,
username=appstruct["user_name"],
@@ -94,8 +96,8 @@ def register_new_user(context, request):
email=appstruct["email"],
role=models.UserRole.NEW,
)
- services.set_new_password(account, appstruct["password"], request)
- request.repo.add_user(account)
+ services.set_new_password(new_user, appstruct["password"], request)
+ request.repo.add_user(new_user)
return HTTPFound(request.resource_path(request.root, "registered"))
@@ -118,3 +120,109 @@ def registration_complete(context, request):
)
def breached_password(context, request):
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
diff --git a/tests/test_services.py b/tests/test_services.py
index 909c608..3fc517f 100644
--- a/tests/test_services.py
+++ b/tests/test_services.py
@@ -62,7 +62,7 @@ class FakeOrderRepository(AbstractOrderRepository):
""" add an password reset token """
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 """
now = datetime.utcnow()
self._tokens = {t for t in self._tokens if t.valid_until > now}