From cb9a0429bbc8c175b4771b5ee8854ee273f327e1 Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Fri, 1 Jul 2022 02:51:05 +0200 Subject: implement password reset tokens --- development.ini | 3 + fietsboek/email.py | 69 ++++++++++++++++++++++ fietsboek/models/__init__.py | 2 +- fietsboek/models/user.py | 74 +++++++++++++++++++++++ fietsboek/routes.py | 3 + fietsboek/templates/password_reset.jinja2 | 21 +++++++ fietsboek/templates/request_password.jinja2 | 20 +++++++ fietsboek/views/default.py | 91 ++++++++++++++++++++++++++++- fietsboek/views/upload.py | 7 ++- 9 files changed, 285 insertions(+), 5 deletions(-) create mode 100644 fietsboek/email.py create mode 100644 fietsboek/templates/password_reset.jinja2 create mode 100644 fietsboek/templates/request_password.jinja2 diff --git a/development.ini b/development.ini index 608f400..3820779 100644 --- a/development.ini +++ b/development.ini @@ -20,6 +20,9 @@ sqlalchemy.url = sqlite:///%(here)s/fietsboek.sqlite retry.attempts = 3 +email.from = fietsboek@kingdread.de +email.smtp_url = debug://localhost:1025 + session_key = hklurha7ildshgfljhrbuajelghug # By default, the toolbar only appears for clients from IP addresses diff --git a/fietsboek/email.py b/fietsboek/email.py new file mode 100644 index 0000000..c07c97d --- /dev/null +++ b/fietsboek/email.py @@ -0,0 +1,69 @@ +"""Utility functions for email sending.""" +import logging +import smtplib +import sys + +from urllib.parse import urlparse +from email.message import EmailMessage + +LOGGER = logging.getLogger(__name__) + + +def prepare_message(settings, addr_to, subject): + """Prepares an email message with the right headers. + + The body of the message can be set by using + :meth:`~email.message.EmailMessage.set_content` on the returned message. + + :param settings: The application settings, used to access the email + specific settings. + :type settings: dict + :param addr_to: Address of the recipient. + :type addr_to: str + :param subject: Subject of the message. + :type subject: str + :return: A prepared message. + :rtype: email.message.EmailMessage + """ + from_address = settings.get('email.from') + if not from_address: + LOGGER.warning("`email.from` is not set, make sure to check your configuration!") + message = EmailMessage() + message['To'] = addr_to + message['From'] = f'Fietsboek <{from_address}>' + message['Subject'] = subject + return message + + +def send_message(settings, message): + """Sends an email message using the STMP server configured in the settings. + + The recipient is taken from the 'To'-header of the message. + + :parm settings: The application settings. + :type settings: dict + :param message: The message to send. + :type message: email.message.EmailMessage + """ + smtp_server = settings.get('email.smtp_url') + if not smtp_server: + LOGGER.warning("`email.smtp_url` not set, no email can be sent!") + return + smtp_url = urlparse(smtp_server) + if smtp_url.scheme == 'debug': + print(message, file=sys.stderr) + return + try: + if smtp_url.scheme == 'smtp': + client = smtplib.SMTP(smtp_url.hostname, smtp_url.port) + elif smtp_url.scheme == 'smtp+ssl': + client = smtplib.SMTP_SSL(smtp_url.hostname, smtp_url.port) + elif smtp_url.scheme == 'smtp+starttls': + client = smtplib.SMTP(smtp_url.hostname, smtp_url.port) + client.starttls() + if 'email.smtp_user' in settings and 'email.smtp_password' in settings: + client.login(settings['email.smtp_user'], settings['email.smtp_password']) + client.send_message(message) + client.quit() + except smtplib.SMTPException: + LOGGER.error("Error when sending an email", exc_info=sys.exc_info()) diff --git a/fietsboek/models/__init__.py b/fietsboek/models/__init__.py index f77433e..fb24f39 100644 --- a/fietsboek/models/__init__.py +++ b/fietsboek/models/__init__.py @@ -10,7 +10,7 @@ import zope.sqlalchemy # Import or define all models here to ensure they are attached to the # ``Base.metadata`` prior to any initialization routines. -from .user import User, FriendRequest # flake8: noqa +from .user import User, FriendRequest, Token # flake8: noqa from .badge import Badge # flake8: noqa from .track import Track, TrackCache, Upload # flake8: noqa diff --git a/fietsboek/models/user.py b/fietsboek/models/user.py index 72e64c2..0f0469f 100644 --- a/fietsboek/models/user.py +++ b/fietsboek/models/user.py @@ -1,4 +1,7 @@ """User models for fietsboek.""" +import datetime +import enum +import uuid import os from sqlalchemy import ( @@ -12,6 +15,7 @@ from sqlalchemy import ( ForeignKey, UniqueConstraint, DateTime, + Enum, ) from sqlalchemy.orm import relationship from sqlalchemy.orm.session import object_session @@ -68,12 +72,16 @@ class User(Base): :vartype email: str :ivar is_admin: Flag determining whether this user has admin access. :vartype is_admin: bool + :ivar is_verified: Flag determining whether this user has been verified. + :vartype is_verified: bool :ivar tracks: Tracks owned by this user. :vartype tracks: list[fietsboek.models.track.Track] :ivar tagged_tracks: Tracks in which this user is tagged. :vartype tracks: list[fietsboek.models.track.Track] :ivar uploads: Currently ongoing uploads by this user. :vartype uploads: list[fietsboek.models.track.Upload] + :ivar tokens: List of tokens that this user can use. + :vartype tokens: list[fietsboek.models.user.Token] """ __tablename__ = 'users' id = Column(Integer, primary_key=True) @@ -82,11 +90,13 @@ class User(Base): salt = Column(LargeBinary) email = Column(Text) is_admin = Column(Boolean) + is_verified = Column(Boolean) tracks = relationship('Track', back_populates='owner') tagged_tracks = relationship('Track', secondary='track_people_assoc', back_populates='tagged_people') uploads = relationship('Upload', back_populates='owner') + tokens = relationship('Token', back_populates='user') @classmethod def query_by_email(cls, email): @@ -221,3 +231,67 @@ class FriendRequest(Base): backref='incoming_requests') __table_args__ = (UniqueConstraint('sender_id', 'recipient_id'),) + + +class TokenType(enum.Enum): + """Type of tokens. + + A token can be used either to verify the user's email, or it can be used to + reset the password. + """ + VERIFY_EMAIL = enum.auto() + """A token that can be used to verify a user's email.""" + RESET_PASSWORD = enum.auto() + """A token that can be used to reset a user's password.""" + + +class Token(Base): + """A token is something that a user can use to perform certain account + related functions. + + A token with type :const:`TokenType.VERIFY_EMAIL` can be used by the user to + verify their email address. + + A token with type :const:`TokenType.RESET_PASSWORD` can be used by the user + to reset their password. + + :ivar id: Database ID. + :vartype id: int + :ivar user_id: ID of the user. + :vartype user_id: int + :ivar uuid: The token UUID. + :vartype uuid: str + :ivar token_type: The type of the token. + :vartype token_type: TokenType + :ivar date: Date of the token creation. + :vartype date: datetime.datetime + :ivar user: User that this token belongs to. + :vartype user: User + """ + # pylint: disable=too-few-public-methods + __tablename__ = "tokens" + id = Column(Integer, primary_key=True) + user_id = Column(Integer, ForeignKey('users.id')) + uuid = Column(Text) + token_type = Column(Enum(TokenType)) + date = Column(DateTime) + + user = relationship('User', back_populates='tokens') + + @classmethod + def generate(cls, user, token_type): + """Generate a new token for the given user. + + :param user: The user which to generate the token for. + :type user: User + :param token_type: The type of the token to generate. + :type token_type: TokenType + :return: The generated token. + :rtype: Token + """ + token_uuid = str(uuid.uuid4()) + now = datetime.datetime.now() + return cls(user=user, uuid=token_uuid, date=now, token_type=token_type) + + +Index('idx_token_uuid', Token.uuid, unique=True) diff --git a/fietsboek/routes.py b/fietsboek/routes.py index 1d7694c..4113913 100644 --- a/fietsboek/routes.py +++ b/fietsboek/routes.py @@ -6,6 +6,9 @@ def includeme(config): config.add_route('login', '/login') config.add_route('logout', '/logout') + config.add_route('password-reset', '/password-reset') + config.add_route('use-token', '/token/{uuid}') + config.add_route('upload', '/upload') config.add_route('preview', '/preview/{id}.gpx') config.add_route('finish-upload', '/upload/{id}') diff --git a/fietsboek/templates/password_reset.jinja2 b/fietsboek/templates/password_reset.jinja2 new file mode 100644 index 0000000..892837f --- /dev/null +++ b/fietsboek/templates/password_reset.jinja2 @@ -0,0 +1,21 @@ +{% extends "layout.jinja2" %} +{% block content %} +
+

{{ _("page.password_reset.title") }}

+
+
+ +
+ +
+
+
+ +
+ +
+
+ +
+
+{% endblock %} diff --git a/fietsboek/templates/request_password.jinja2 b/fietsboek/templates/request_password.jinja2 new file mode 100644 index 0000000..4acf1eb --- /dev/null +++ b/fietsboek/templates/request_password.jinja2 @@ -0,0 +1,20 @@ +{% extends "layout.jinja2" %} +{% block content %} +
+

{{ _("page.request_password.title") }}

+

{{ _("page.request_password.info") }}

+
+
+
+ +
+
+ +
+
+ +
+
+
+
+{% endblock %} diff --git a/fietsboek/views/default.py b/fietsboek/views/default.py index 0170e02..9c417d2 100644 --- a/fietsboek/views/default.py +++ b/fietsboek/views/default.py @@ -1,13 +1,15 @@ """Home views.""" from pyramid.view import view_config -from pyramid.httpexceptions import HTTPFound +from pyramid.httpexceptions import HTTPFound, HTTPNotFound from pyramid.security import remember, forget from pyramid.i18n import TranslationString as _ +from pyramid.renderers import render_to_response +from sqlalchemy import select from sqlalchemy.exc import NoResultFound -from .. import models, summaries, util -from ..models.user import PasswordMismatch +from .. import models, summaries, util, email +from ..models.user import PasswordMismatch, TokenType @view_config(route_name='home', renderer='fietsboek:templates/home.jinja2') @@ -83,3 +85,86 @@ def logout(request): request.session.flash(request.localizer.translate(_('flash.logged_out'))) headers = forget(request) return HTTPFound('/', headers=headers) + + +@view_config(route_name="password-reset", request_method="GET", + renderer="fietsboek:templates/request_password.jinja2") +def password_reset(request): + """Form to request a new password. + + :param request: The Pyramid request. + :type request: pyramid.request.Request + :return: The HTTP response. + :rtype: pyramid.response.Response + """ + # pylint: disable=unused-argument + return {} + + +@view_config(route_name="password-reset", request_method="POST") +def do_password_reset(request): + """Endpoint for the password request form. + + :param request: The Pyramid request. + :type request: pyramid.request.Request + :return: The HTTP response. + :rtype: pyramid.response.Response + """ + query = models.User.query_by_email(request.params['email']) + user = request.dbsession.execute(query).scalar_one_or_none() + if user is None: + request.session.flash(request.localizer.translate(_("flash.reset_invalid_email"))) + return HTTPFound(request.route_url('password-reset')) + + token = models.Token.generate(user, TokenType.RESET_PASSWORD) + request.dbsession.add(token) + request.session.flash(request.localizer.translate(_("flash.password_token_generated"))) + + mail = email.prepare_message( + request.registry.settings, + user.email, + request.localizer.translate(_("page.password_reset.email.subject")), + ) + mail.set_content( + request + .localizer + .translate(_("page.password_reset.email.body")) + .format(request.route_url('use-token', uuid=token.uuid)) + ) + email.send_message(request.registry.settings, mail) + + return HTTPFound(request.route_url('password-reset')) + + +@view_config(route_name="use-token") +def use_token(request): + """Endpoint with which a user can use a token for a password reset or email + verification. + + :param request: The Pyramid request. + :type request: pyramid.request.Request + :return: The HTTP response. + :rtype: pyramid.response.Response + """ + token = request.dbsession.execute( + select(models.Token).filter_by(uuid=request.matchdict['uuid']) + ).scalar_one_or_none() + if token is None: + return HTTPNotFound() + + if token.token_type == TokenType.VERIFY_EMAIL: + token.user.is_verified = True + request.session.flash(request.localizer.translate(_('flash.email_verified'))) + return HTTPFound(request.route_url('login')) + if request.method == 'GET' and token.token_type == TokenType.RESET_PASSWORD: + return render_to_response('fietsboek:templates/password_reset.jinja2', {}, request) + if request.method == 'POST' and token.token_type == TokenType.RESET_PASSWORD: + if request.params["password"] != request.params["repeat-password"]: + request.session.flash(request.localizer.translate(_("flash.password_mismatch"))) + return HTTPFound(request.route_url('use-token', uuid=token.uuid)) + + token.user.set_password(request.params["password"]) + request.dbsession.delete(token) + request.session.flash(request.localizer.translate(_("flash.password_updated"))) + return HTTPFound(request.route_url("login")) + return None diff --git a/fietsboek/views/upload.py b/fietsboek/views/upload.py index d05cf81..fdb0a38 100644 --- a/fietsboek/views/upload.py +++ b/fietsboek/views/upload.py @@ -1,5 +1,6 @@ """Upload functionality.""" import datetime +import logging from pyramid.httpexceptions import HTTPFound, HTTPForbidden from pyramid.response import Response @@ -14,6 +15,9 @@ from .. import models, util from ..models.track import Visibility +LOGGER = logging.getLogger(__name__) + + @view_config(route_name='upload', renderer='fietsboek:templates/upload.jinja2', request_method='GET', permission='upload') def show_upload(request): @@ -53,8 +57,9 @@ def do_upload(request): # pylint: disable=broad-except try: gpxpy.parse(gpx) - except Exception: + except Exception as exc: request.session.flash(request.localizer.translate(_('flash.invalid_file'))) + LOGGER.info("Could not parse gpx: %s", exc) return HTTPFound(request.route_url('upload')) now = datetime.datetime.now() -- cgit v1.2.3