From 51d627fa1cc401e30a50d62bc3a06171a1f2b840 Mon Sep 17 00:00:00 2001 From: Sam Date: Sun, 17 May 2020 16:42:44 +0200 Subject: [PATCH] API - add route to update password and email templates - #50 --- .flake8 | 2 + Makefile.custom.config.example | 5 +- fittrackee_api/fittrackee_api/__init__.py | 12 +- fittrackee_api/fittrackee_api/config.py | 5 + fittrackee_api/fittrackee_api/email/email.py | 103 +++++++ .../password_reset_request/en/body.html | 268 +++++++++++++++++ .../password_reset_request/en/body.txt | 10 + .../password_reset_request/en/subject.txt | 1 + .../password_reset_request/fr/body.html | 270 ++++++++++++++++++ .../password_reset_request/fr/body.txt | 12 + .../password_reset_request/fr/subject.txt | 1 + .../fittrackee_api/email/utils_email.py | 20 ++ .../fittrackee_api/tests/conftest.py | 17 +- .../password_reset_request.py | 175 ++++++++++++ .../fittrackee_api/tests/test_auth_api.py | 194 +++++++++++-- .../fittrackee_api/tests/test_email.py | 94 ++++++ .../test_email_template_password_request.py | 76 +++++ .../fittrackee_api/tests/test_email_utils.py | 42 +++ .../fittrackee_api/tests/test_users_model.py | 4 + fittrackee_api/fittrackee_api/users/auth.py | 123 +++++++- fittrackee_api/fittrackee_api/users/utils.py | 14 +- fittrackee_api/poetry.lock | 18 +- fittrackee_api/pyproject.toml | 1 + 23 files changed, 1429 insertions(+), 38 deletions(-) create mode 100644 fittrackee_api/fittrackee_api/email/email.py create mode 100644 fittrackee_api/fittrackee_api/email/templates/password_reset_request/en/body.html create mode 100644 fittrackee_api/fittrackee_api/email/templates/password_reset_request/en/body.txt create mode 100644 fittrackee_api/fittrackee_api/email/templates/password_reset_request/en/subject.txt create mode 100644 fittrackee_api/fittrackee_api/email/templates/password_reset_request/fr/body.html create mode 100644 fittrackee_api/fittrackee_api/email/templates/password_reset_request/fr/body.txt create mode 100644 fittrackee_api/fittrackee_api/email/templates/password_reset_request/fr/subject.txt create mode 100644 fittrackee_api/fittrackee_api/email/utils_email.py create mode 100644 fittrackee_api/fittrackee_api/tests/template_results/password_reset_request.py create mode 100644 fittrackee_api/fittrackee_api/tests/test_email.py create mode 100644 fittrackee_api/fittrackee_api/tests/test_email_template_password_request.py create mode 100644 fittrackee_api/fittrackee_api/tests/test_email_utils.py diff --git a/.flake8 b/.flake8 index cf798af2..613b62d2 100644 --- a/.flake8 +++ b/.flake8 @@ -1,3 +1,5 @@ [flake8] per-file-ignores = fittrackee_api/fittrackee_api/activities/stats.py:E501 + fittrackee_api/fittrackee_api/tests/test_email.py:E501 + fittrackee_api/fittrackee_api/tests/test_email_template_password_request.py:E501 diff --git a/Makefile.custom.config.example b/Makefile.custom.config.example index d21607be..cb9e0049 100644 --- a/Makefile.custom.config.example +++ b/Makefile.custom.config.example @@ -1,6 +1,9 @@ -export REACT_APP_API_URL = http://$(HOST):$(API_PORT) +export REACT_APP_API_URL= export REACT_APP_THUNDERFOREST_API_KEY= export WEATHER_API= +export UI_URL= +export EMAIL_URL= +export SENDER_EMAIL= # for dev env export CODACY_PROJECT_TOKEN= diff --git a/fittrackee_api/fittrackee_api/__init__.py b/fittrackee_api/fittrackee_api/__init__.py index b49ef10c..da93f8a4 100644 --- a/fittrackee_api/fittrackee_api/__init__.py +++ b/fittrackee_api/fittrackee_api/__init__.py @@ -1,14 +1,18 @@ import logging import os +from importlib import import_module, reload from flask import Flask from flask_bcrypt import Bcrypt from flask_migrate import Migrate from flask_sqlalchemy import SQLAlchemy +from .email.email import Email + db = SQLAlchemy() bcrypt = Bcrypt() migrate = Migrate() +email_service = Email() appLog = logging.getLogger('fittrackee_api') @@ -19,6 +23,10 @@ def create_app(): # set config with app.app_context(): app_settings = os.getenv('APP_SETTINGS') + if app_settings == 'fittrackee_api.config.TestingConfig': + # reload config on tests + config = import_module('fittrackee_api.config') + reload(config) app.config.from_object(app_settings) # set up extensions @@ -26,6 +34,9 @@ def create_app(): bcrypt.init_app(app) migrate.init_app(app, db) + # set up email + email_service.init_email(app) + # get configuration from database from .application.models import AppConfig from .application.utils import init_config, update_app_config_from_database @@ -64,7 +75,6 @@ def create_app(): logging.getLogger('flake8').propagate = False appLog.setLevel(logging.DEBUG) - if app.debug: # Enable CORS @app.after_request def after_request(response): diff --git a/fittrackee_api/fittrackee_api/config.py b/fittrackee_api/fittrackee_api/config.py index b482611f..9ed95d8a 100644 --- a/fittrackee_api/fittrackee_api/config.py +++ b/fittrackee_api/fittrackee_api/config.py @@ -16,6 +16,10 @@ class BaseConfig: UPLOAD_FOLDER = os.path.join(current_app.root_path, 'uploads') PICTURE_ALLOWED_EXTENSIONS = {'jpg', 'png', 'gif'} ACTIVITY_ALLOWED_EXTENSIONS = {'gpx', 'zip'} + TEMPLATES_FOLDER = os.path.join(current_app.root_path, 'email/templates') + UI_URL = os.environ.get('UI_URL') + EMAIL_URL = os.environ.get('EMAIL_URL') + SENDER_EMAIL = os.environ.get('SENDER_EMAIL') class DevelopmentConfig(BaseConfig): @@ -41,4 +45,5 @@ class TestingConfig(BaseConfig): BCRYPT_LOG_ROUNDS = 4 TOKEN_EXPIRATION_DAYS = 0 TOKEN_EXPIRATION_SECONDS = 3 + PASSWORD_TOKEN_EXPIRATION_SECONDS = 3 UPLOAD_FOLDER = '/tmp/fitTrackee/uploads' diff --git a/fittrackee_api/fittrackee_api/email/email.py b/fittrackee_api/fittrackee_api/email/email.py new file mode 100644 index 00000000..271c7ca2 --- /dev/null +++ b/fittrackee_api/fittrackee_api/email/email.py @@ -0,0 +1,103 @@ +import logging +import smtplib +import ssl +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText + +from jinja2 import Environment, FileSystemLoader + +from .utils_email import parse_email_url + +email_log = logging.getLogger('fittrackee_api_email') +email_log.setLevel(logging.DEBUG) + + +class EmailMessage: + def __init__(self, sender, recipient, subject, html, text): + self.sender = sender + self.recipient = recipient + self.subject = subject + self.html = html + self.text = text + + def generate_message(self): + message = MIMEMultipart('alternative') + message['Subject'] = self.subject + message['From'] = self.sender + message['To'] = self.recipient + part1 = MIMEText(self.text, 'plain') + part2 = MIMEText(self.html, 'html') + message.attach(part1) + message.attach(part2) + return message + + +class EmailTemplate: + def __init__(self, template_directory): + self._env = Environment(loader=FileSystemLoader(template_directory)) + + def get_content(self, template, lang, part, data): + template = self._env.get_template(f'{template}/{lang}/{part}') + return template.render(data) + + def get_all_contents(self, template, lang, data): + output = {} + for part in ['subject.txt', 'body.txt', 'body.html']: + output[part] = self.get_content(template, lang, part, data) + return output + + def get_message(self, template, lang, sender, recipient, data): + output = self.get_all_contents(template, lang, data) + message = EmailMessage( + sender, + recipient, + output['subject.txt'], + output['body.html'], + output['body.txt'], + ) + return message.generate_message() + + +class Email: + def __init__(self, app=None): + self.host = 'localhost' + self.port = 1025 + self.use_tls = False + self.use_ssl = False + self.username = None + self.password = None + self.sender_email = 'no-reply@example.com' + self.email_template = None + if app is not None: + self.init_email(app) + + def init_email(self, app): + parsed_url = parse_email_url(app.config.get('EMAIL_URL')) + self.host = parsed_url['host'] + self.port = parsed_url['port'] + self.use_tls = parsed_url['use_tls'] + self.use_ssl = parsed_url['use_ssl'] + self.username = parsed_url['username'] + self.password = parsed_url['password'] + self.sender_email = app.config.get('SENDER_EMAIL') + self.email_template = EmailTemplate(app.config.get('TEMPLATES_FOLDER')) + + @property + def smtp(self): + return smtplib.SMTP_SSL if self.use_ssl else smtplib.SMTP + + def send(self, template, lang, recipient, data): + message = self.email_template.get_message( + template, lang, self.sender_email, recipient, data + ) + connection_params = {} + if self.use_ssl or self.use_tls: + context = ssl.create_default_context() + if self.use_ssl: + connection_params.update({'context': context}) + with self.smtp(self.host, self.port, **connection_params) as smtp: + smtp.login(self.username, self.password) + if self.use_tls: + smtp.starttls(context=context) + smtp.sendmail(self.sender_email, recipient, message.as_string()) + smtp.quit() diff --git a/fittrackee_api/fittrackee_api/email/templates/password_reset_request/en/body.html b/fittrackee_api/fittrackee_api/email/templates/password_reset_request/en/body.html new file mode 100644 index 00000000..01dc5842 --- /dev/null +++ b/fittrackee_api/fittrackee_api/email/templates/password_reset_request/en/body.html @@ -0,0 +1,268 @@ + + + + + + + Fittrackee - Password reset request + + + + + Use this link to reset your password. The link is only valid for 24 hours. + + + + + + + \ No newline at end of file diff --git a/fittrackee_api/fittrackee_api/email/templates/password_reset_request/en/body.txt b/fittrackee_api/fittrackee_api/email/templates/password_reset_request/en/body.txt new file mode 100644 index 00000000..a139ff70 --- /dev/null +++ b/fittrackee_api/fittrackee_api/email/templates/password_reset_request/en/body.txt @@ -0,0 +1,10 @@ +Hi {{username}}, + +You recently requested to reset your password for your FitTrackee account. Use the button below to reset it. This password reset is only valid for the next 24 hours. + +Reset your password ( {{ password_reset_url }} ) + +For security, this request was received from a {{operating_system}} device using {{browser_name}}. If you did not request a password reset, please ignore this email. + +Thanks, +The FitTrackee Team diff --git a/fittrackee_api/fittrackee_api/email/templates/password_reset_request/en/subject.txt b/fittrackee_api/fittrackee_api/email/templates/password_reset_request/en/subject.txt new file mode 100644 index 00000000..d8033bea --- /dev/null +++ b/fittrackee_api/fittrackee_api/email/templates/password_reset_request/en/subject.txt @@ -0,0 +1 @@ +FitTrackee - Password reset request \ No newline at end of file diff --git a/fittrackee_api/fittrackee_api/email/templates/password_reset_request/fr/body.html b/fittrackee_api/fittrackee_api/email/templates/password_reset_request/fr/body.html new file mode 100644 index 00000000..092113cb --- /dev/null +++ b/fittrackee_api/fittrackee_api/email/templates/password_reset_request/fr/body.html @@ -0,0 +1,270 @@ + + + + + + + FitTrackee - Réinitialiser le mot de passe + + + + + Use this link to reset your password. The link is only valid for 24 hours. + Utiliser ce lien pour réinitialiser le mot de passe. Ce lien n'est valide que pendant 1 heure. + + + + + + + \ No newline at end of file diff --git a/fittrackee_api/fittrackee_api/email/templates/password_reset_request/fr/body.txt b/fittrackee_api/fittrackee_api/email/templates/password_reset_request/fr/body.txt new file mode 100644 index 00000000..a0f4fafc --- /dev/null +++ b/fittrackee_api/fittrackee_api/email/templates/password_reset_request/fr/body.txt @@ -0,0 +1,12 @@ +Bonjour {{username}}, + +Vous avez récemment demander la réinitilisation du mot de passe de votre compte sur FitTrackee. +Cliquez sur le lien ci-dessous pour le réinitialiser. Ce lien n'est valide que pendant 1 heure. + +Réinitialiser le mot de passe: ( {{ password_reset_url }} ) + +Pour vérification, cette demande a été reçue à partir d'un appareil sous {{operating_system}}, utilisant le navigateur {{browser_name}}. +Si vous n'avez pas demandé de réinitalisation, vous pouvez ignorer cet e-mail. + +Merci, +L'équipe FitTrackee diff --git a/fittrackee_api/fittrackee_api/email/templates/password_reset_request/fr/subject.txt b/fittrackee_api/fittrackee_api/email/templates/password_reset_request/fr/subject.txt new file mode 100644 index 00000000..2856fcc5 --- /dev/null +++ b/fittrackee_api/fittrackee_api/email/templates/password_reset_request/fr/subject.txt @@ -0,0 +1 @@ +FitTrackee - Réinitialiser votre mot de passe \ No newline at end of file diff --git a/fittrackee_api/fittrackee_api/email/utils_email.py b/fittrackee_api/fittrackee_api/email/utils_email.py new file mode 100644 index 00000000..57f8a5f7 --- /dev/null +++ b/fittrackee_api/fittrackee_api/email/utils_email.py @@ -0,0 +1,20 @@ +from urllib3.util import parse_url + + +class InvalidEmailUrlScheme(Exception): + ... + + +def parse_email_url(email_url): + parsed_url = parse_url(email_url) + if parsed_url.scheme != 'smtp': + raise InvalidEmailUrlScheme() + credentials = parsed_url.auth.split(':') + return { + 'host': parsed_url.host, + 'port': parsed_url.port, + 'use_tls': True if parsed_url.query == 'tls=True' else False, + 'use_ssl': True if parsed_url.query == 'ssl=True' else False, + 'username': credentials[0], + 'password': credentials[1], + } diff --git a/fittrackee_api/fittrackee_api/tests/conftest.py b/fittrackee_api/fittrackee_api/tests/conftest.py index dff48f54..6ea7cc56 100644 --- a/fittrackee_api/fittrackee_api/tests/conftest.py +++ b/fittrackee_api/fittrackee_api/tests/conftest.py @@ -46,7 +46,8 @@ def get_app(with_config=False): @pytest.fixture -def app(): +def app(monkeypatch): + monkeypatch.setenv('EMAIL_URL', 'smtp://none:none@0.0.0.0:1025') yield from get_app(with_config=True) @@ -55,6 +56,20 @@ def app_no_config(): yield from get_app(with_config=False) +@pytest.fixture +def app_ssl(monkeypatch): + print('app') + monkeypatch.setenv('EMAIL_URL', 'smtp://none:none@0.0.0.0:1025?ssl=True') + yield from get_app(with_config=True) + + +@pytest.fixture +def app_tls(monkeypatch): + print('app') + monkeypatch.setenv('EMAIL_URL', 'smtp://none:none@0.0.0.0:1025?tls=True') + yield from get_app(with_config=True) + + @pytest.fixture() def app_config(): config = AppConfig() diff --git a/fittrackee_api/fittrackee_api/tests/template_results/password_reset_request.py b/fittrackee_api/fittrackee_api/tests/template_results/password_reset_request.py new file mode 100644 index 00000000..06fa7dd0 --- /dev/null +++ b/fittrackee_api/fittrackee_api/tests/template_results/password_reset_request.py @@ -0,0 +1,175 @@ +# flake8: noqa + +expected_en_text_body = """Hi test, + +You recently requested to reset your password for your FitTrackee account. Use the button below to reset it. This password reset is only valid for the next 24 hours. + +Reset your password ( http://localhost/password-reset?token=xxx ) + +For security, this request was received from a Linux device using Firefox. If you did not request a password reset, please ignore this email. + +Thanks, +The FitTrackee Team""" + +expected_fr_text_body = """Bonjour test, + +Vous avez récemment demander la réinitilisation du mot de passe de votre compte sur FitTrackee. +Cliquez sur le lien ci-dessous pour le réinitialiser. Ce lien n'est valide que pendant 1 heure. + +Réinitialiser le mot de passe: ( http://localhost/password-reset?token=xxx ) + +Pour vérification, cette demande a été reçue à partir d'un appareil sous Linux, utilisant le navigateur Firefox. +Si vous n'avez pas demandé de réinitalisation, vous pouvez ignorer cet e-mail. + +Merci, +L'équipe FitTrackee""" + +expected_en_html_body = """ + Use this link to reset your password. The link is only valid for 24 hours. + + + + + + +""" + +expected_fr_html_body = """ + Use this link to reset your password. The link is only valid for 24 hours. + Utiliser ce lien pour réinitialiser le mot de passe. Ce lien n'est valide que pendant 1 heure. + + + + + + +""" diff --git a/fittrackee_api/fittrackee_api/tests/test_auth_api.py b/fittrackee_api/fittrackee_api/tests/test_auth_api.py index c144832c..45ca0d00 100644 --- a/fittrackee_api/fittrackee_api/tests/test_auth_api.py +++ b/fittrackee_api/fittrackee_api/tests/test_auth_api.py @@ -1,6 +1,10 @@ import json -import time +from datetime import datetime, timedelta from io import BytesIO +from unittest.mock import patch + +from fittrackee_api.users.utils_token import get_user_token +from freezegun import freeze_time class TestUserRegistration: @@ -352,24 +356,24 @@ class TestUserLogout: def test_it_returns_error_with_expired_token(self, app, user_1): client = app.test_client() + now = datetime.utcnow() resp_login = client.post( '/api/auth/login', data=json.dumps(dict(email='test@test.com', password='12345678')), content_type='application/json', ) - # invalid token logout - time.sleep(4) - response = client.get( - '/api/auth/logout', - headers=dict( - Authorization='Bearer ' - + json.loads(resp_login.data.decode())['auth_token'] - ), - ) - data = json.loads(response.data.decode()) - assert data['status'] == 'error' - assert data['message'] == 'Signature expired. Please log in again.' - assert response.status_code == 401 + with freeze_time(now + timedelta(seconds=4)): + response = client.get( + '/api/auth/logout', + headers=dict( + Authorization='Bearer ' + + json.loads(resp_login.data.decode())['auth_token'] + ), + ) + data = json.loads(response.data.decode()) + assert data['status'] == 'error' + assert data['message'] == 'Signature expired. Please log in again.' + assert response.status_code == 401 def test_it_returns_error_with_invalid_token(self, app): client = app.test_client() @@ -915,10 +919,14 @@ class TestRegistrationConfiguration: class TestPasswordResetRequest: - def test_it_requests_password_reset_when_user_exists(self, app, user_1): + @patch('smtplib.SMTP_SSL') + @patch('smtplib.SMTP') + def test_it_requests_password_reset_when_user_exists( + self, mock_smtp, mock_smtp_ssl, app, user_1 + ): client = app.test_client() response = client.post( - '/api/auth/password-reset/request', + '/api/auth/password/reset-request', data=json.dumps(dict(email='test@test.com')), content_type='application/json', ) @@ -932,7 +940,7 @@ class TestPasswordResetRequest: client = app.test_client() response = client.post( - '/api/auth/password-reset/request', + '/api/auth/password/reset-request', data=json.dumps(dict(email='test@test.com')), content_type='application/json', ) @@ -946,7 +954,7 @@ class TestPasswordResetRequest: client = app.test_client() response = client.post( - '/api/auth/password-reset/request', + '/api/auth/password/reset-request', data=json.dumps(dict(usernmae='test')), content_type='application/json', ) @@ -960,7 +968,7 @@ class TestPasswordResetRequest: client = app.test_client() response = client.post( - '/api/auth/password-reset/request', + '/api/auth/password/reset-request', data=json.dumps(dict()), content_type='application/json', ) @@ -969,3 +977,151 @@ class TestPasswordResetRequest: data = json.loads(response.data.decode()) assert data['message'] == 'Invalid payload.' assert data['status'] == 'error' + + +class TestPasswordUpdate: + def test_it_returns_error_if_payload_is_empty(self, app): + client = app.test_client() + + response = client.post( + '/api/auth/password/update', + data=json.dumps(dict(token='xxx', password='1234567',)), + content_type='application/json', + ) + + assert response.status_code == 400 + data = json.loads(response.data.decode()) + assert data['status'] == 'error' + assert data['message'] == 'Invalid payload.' + + def test_it_returns_error_if_token_is_missing(self, app): + client = app.test_client() + + response = client.post( + '/api/auth/password/update', + data=json.dumps( + dict(password='12345678', password_conf='12345678',) + ), + content_type='application/json', + ) + + assert response.status_code == 400 + data = json.loads(response.data.decode()) + assert data['status'] == 'error' + assert data['message'] == 'Invalid payload.' + + def test_it_returns_error_if_password_is_missing(self, app): + client = app.test_client() + + response = client.post( + '/api/auth/password/update', + data=json.dumps(dict(token='xxx', password_conf='12345678',)), + content_type='application/json', + ) + + assert response.status_code == 400 + data = json.loads(response.data.decode()) + assert data['status'] == 'error' + assert data['message'] == 'Invalid payload.' + + def test_it_returns_error_if_password_confirmation_is_missing(self, app): + client = app.test_client() + + response = client.post( + '/api/auth/password/update', + data=json.dumps(dict(token='xxx', password='12345678',)), + content_type='application/json', + ) + + assert response.status_code == 400 + data = json.loads(response.data.decode()) + assert data['status'] == 'error' + assert data['message'] == 'Invalid payload.' + + def test_it_returns_error_if_token_is_invalid(self, app): + token = get_user_token(1) + client = app.test_client() + + response = client.post( + '/api/auth/password/update', + data=json.dumps( + dict( + token=token.decode(), + password='12345678', + password_conf='12345678', + ) + ), + content_type='application/json', + ) + + assert response.status_code == 401 + data = json.loads(response.data.decode()) + assert data['status'] == 'error' + assert data['message'] == 'Invalid token. Please request a new token.' + + def test_it_returns_error_if_token_is_expired(self, app, user_1): + now = datetime.utcnow() + token = get_user_token(user_1.id, password_reset=True) + client = app.test_client() + + with freeze_time(now + timedelta(seconds=4)): + response = client.post( + '/api/auth/password/update', + data=json.dumps( + dict( + token=token.decode(), + password='12345678', + password_conf='12345678', + ) + ), + content_type='application/json', + ) + + assert response.status_code == 401 + data = json.loads(response.data.decode()) + assert data['status'] == 'error' + assert ( + data['message'] == 'Invalid token. Please request a new token.' + ) + + def test_it_returns_error_if_password_is_invalid(self, app, user_1): + token = get_user_token(user_1.id, password_reset=True) + client = app.test_client() + + response = client.post( + '/api/auth/password/update', + data=json.dumps( + dict( + token=token.decode(), + password='1234567', + password_conf='1234567', + ) + ), + content_type='application/json', + ) + + assert response.status_code == 400 + data = json.loads(response.data.decode()) + assert data['status'] == 'error' + assert data['message'] == 'Password: 8 characters required.\n' + + def test_it_update_password(self, app, user_1): + token = get_user_token(user_1.id, password_reset=True) + client = app.test_client() + + response = client.post( + '/api/auth/password/update', + data=json.dumps( + dict( + token=token.decode(), + password='12345678', + password_conf='12345678', + ) + ), + content_type='application/json', + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data['status'] == 'success' + assert data['message'] == 'Password updated.' diff --git a/fittrackee_api/fittrackee_api/tests/test_email.py b/fittrackee_api/fittrackee_api/tests/test_email.py new file mode 100644 index 00000000..5590e6cf --- /dev/null +++ b/fittrackee_api/fittrackee_api/tests/test_email.py @@ -0,0 +1,94 @@ +from unittest.mock import patch + +from fittrackee_api import email_service +from fittrackee_api.email.email import EmailMessage + +from .template_results.password_reset_request import expected_en_text_body + + +class TestEmailMessage: + def test_it_generate_email_data(self): + message = EmailMessage( + sender='fittrackee@example.com', + recipient='test@test.com', + subject='Fittrackee - test email', + html="""\ + + +

Hello !

+ + + """, + text='Hello !', + ) + message_data = message.generate_message() + assert message_data.get('From') == 'fittrackee@example.com' + assert message_data.get('To') == 'test@test.com' + assert message_data.get('Subject') == 'Fittrackee - test email' + message_string = message_data.as_string() + assert 'Hello !' in message_string + + +class TestEmailSending: + + email_data = { + 'username': 'test', + 'password_reset_url': f'http://localhost/password-reset?token=xxx', + 'operating_system': 'Linux', + 'browser_name': 'Firefox', + } + + @staticmethod + def assert_smtp(smtp): + assert smtp.sendmail.call_count == 1 + call_args = smtp.sendmail.call_args.args + assert call_args[0] == 'fittrackee@example.com' + assert call_args[1] == 'test@test.com' + assert expected_en_text_body in call_args[2] + + @patch('smtplib.SMTP_SSL') + @patch('smtplib.SMTP') + def test_it_sends_message(self, mock_smtp, mock_smtp_ssl, app): + + email_service.send( + template='password_reset_request', + lang='en', + recipient='test@test.com', + data=self.email_data, + ) + + smtp = mock_smtp.return_value.__enter__.return_value + assert smtp.starttls.not_called + self.assert_smtp(smtp) + + @patch('smtplib.SMTP_SSL') + @patch('smtplib.SMTP') + def test_it_sends_message_with_ssl( + self, mock_smtp, mock_smtp_ssl, app_ssl + ): + email_service.send( + template='password_reset_request', + lang='en', + recipient='test@test.com', + data=self.email_data, + ) + + smtp = mock_smtp_ssl.return_value.__enter__.return_value + assert smtp.starttls.not_called + self.assert_smtp(smtp) + + @patch('smtplib.SMTP_SSL') + @patch('smtplib.SMTP') + def test_it_sends_message_with_tls( + self, mock_smtp, mock_smtp_ssl, app_tls + ): + email_service.send( + template='password_reset_request', + lang='en', + recipient='test@test.com', + data=self.email_data, + ) + + smtp = mock_smtp.return_value.__enter__.return_value + assert smtp.starttls.call_count == 1 + self.assert_smtp(smtp) diff --git a/fittrackee_api/fittrackee_api/tests/test_email_template_password_request.py b/fittrackee_api/fittrackee_api/tests/test_email_template_password_request.py new file mode 100644 index 00000000..b9ca884a --- /dev/null +++ b/fittrackee_api/fittrackee_api/tests/test_email_template_password_request.py @@ -0,0 +1,76 @@ +import pytest +from fittrackee_api.email.email import EmailTemplate + +from .template_results.password_reset_request import ( + expected_en_html_body, + expected_en_text_body, + expected_fr_html_body, + expected_fr_text_body, +) + + +class TestEmailTemplateForPasswordRequest: + @pytest.mark.parametrize( + 'lang, expected_subject', + [ + ('en', 'FitTrackee - Password reset request'), + ('fr', 'FitTrackee - Réinitialiser votre mot de passe'), + ], + ) + def test_it_gets_subject(self, app, lang, expected_subject): + email_template = EmailTemplate(app.config.get('TEMPLATES_FOLDER')) + + subject = email_template.get_content( + 'password_reset_request', lang, 'subject.txt', {} + ) + + assert subject == expected_subject + + @pytest.mark.parametrize( + 'lang, expected_text_body', + [('en', expected_en_text_body), ('fr', expected_fr_text_body)], + ) + def test_it_gets_text_body(self, app, lang, expected_text_body): + email_template = EmailTemplate(app.config.get('TEMPLATES_FOLDER')) + email_data = { + 'username': 'test', + 'password_reset_url': f'http://localhost/password-reset?token=xxx', + 'operating_system': 'Linux', + 'browser_name': 'Firefox', + } + + text_body = email_template.get_content( + 'password_reset_request', lang, 'body.txt', email_data + ) + + assert text_body == expected_text_body + + def test_it_gets_en_html_body(self, app): + email_template = EmailTemplate(app.config.get('TEMPLATES_FOLDER')) + email_data = { + 'username': 'test', + 'password_reset_url': f'http://localhost/password-reset?token=xxx', + 'operating_system': 'Linux', + 'browser_name': 'Firefox', + } + + text_body = email_template.get_content( + 'password_reset_request', 'en', 'body.html', email_data + ) + + assert expected_en_html_body in text_body + + def test_it_gets_fr_html_body(self, app): + email_template = EmailTemplate(app.config.get('TEMPLATES_FOLDER')) + email_data = { + 'username': 'test', + 'password_reset_url': f'http://localhost/password-reset?token=xxx', + 'operating_system': 'Linux', + 'browser_name': 'Firefox', + } + + text_body = email_template.get_content( + 'password_reset_request', 'fr', 'body.html', email_data + ) + + assert expected_fr_html_body in text_body diff --git a/fittrackee_api/fittrackee_api/tests/test_email_utils.py b/fittrackee_api/fittrackee_api/tests/test_email_utils.py new file mode 100644 index 00000000..b174e4ce --- /dev/null +++ b/fittrackee_api/fittrackee_api/tests/test_email_utils.py @@ -0,0 +1,42 @@ +import pytest +from fittrackee_api.email.utils_email import ( + InvalidEmailUrlScheme, + parse_email_url, +) + + +class TestEmailUrlParser: + def test_it_raises_error_if_url_scheme_is_invalid(self): + url = 'stmp://username:password@localhost:587' + with pytest.raises(InvalidEmailUrlScheme): + parse_email_url(url) + + def test_it_parses_email_url(self): + url = 'smtp://test@example.com:12345678@localhost:25' + parsed_email = parse_email_url(url) + assert parsed_email['username'] == 'test@example.com' + assert parsed_email['password'] == '12345678' + assert parsed_email['host'] == 'localhost' + assert parsed_email['port'] == 25 + assert parsed_email['use_tls'] is False + assert parsed_email['use_ssl'] is False + + def test_it_parses_email_url_with_tls(self): + url = 'smtp://test@example.com:12345678@localhost:587?tls=True' + parsed_email = parse_email_url(url) + assert parsed_email['username'] == 'test@example.com' + assert parsed_email['password'] == '12345678' + assert parsed_email['host'] == 'localhost' + assert parsed_email['port'] == 587 + assert parsed_email['use_tls'] is True + assert parsed_email['use_ssl'] is False + + def test_it_parses_email_url_with_ssl(self): + url = 'smtp://test@example.com:12345678@localhost:465?ssl=True' + parsed_email = parse_email_url(url) + assert parsed_email['username'] == 'test@example.com' + assert parsed_email['password'] == '12345678' + assert parsed_email['host'] == 'localhost' + assert parsed_email['port'] == 465 + assert parsed_email['use_tls'] is False + assert parsed_email['use_ssl'] is True diff --git a/fittrackee_api/fittrackee_api/tests/test_users_model.py b/fittrackee_api/fittrackee_api/tests/test_users_model.py index 85c0cb52..d810bf8c 100644 --- a/fittrackee_api/fittrackee_api/tests/test_users_model.py +++ b/fittrackee_api/fittrackee_api/tests/test_users_model.py @@ -27,6 +27,10 @@ class TestUserModel: auth_token = user_1.encode_auth_token(user_1.id) assert isinstance(auth_token, bytes) + def test_encode_password_token(self, app, user_1): + password_token = user_1.encode_password_reset_token(user_1.id) + assert isinstance(password_token, bytes) + def test_decode_auth_token(self, app, user_1): auth_token = user_1.encode_auth_token(user_1.id) assert isinstance(auth_token, bytes) diff --git a/fittrackee_api/fittrackee_api/users/auth.py b/fittrackee_api/fittrackee_api/users/auth.py index 88488ca0..3e630d5e 100644 --- a/fittrackee_api/fittrackee_api/users/auth.py +++ b/fittrackee_api/fittrackee_api/users/auth.py @@ -1,7 +1,8 @@ import datetime import os -from fittrackee_api import appLog, bcrypt, db +import jwt +from fittrackee_api import appLog, bcrypt, db, email_service from flask import Blueprint, current_app, jsonify, request from sqlalchemy import exc, or_ from werkzeug.exceptions import RequestEntityTooLarge @@ -11,10 +12,12 @@ from ..activities.utils_files import get_absolute_file_path from .models import User from .utils import ( authenticate, + check_passwords, display_readable_file_size, register_controls, verify_extension_and_size, ) +from .utils_token import decode_user_token auth_blueprint = Blueprint('auth', __name__) @@ -464,14 +467,13 @@ def edit_user(auth_user_id): weekm = post_data.get('weekm') if password is not None and password != '': - if password_conf != password: - message = 'Password and password confirmation don\'t match.\n' + message = check_passwords(password, password_conf) + if message != '': response_object = {'status': 'error', 'message': message} return jsonify(response_object), 400 - else: - password = bcrypt.generate_password_hash( - password, current_app.config.get('BCRYPT_LOG_ROUNDS') - ).decode() + password = bcrypt.generate_password_hash( + password, current_app.config.get('BCRYPT_LOG_ROUNDS') + ).decode() try: user = User.query.filter_by(id=auth_user_id).first() @@ -657,7 +659,7 @@ def del_picture(auth_user_id): return jsonify(response_object), 500 -@auth_blueprint.route('/auth/password-reset/request', methods=['POST']) +@auth_blueprint.route('/auth/password/reset-request', methods=['POST']) def request_password_reset(): """ handle password reset request @@ -666,7 +668,7 @@ def request_password_reset(): .. sourcecode:: http - POST /api/auth/password-reset/request HTTP/1.1 + POST /api/auth/password/reset-request HTTP/1.1 Content-Type: application/json **Example response**: @@ -685,7 +687,6 @@ def request_password_reset(): :statuscode 200: Password reset request processed. :statuscode 400: Invalid payload. - :statuscode 500: Error. Please try again or contact the administrator. """ post_data = request.get_json() @@ -696,9 +697,109 @@ def request_password_reset(): user = User.query.filter(User.email == email).first() if user: - password_reset_token = user.encode_auth_token(user.id) + password_reset_token = user.encode_password_reset_token(user.id) + ui_url = current_app.config['UI_URL'] + email_data = { + 'username': user.username, + 'password_reset_url': ( + f'{ui_url}/password-reset?token={password_reset_token.decode()}' # noqa + ), + 'operating_system': request.user_agent.platform, + 'browser_name': request.user_agent.browser, + } + email_service.send( + template='password_reset_request', + lang=user.language if user.language else 'en', + recipient=user.email, + data=email_data, + ) response_object = { 'status': 'success', 'message': 'Password reset request processed.', } return jsonify(response_object), 200 + + +@auth_blueprint.route('/auth/password/update', methods=['POST']) +def update_password(): + """ + update user password + + **Example request**: + + .. sourcecode:: http + + POST /api/auth/password/update HTTP/1.1 + Content-Type: application/json + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Content-Type: application/json + + { + "message": "Password updated.", + "status": "success" + } + + :=3.2)", "rst.linker (>=1.9)"] testing = ["jaraco.itertools", "func-timeout"] [metadata] -content-hash = "eb952aaba2a4049373b197f711474741675ac9029129bdcf57b238bc466a15c6" +content-hash = "4199f7c3f0fe738bf7d846017b57e9903aff0b4697a8570be54a9ed63bd20306" python-versions = "^3.7" [metadata.files] @@ -1169,6 +1181,10 @@ flask-sqlalchemy = [ {file = "Flask-SQLAlchemy-2.4.1.tar.gz", hash = "sha256:6974785d913666587949f7c2946f7001e4fa2cb2d19f4e69ead02e4b8f50b33d"}, {file = "Flask_SQLAlchemy-2.4.1-py2.py3-none-any.whl", hash = "sha256:0078d8663330dc05a74bc72b3b6ddc441b9a744e2f56fe60af1a5bfc81334327"}, ] +freezegun = [ + {file = "freezegun-0.3.15-py2.py3-none-any.whl", hash = "sha256:82c757a05b7c7ca3e176bfebd7d6779fd9139c7cb4ef969c38a28d74deef89b2"}, + {file = "freezegun-0.3.15.tar.gz", hash = "sha256:e2062f2c7f95cc276a834c22f1a17179467176b624cc6f936e8bc3be5535ad1b"}, +] gpxpy = [ {file = "gpxpy-1.3.4.tar.gz", hash = "sha256:4a0f072ae5bdf9270c7450e452f93a6c5c91d888114e8d78868a8f163b0dbb15"}, ] diff --git a/fittrackee_api/pyproject.toml b/fittrackee_api/pyproject.toml index 40e069cb..31cb0e27 100644 --- a/fittrackee_api/pyproject.toml +++ b/fittrackee_api/pyproject.toml @@ -32,6 +32,7 @@ sphinxcontrib-httpdomain = "^1.7" sphinx-bootstrap-theme = "^0.7.1" recommonmark = "^0.6.0" pyopenssl = "^19.0" +freezegun = "^0.3.15" [tool.pytest] norecursedirs = "fittrackee_api/.venv"