aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDaniel Schadt <kingdread@gmx.de>2022-07-01 02:51:05 +0200
committerDaniel Schadt <kingdread@gmx.de>2022-07-01 02:51:05 +0200
commitcb9a0429bbc8c175b4771b5ee8854ee273f327e1 (patch)
treec1fdf108b964c747f33c5c5d12496ad11b52c6b5
parent4482a367f1d2266fb1649397e1524fc8ef501467 (diff)
downloadfietsboek-cb9a0429bbc8c175b4771b5ee8854ee273f327e1.tar.gz
fietsboek-cb9a0429bbc8c175b4771b5ee8854ee273f327e1.tar.bz2
fietsboek-cb9a0429bbc8c175b4771b5ee8854ee273f327e1.zip
implement password reset tokens
-rw-r--r--development.ini3
-rw-r--r--fietsboek/email.py69
-rw-r--r--fietsboek/models/__init__.py2
-rw-r--r--fietsboek/models/user.py74
-rw-r--r--fietsboek/routes.py3
-rw-r--r--fietsboek/templates/password_reset.jinja221
-rw-r--r--fietsboek/templates/request_password.jinja220
-rw-r--r--fietsboek/views/default.py91
-rw-r--r--fietsboek/views/upload.py7
9 files changed, 285 insertions, 5 deletions
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 %}
+<div class="container">
+ <h1>{{ _("page.password_reset.title") }}</h1>
+ <form method="POST">
+ <div class="row mb-3">
+ <label for="inputPassword" class="col-sm-3 col-form-label">{{ _("page.password_reset.password") }}</label>
+ <div class="col-sm-9">
+ <input type="password" class="form-control" id="inputPassword" name="password">
+ </div>
+ </div>
+ <div class="row mb-3">
+ <label for="repeatPassword" class="col-sm-3 col-form-label">{{ _("page.password_reset.repeat_password") }}</label>
+ <div class="col-sm-9">
+ <input type="password" class="form-control" id="repeatPassword" name="repeat-password">
+ </div>
+ </div>
+ <button type="submit" class="btn btn-primary">{{ _("page.password_reset.reset") }}</button>
+ </form>
+</diV>
+{% 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 %}
+<div class="container">
+ <h1>{{ _("page.request_password.title") }}</h1>
+ <p>{{ _("page.request_password.info") }}</p>
+ <form method="POST">
+ <div class="row">
+ <div class="col-lg-4 d-flex align-items-center">
+ <label for="resetEmail">{{ _("page.request_password.email") }}</label>
+ </div>
+ <div class="col-lg-4">
+ <input type="email" id="resetEmail" name="email" class="form-control">
+ </div>
+ <div class="col-lg-4">
+ <button class="btn btn-primary">{{ _("page.request_password.request") }}</button>
+ </div>
+ </div>
+ </form>
+</div>
+{% 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()