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 %} + +
+
+
+
+
+
+

Ordr

+
+
+
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 %} + +
+
+
+
+
+
+

Ordr

+
+
+
Password reseted
+ {% for message in request.session.pop_flash("warning") %} + + {% 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 %} + +
+
+
+
+
+
+

Ordr

+
+
+
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 %} + +
+
+
+
+
+
+

Ordr

+
+
+
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}