API - add route to update password and email templates - #50
This commit is contained in:
parent
d6ce02d385
commit
51d627fa1c
2
.flake8
2
.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
|
||||
|
@ -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=
|
||||
|
@ -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):
|
||||
|
@ -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'
|
||||
|
103
fittrackee_api/fittrackee_api/email/email.py
Normal file
103
fittrackee_api/fittrackee_api/email/email.py
Normal 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()
|
@ -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 you’re 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">© FitTrackee.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
@ -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
|
@ -0,0 +1 @@
|
||||
FitTrackee - Password reset request
|
@ -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">© FitTrackee.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
@ -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
|
@ -0,0 +1 @@
|
||||
FitTrackee - Réinitialiser votre mot de passe
|
20
fittrackee_api/fittrackee_api/email/utils_email.py
Normal file
20
fittrackee_api/fittrackee_api/email/utils_email.py
Normal 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],
|
||||
}
|
@ -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()
|
||||
|
@ -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 you’re 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">© 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">© FitTrackee.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>"""
|
@ -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.'
|
||||
|
94
fittrackee_api/fittrackee_api/tests/test_email.py
Normal file
94
fittrackee_api/fittrackee_api/tests/test_email.py
Normal 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)
|
@ -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
|
42
fittrackee_api/fittrackee_api/tests/test_email_utils.py
Normal file
42
fittrackee_api/fittrackee_api/tests/test_email_utils.py
Normal 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
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
||||
|
18
fittrackee_api/poetry.lock
generated
18
fittrackee_api/poetry.lock
generated
@ -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"},
|
||||
]
|
||||
|
@ -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"
|
||||
|
Loading…
Reference in New Issue
Block a user