API - add route to update password and email templates - #50

This commit is contained in:
Sam 2020-05-17 16:42:44 +02:00
parent d6ce02d385
commit 51d627fa1c
23 changed files with 1429 additions and 38 deletions

View File

@ -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

View File

@ -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=

View File

@ -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):

View File

@ -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'

View File

@ -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()

View File

@ -0,0 +1,268 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html lang="en">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="x-apple-disable-message-reformatting" content=""/>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>Fittrackee - Password reset request</title>
<style type="text/css" rel="stylesheet" media="all">
body {
background-color: #F4F4F7;
color: #51545E;
width: 100% !important;
height: 100%;
margin: 0;
-webkit-text-size-adjust: none;
}
a {
color: #3869D4;
}
a img {
border: none;
}
td {
word-break: break-word;
}
.preheader {
display: none !important;
visibility: hidden;
mso-hide: all;
font-size: 1px;
line-height: 1px;
max-height: 0;
max-width: 0;
opacity: 0;
overflow: hidden;
}
body,
td,
th {
font-family: Helvetica, Arial, sans-serif;
}
h1 {
margin-top: 0;
color: #333333;
font-size: 22px;
font-weight: bold;
text-align: left;
}
td,
th {
font-size: 16px;
}
p {
color: #51545E;
margin: .4em 0 1.1875em;
font-size: 16px;
line-height: 1.625;
}
p.sub {
color: #6B6E76;
font-size: 13px;
}
.button {
background-color: #3869D4;
border-top: 10px solid #3869D4;
border-right: 18px solid #3869D4;
border-bottom: 10px solid #3869D4;
border-left: 18px solid #3869D4;
display: inline-block;
color: #FFF;
text-decoration: none;
border-radius: 3px;
box-shadow: 0 2px 3px rgba(0, 0, 0, 0.16);
-webkit-text-size-adjust: none;
box-sizing: border-box;
}
.button--green {
background-color: #22BC66;
border-top: 10px solid #22BC66;
border-right: 18px solid #22BC66;
border-bottom: 10px solid #22BC66;
border-left: 18px solid #22BC66;
}
@media only screen and (max-width: 500px) {
.button {
width: 100% !important;
text-align: center !important;
}
}
.email-wrapper {
width: 100%;
margin: 0;
padding: 0;
background-color: #F4F4F7;
}
.email-content {
width: 100%;
margin: 0;
padding: 0;
}
.email-masthead {
padding: 25px 0;
text-align: center;
}
.email-masthead-name {
font-size: 16px;
font-weight: bold;
color: #A8AAAF;
text-decoration: none;
text-shadow: 0 1px 0 white;
}
.email-body {
width: 100%;
margin: 0;
padding: 0;
background-color: #FFFFFF;
}
.email-body-inner {
width: 570px;
margin: 0 auto;
padding: 0;
background-color: #FFFFFF;
}
.body-action {
width: 100%;
margin: 30px auto;
padding: 0;
text-align: center;
}
.body-sub {
margin-top: 25px;
padding-top: 25px;
border-top: 1px solid #EAEAEC;
}
.content-cell {
padding: 35px;
}
@media only screen and (max-width: 600px) {
.email-body-inner,
.email-footer {
width: 100% !important;
}
}
@media (prefers-color-scheme: dark) {
body,
.email-body,
.email-body-inner,
.email-content,
.email-wrapper,
.email-masthead,
.email-footer {
background-color: #333333 !important;
color: #FFF !important;
}
p,
h1 {
color: #FFF !important;
}
.email-masthead-name {
text-shadow: none !important;
}
}
</style>
<!--[if mso]>
<style type="text/css">
.f-fallback {
font-family: Arial, sans-serif;
}
</style>
<![endif]-->
</head>
<body>
<span class="preheader">Use this link to reset your password. The link is only valid for 24 hours.</span>
<table class="email-wrapper" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td align="center">
<table class="email-content" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td class="email-masthead">
<a href="https://example.com" class="f-fallback email-masthead-name">
FitTrackee
</a>
</td>
</tr>
<tr>
<td class="email-body" width="100%" cellpadding="0" cellspacing="0">
<table class="email-body-inner" align="center" width="570" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td class="content-cell">
<div class="f-fallback">
<h1>Hi {{username}},</h1>
<p>You recently requested to reset your password for your account. Use the button below to reset it.
<strong>This password reset is only valid for the next 24 hours.</strong>
</p>
<table class="body-action" align="center" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td align="center">
<table width="100%" border="0" cellspacing="0" cellpadding="0" role="presentation">
<tr>
<td align="center">
<a href="{{password_reset_url}}" class="f-fallback button button--green" target="_blank">Reset your password</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
<p>
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.
</p>
<p>Thanks,
<br>The FitTrackee Team</p>
<table class="body-sub" role="presentation">
<tr>
<td>
<p class="f-fallback sub">If youre having trouble with the button above, copy and paste the URL below into your web browser.</p>
<p class="f-fallback sub">{{password_reset_url}}</p>
</td>
</tr>
</table>
</div>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td>
<table class="email-footer" align="center" width="570" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td class="content-cell" align="center">
<p class="f-fallback sub align-center">&copy; FitTrackee.</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@ -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

View File

@ -0,0 +1 @@
FitTrackee - Password reset request

View File

@ -0,0 +1,270 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html lang="en">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="x-apple-disable-message-reformatting" content=""/>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>FitTrackee - Réinitialiser le mot de passe</title>
<style type="text/css" rel="stylesheet" media="all">
body {
background-color: #F4F4F7;
color: #51545E;
width: 100% !important;
height: 100%;
margin: 0;
-webkit-text-size-adjust: none;
}
a {
color: #3869D4;
}
a img {
border: none;
}
td {
word-break: break-word;
}
.preheader {
display: none !important;
visibility: hidden;
mso-hide: all;
font-size: 1px;
line-height: 1px;
max-height: 0;
max-width: 0;
opacity: 0;
overflow: hidden;
}
body,
td,
th {
font-family: Helvetica, Arial, sans-serif;
}
h1 {
margin-top: 0;
color: #333333;
font-size: 22px;
font-weight: bold;
text-align: left;
}
td,
th {
font-size: 16px;
}
p {
color: #51545E;
margin: .4em 0 1.1875em;
font-size: 16px;
line-height: 1.625;
}
p.sub {
color: #6B6E76;
font-size: 13px;
}
.button {
background-color: #3869D4;
border-top: 10px solid #3869D4;
border-right: 18px solid #3869D4;
border-bottom: 10px solid #3869D4;
border-left: 18px solid #3869D4;
display: inline-block;
color: #FFF;
text-decoration: none;
border-radius: 3px;
box-shadow: 0 2px 3px rgba(0, 0, 0, 0.16);
-webkit-text-size-adjust: none;
box-sizing: border-box;
}
.button--green {
background-color: #22BC66;
border-top: 10px solid #22BC66;
border-right: 18px solid #22BC66;
border-bottom: 10px solid #22BC66;
border-left: 18px solid #22BC66;
}
@media only screen and (max-width: 500px) {
.button {
width: 100% !important;
text-align: center !important;
}
}
.email-wrapper {
width: 100%;
margin: 0;
padding: 0;
background-color: #F4F4F7;
}
.email-content {
width: 100%;
margin: 0;
padding: 0;
}
.email-masthead {
padding: 25px 0;
text-align: center;
}
.email-masthead-name {
font-size: 16px;
font-weight: bold;
color: #A8AAAF;
text-decoration: none;
text-shadow: 0 1px 0 white;
}
.email-body {
width: 100%;
margin: 0;
padding: 0;
background-color: #FFFFFF;
}
.email-body-inner {
width: 570px;
margin: 0 auto;
padding: 0;
background-color: #FFFFFF;
}
.body-action {
width: 100%;
margin: 30px auto;
padding: 0;
text-align: center;
}
.body-sub {
margin-top: 25px;
padding-top: 25px;
border-top: 1px solid #EAEAEC;
}
.content-cell {
padding: 35px;
}
@media only screen and (max-width: 600px) {
.email-body-inner,
.email-footer {
width: 100% !important;
}
}
@media (prefers-color-scheme: dark) {
body,
.email-body,
.email-body-inner,
.email-content,
.email-wrapper,
.email-masthead,
.email-footer {
background-color: #333333 !important;
color: #FFF !important;
}
p,
h1 {
color: #FFF !important;
}
.email-masthead-name {
text-shadow: none !important;
}
}
</style>
<!--[if mso]>
<style type="text/css">
.f-fallback {
font-family: Arial, sans-serif;
}
</style>
<![endif]-->
</head>
<body>
<span class="preheader">Use this link to reset your password. The link is only valid for 24 hours.</span>
<span class="preheader">Utiliser ce lien pour réinitialiser le mot de passe. Ce lien n'est valide que pendant 1 heure.</span>
<table class="email-wrapper" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td align="center">
<table class="email-content" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td class="email-masthead">
<a href="https://example.com" class="f-fallback email-masthead-name">
FitTrackee
</a>
</td>
</tr>
<tr>
<td class="email-body" width="100%" cellpadding="0" cellspacing="0">
<table class="email-body-inner" align="center" width="570" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td class="content-cell">
<div class="f-fallback">
<h1>Bonjour {{username}},</h1>
<p>Vous avez récemment demander la réinitilisation du mot de passe de votre compte sur FitTrackee.
Cliquez sur le bouton ci-dessous pour le réinitialiser.
<strong>Cette réinitialisation n'est valide que pendant 1 heure.</strong>
</p>
<table class="body-action" align="center" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td align="center">
<table width="100%" border="0" cellspacing="0" cellpadding="0" role="presentation">
<tr>
<td align="center">
<a href="{{password_reset_url}}" class="f-fallback button button--green" target="_blank">Réinitialiser le mot de passe</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
<p>
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.
</p>
<p>Merci,
<br>L'équipe FitTrackee</p>
<table class="body-sub" role="presentation">
<tr>
<td>
<p class="f-fallback sub">Si vous avez des problèmes avec le bouton, vous pouvez copier et coller le lien suivant dans votre navigateur</p>
<p class="f-fallback sub">{{password_reset_url}}</p>
</td>
</tr>
</table>
</div>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td>
<table class="email-footer" align="center" width="570" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td class="content-cell" align="center">
<p class="f-fallback sub align-center">&copy; FitTrackee.</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@ -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

View File

@ -0,0 +1 @@
FitTrackee - Réinitialiser votre mot de passe

View File

@ -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],
}

View File

@ -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()

View File

@ -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 = """ <body>
<span class="preheader">Use this link to reset your password. The link is only valid for 24 hours.</span>
<table class="email-wrapper" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td align="center">
<table class="email-content" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td class="email-masthead">
<a href="https://example.com" class="f-fallback email-masthead-name">
FitTrackee
</a>
</td>
</tr>
<tr>
<td class="email-body" width="100%" cellpadding="0" cellspacing="0">
<table class="email-body-inner" align="center" width="570" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td class="content-cell">
<div class="f-fallback">
<h1>Hi test,</h1>
<p>You recently requested to reset your password for your account. Use the button below to reset it.
<strong>This password reset is only valid for the next 24 hours.</strong>
</p>
<table class="body-action" align="center" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td align="center">
<table width="100%" border="0" cellspacing="0" cellpadding="0" role="presentation">
<tr>
<td align="center">
<a href="http://localhost/password-reset?token=xxx" class="f-fallback button button--green" target="_blank">Reset your password</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
<p>
For security, this request was received from a Linux device using Firefox.
If you did not request a password reset, please ignore this email.
</p>
<p>Thanks,
<br>The FitTrackee Team</p>
<table class="body-sub" role="presentation">
<tr>
<td>
<p class="f-fallback sub">If youre having trouble with the button above, copy and paste the URL below into your web browser.</p>
<p class="f-fallback sub">http://localhost/password-reset?token=xxx</p>
</td>
</tr>
</table>
</div>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td>
<table class="email-footer" align="center" width="570" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td class="content-cell" align="center">
<p class="f-fallback sub align-center">&copy; FitTrackee.</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>"""
expected_fr_html_body = """ <body>
<span class="preheader">Use this link to reset your password. The link is only valid for 24 hours.</span>
<span class="preheader">Utiliser ce lien pour réinitialiser le mot de passe. Ce lien n'est valide que pendant 1 heure.</span>
<table class="email-wrapper" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td align="center">
<table class="email-content" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td class="email-masthead">
<a href="https://example.com" class="f-fallback email-masthead-name">
FitTrackee
</a>
</td>
</tr>
<tr>
<td class="email-body" width="100%" cellpadding="0" cellspacing="0">
<table class="email-body-inner" align="center" width="570" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td class="content-cell">
<div class="f-fallback">
<h1>Bonjour test,</h1>
<p>Vous avez récemment demander la réinitilisation du mot de passe de votre compte sur FitTrackee.
Cliquez sur le bouton ci-dessous pour le réinitialiser.
<strong>Cette réinitialisation n'est valide que pendant 1 heure.</strong>
</p>
<table class="body-action" align="center" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td align="center">
<table width="100%" border="0" cellspacing="0" cellpadding="0" role="presentation">
<tr>
<td align="center">
<a href="http://localhost/password-reset?token=xxx" class="f-fallback button button--green" target="_blank">Réinitialiser le mot de passe</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
<p>
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.
</p>
<p>Merci,
<br>L'équipe FitTrackee</p>
<table class="body-sub" role="presentation">
<tr>
<td>
<p class="f-fallback sub">Si vous avez des problèmes avec le bouton, vous pouvez copier et coller le lien suivant dans votre navigateur</p>
<p class="f-fallback sub">http://localhost/password-reset?token=xxx</p>
</td>
</tr>
</table>
</div>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td>
<table class="email-footer" align="center" width="570" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td class="content-cell" align="center">
<p class="f-fallback sub align-center">&copy; FitTrackee.</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>"""

View File

@ -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.'

View File

@ -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="""\
<html>
<body>
<p>Hello !</p>
</body>
</html>
""",
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)

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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"
}
:<json string password: password (8 characters required)
:<json string password_conf: password confirmation
:<json string token: password reset token
:statuscode 200: Password updated.
:statuscode 400: Invalid payload.
:statuscode 401: Invalid token.
:statuscode 500: Error. Please try again or contact the administrator.
"""
post_data = request.get_json()
if (
not post_data
or post_data.get('password') is None
or post_data.get('password_conf') is None
or post_data.get('token') is None
):
response_object = {'status': 'error', 'message': 'Invalid payload.'}
return jsonify(response_object), 400
password = post_data.get('password')
password_conf = post_data.get('password_conf')
token = post_data.get('token')
invalid_token_response_object = {
'status': 'error',
'message': 'Invalid token. Please request a new token.',
}
try:
user_id = decode_user_token(token)
except (jwt.ExpiredSignatureError, jwt.InvalidTokenError):
return jsonify(invalid_token_response_object), 401
message = check_passwords(password, password_conf)
if message != '':
response_object = {'status': 'error', 'message': message}
return jsonify(response_object), 400
user = User.query.filter(User.id == user_id).first()
if not user:
return jsonify(invalid_token_response_object), 401
try:
user.password = bcrypt.generate_password_hash(
password, current_app.config.get('BCRYPT_LOG_ROUNDS')
).decode()
db.session.commit()
response_object = {
'status': 'success',
'message': 'Password updated.',
}
return jsonify(response_object), 200
except (exc.OperationalError, ValueError) as e:
db.session.rollback()
appLog.error(e)
response_object = {
'status': 'error',
'message': 'Error. Please try again or contact the administrator.',
}
return jsonify(response_object), 500

View File

@ -16,16 +16,22 @@ def is_valid_email(email):
return re.match(mail_pattern, email) is not None
def check_passwords(password, password_conf):
ret = ''
if password_conf != password:
ret = 'Password and password confirmation don\'t match.\n'
if len(password) < 8:
ret += 'Password: 8 characters required.\n'
return ret
def register_controls(username, email, password, password_conf):
ret = ''
if not 2 < len(username) < 13:
ret += 'Username: 3 to 12 characters required.\n'
if not is_valid_email(email):
ret += 'Valid email must be provided.\n'
if password != password_conf:
ret += 'Password and password confirmation don\'t match.\n'
if len(password) < 8:
ret += 'Password: 8 characters required.\n'
ret += check_passwords(password, password_conf)
return ret

View File

@ -285,6 +285,18 @@ version = "2.4.1"
Flask = ">=0.10"
SQLAlchemy = ">=0.8.0"
[[package]]
category = "dev"
description = "Let your Python tests travel through time"
name = "freezegun"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
version = "0.3.15"
[package.dependencies]
python-dateutil = ">=1.0,<2.0 || >2.0"
six = "*"
[[package]]
category = "main"
description = "GPX file parser and GPS track manipulation library"
@ -983,7 +995,7 @@ docs = ["sphinx", "jaraco.packaging (>=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"},
]

View File

@ -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"