aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/__init__.py47
-rw-r--r--app/config.py7
-rw-r--r--app/email.py36
-rw-r--r--app/errors.py13
-rw-r--r--app/forms.py13
-rw-r--r--app/models.py20
-rw-r--r--app/routes.py41
-rw-r--r--app/templates/404.html6
-rw-r--r--app/templates/500.html7
-rw-r--r--app/templates/email/reset_password.html12
-rw-r--r--app/templates/email/reset_password.txt11
-rw-r--r--app/templates/login.html4
-rw-r--r--app/templates/reset_password.html23
-rw-r--r--app/templates/reset_password_request.html15
-rw-r--r--poetry.lock33
-rw-r--r--pyproject.toml2
16 files changed, 286 insertions, 4 deletions
diff --git a/app/__init__.py b/app/__init__.py
index aaeb5a0..2c4cc32 100644
--- a/app/__init__.py
+++ b/app/__init__.py
@@ -2,10 +2,17 @@
__version__ = "0.1.0"
+import logging
+import os
+
+from logging.handlers import SMTPHandler, RotatingFileHandler
+
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from flask_login import LoginManager
+from flask_mail import Mail
+
from .config import Config
app = Flask(__name__)
@@ -13,6 +20,42 @@ app.config.from_object(Config)
db = SQLAlchemy(app)
migrate = Migrate(app, db)
login = LoginManager(app)
-login.login_view = 'login'
+login.login_view = "login"
+mail = Mail(app)
+
+from app import routes, models, errors
+
+if not app.debug:
+ if app.config["MAIL_SERVER"]:
+ auth = None
+ if app.config["MAIL_USERNAME"] or app.config["MAIL_PASSWORD"]:
+ auth = (app.config["MAIL_USERNAME"], app.config["MAIL_PASSWORD"])
+ secure = None
+ if app.config["MAIL_USE_TLS"]:
+ secure = ()
+ mail_handler = SMTPHandler(
+ mailhost=(app.config["MAIL_SERVER"], app.config["MAIL_PORT"]),
+ fromaddr="no-reply@" + app.config["MAIL_SERVER"],
+ toaddrs=app.config["ADMINS"],
+ subject="AnnotateChange Failure",
+ credentials=auth,
+ secure=secure,
+ )
+ mail_handler.setLevel(logging.ERROR)
+ app.logger.addHandler(mail_handler)
+
+ if not os.path.exists("logs"):
+ os.mkdir("logs")
+ file_handler = RotatingFileHandler(
+ "logs/annotatechange.log", maxBytes=10240, backupCount=10
+ )
+ file_handler.setFormatter(
+ logging.Formatter(
+ "%(asctime)s %(levelname)s: %(message)s [in %(pathname)s:%(lineno)d]"
+ )
+ )
+ file_handler.setLevel(logging.INFO)
+ app.logger.addHandler(file_handler)
-from app import routes, models
+ app.logger.setLevel(logging.INFO)
+ app.logger.info("AnnotateChange startup")
diff --git a/app/config.py b/app/config.py
index bd7561c..56e4910 100644
--- a/app/config.py
+++ b/app/config.py
@@ -13,3 +13,10 @@ class Config(object):
"DATABASE_URL"
) or "sqlite:///" + os.path.join(basedir, "app.db")
SQLALCHEMY_TRACK_MODIFICATIONS = False
+
+ MAIL_SERVER = os.environ.get('MAIL_SERVER')
+ MAIL_PORT = int(os.environ.get('MAIL_PORT') or 25)
+ MAIL_USE_TLS = os.environ.get('MAIL_USE_TLS') is not None
+ MAIL_USERNAME = os.environ.get('MAIL_USERNAME')
+ MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD')
+ ADMINS = ['gvandenburg@turing.ac.uk']
diff --git a/app/email.py b/app/email.py
new file mode 100644
index 0000000..8c71a50
--- /dev/null
+++ b/app/email.py
@@ -0,0 +1,36 @@
+# -*- coding: utf-8 -*-
+
+from threading import Thread
+
+from flask import render_template
+from flask_mail import Message
+
+from app import app
+from app import mail
+
+
+def send_async_email(app, msg):
+ with app.app_context():
+ mail.send(msg)
+
+
+def send_email(subject, sender, recipients, text_body, html_body):
+ msg = Message(subject, sender=sender, recipients=recipients)
+ msg.body = text_body
+ msg.html = html_body
+ Thread(target=send_async_email, args=(app, msg)).start()
+
+
+def send_password_reset_email(user):
+ token = user.get_reset_password_token()
+ send_email(
+ "[AnnotateChange] Reset your password",
+ sender=app.config["ADMINS"][0],
+ recipients=[user.email],
+ text_body=render_template(
+ "email/reset_password.txt", user=user, token=token
+ ),
+ html_body=render_template(
+ "email/reset_password.html", user=user, token=token
+ ),
+ )
diff --git a/app/errors.py b/app/errors.py
new file mode 100644
index 0000000..f459038
--- /dev/null
+++ b/app/errors.py
@@ -0,0 +1,13 @@
+# -*- coding: utf-8 -*-
+
+from flask import render_template
+from app import app, db
+
+@app.errorhandler(404)
+def not_found_error(error):
+ return render_template('404.html'), 404
+
+@app.errorhandler(500)
+def internal_error(error):
+ db.session.rollback()
+ return render_template('500.html'), 500
diff --git a/app/forms.py b/app/forms.py
index 96f23ef..8f7662a 100644
--- a/app/forms.py
+++ b/app/forms.py
@@ -37,3 +37,16 @@ class RegistrationForm(FlaskForm):
raise ValidationError(
"Email address already in use, please use a different one."
)
+
+
+class ResetPasswordRequestForm(FlaskForm):
+ email = StringField("Email", validators=[DataRequired(), Email()])
+ submit = SubmitField("Request password reset")
+
+
+class ResetPasswordForm(FlaskForm):
+ password = PasswordField("Password", validators=[DataRequired()])
+ password2 = PasswordField(
+ "Repeat Password", validators=[DataRequired(), EqualTo("password")]
+ )
+ submit = SubmitField("Request Password Reset")
diff --git a/app/models.py b/app/models.py
index 0483ff7..cb90fda 100644
--- a/app/models.py
+++ b/app/models.py
@@ -1,11 +1,14 @@
# -*- coding: utf-8 -*-
import datetime
+import jwt
+import time
from flask_login import UserMixin
from werkzeug.security import generate_password_hash, check_password_hash
+from app import app
from app import db
from app import login
@@ -28,6 +31,23 @@ class User(UserMixin, db.Model):
def check_password(self, password):
return check_password_hash(self.password_hash, password)
+ def get_reset_password_token(self, expires_in=600):
+ return jwt.encode(
+ {"reset_password": self.id, "exp": time.time() + expires_in},
+ app.config["SECRET_KEY"],
+ algorithm="HS256",
+ ).decode("utf-8")
+
+ @staticmethod
+ def verify_reset_password_token(token):
+ try:
+ _id = jwt.decode(
+ token, app.config["SECRET_KEY"], algorithms=["HS256"]
+ )["reset_password"]
+ except:
+ return None
+ return User.query.get(_id)
+
class Dataset(db.Model):
id = db.Column(db.Integer, primary_key=True)
diff --git a/app/routes.py b/app/routes.py
index a1a4f54..ba07a02 100644
--- a/app/routes.py
+++ b/app/routes.py
@@ -9,8 +9,15 @@ from werkzeug.urls import url_parse
from app import app
from app import db
-from app.forms import LoginForm, RegistrationForm
+
+from app.forms import (
+ LoginForm,
+ RegistrationForm,
+ ResetPasswordRequestForm,
+ ResetPasswordForm,
+)
from app.models import User
+from app.email import send_password_reset_email
@app.route("/")
@@ -59,3 +66,35 @@ def register():
flash("Thank you, you are now a registered user!")
return redirect(url_for("login"))
return render_template("register.html", title="Register", form=form)
+
+
+@app.route("/reset_password_request", methods=("GET", "POST"))
+def reset_password_request():
+ if current_user.is_authenticated:
+ return redirect(url_for("index"))
+ form = ResetPasswordRequestForm()
+ if form.validate_on_submit():
+ user = User.query.filter_by(email=form.email.data).first()
+ if user:
+ send_password_reset_email(user)
+ flash("Check your email for the instructions to reset your password.")
+ return redirect(url_for("login"))
+ return render_template(
+ "reset_password_request.html", title="Reset Password", form=form
+ )
+
+
+@app.route("/reset_password/<token>", methods=("GET", "POST"))
+def reset_password(token):
+ if current_user.is_authenticated:
+ return redirect(url_for("index"))
+ user = User.verify_reset_password_token(token)
+ if not user:
+ return redirect(url_for("index"))
+ form = ResetPasswordForm()
+ if form.validate_on_submit():
+ user.set_password(form.password.data)
+ db.session.commit()
+ flash("Your password has been reset.")
+ return redirect(url_for("login"))
+ return render_template("reset_password.html", form=form)
diff --git a/app/templates/404.html b/app/templates/404.html
new file mode 100644
index 0000000..7dde47e
--- /dev/null
+++ b/app/templates/404.html
@@ -0,0 +1,6 @@
+{% extends "base.html" %}
+
+{% block content %}
+ <h1>Page Not Found</h1>
+ <p><a href="{{ url_for('index') }}">Home</p>
+{% endblock %}
diff --git a/app/templates/500.html b/app/templates/500.html
new file mode 100644
index 0000000..ca8830c
--- /dev/null
+++ b/app/templates/500.html
@@ -0,0 +1,7 @@
+{% extends "base.html" %}
+
+{% block content %}
+ <h1>An unexpected error has occurred</h1>
+ <p>The administrator has been notified, apologies for the inconvenience.</p>
+ <p><a href="{{ url_for('index') }}">Home</p>
+{% endblock %}
diff --git a/app/templates/email/reset_password.html b/app/templates/email/reset_password.html
new file mode 100644
index 0000000..f7403a5
--- /dev/null
+++ b/app/templates/email/reset_password.html
@@ -0,0 +1,12 @@
+<p>Dear {{ user.username }},</p>
+<p>
+ To reset your password
+ <a href="{{ url_for('reset_password', token=token, _external=True) }}">
+ click here
+ </a>.
+</p>
+<p>Alternatively, you can paste the following link in your browser's address bar:</p>
+<p>{{ url_for('reset_password', token=token, _external=True) }}</p>
+<p>If you have not requested a password reset then you can simply ignore this email.</p>
+<p>Sincerely,</p>
+<p>The AnnotateChange Team</p>
diff --git a/app/templates/email/reset_password.txt b/app/templates/email/reset_password.txt
new file mode 100644
index 0000000..da1330f
--- /dev/null
+++ b/app/templates/email/reset_password.txt
@@ -0,0 +1,11 @@
+Dear {{ user.username }},
+
+To reset your password click on the following link:
+
+{{ url_for('reset_password', token=token, _external=True) }}
+
+If you have not requested a password reset then you can simply ignore this email.
+
+Sincerely,
+
+The AnnotateChange Team
diff --git a/app/templates/login.html b/app/templates/login.html
index 77410a1..4d5181b 100644
--- a/app/templates/login.html
+++ b/app/templates/login.html
@@ -22,4 +22,8 @@
<p>{{ form.submit() }}</p>
</form>
<p>New User? <a href="{{ url_for('register') }}">Click to Register!</a></p>
+ <p>
+ Forgot your password?
+ <a href="{{ url_for('reset_password_request') }}">Click here to reset it</a>
+ </p>
{% endblock %}
diff --git a/app/templates/reset_password.html b/app/templates/reset_password.html
new file mode 100644
index 0000000..da3aa95
--- /dev/null
+++ b/app/templates/reset_password.html
@@ -0,0 +1,23 @@
+{% extends "base.html" %}
+
+{% block content %}
+ <h1>Reset Your Password</h1>
+ <form action="" method="post">
+ {{ form.hidden_tag() }}
+ <p>
+ {{ form.password.label }}<br>
+ {{ form.password(size=32) }}<br>
+ {% for error in form.password.errors %}
+ <span stle="color: red;">[{{ error }}]</span>
+ {% endfor %}
+ </p>
+ <p>
+ {{ form.password2.label }}<br>
+ {{ form.password2(size=32) }}<br>
+ {% for error in form.password2.errors %}
+ <span stle="color: red;">[{{ error }}]</span>
+ {% endfor %}
+ </p>
+ <p>{{ form.submit() }}</p>
+ </form>
+{% endblock %}
diff --git a/app/templates/reset_password_request.html b/app/templates/reset_password_request.html
new file mode 100644
index 0000000..914c593
--- /dev/null
+++ b/app/templates/reset_password_request.html
@@ -0,0 +1,15 @@
+{% extends "base.html" %}
+
+{% block content %}
+ <h1>Reset Password</h1>
+ <form action="" method="post">
+ {{ form.hidden_tag() }}
+ <p>
+ {{ form.email.label }}<br>
+ {{ form.email(size=64) }}<br>
+ {% for error in form.email.errors %}
+ <span style="color: red;">[{{ error }}]</span>
+ {% endfor %}
+ </p>
+ </form>
+{% endblock %}
diff --git a/poetry.lock b/poetry.lock
index f617752..e55db49 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -30,6 +30,14 @@ version = "19.1.0"
[[package]]
category = "main"
+description = "Fast, simple object-to-object and broadcast signaling"
+name = "blinker"
+optional = false
+python-versions = "*"
+version = "1.4"
+
+[[package]]
+category = "main"
description = "Composable command line interface toolkit"
name = "click"
optional = false
@@ -72,6 +80,18 @@ Flask = "*"
[[package]]
category = "main"
+description = "Flask extension for sending email"
+name = "flask-mail"
+optional = false
+python-versions = "*"
+version = "0.9.1"
+
+[package.dependencies]
+Flask = "*"
+blinker = "*"
+
+[[package]]
+category = "main"
description = "SQLAlchemy database migrations for Flask applications using Alembic"
name = "flask-migrate"
optional = false
@@ -170,6 +190,14 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
version = "1.8.0"
[[package]]
+category = "main"
+description = "JSON Web Token implementation in Python"
+name = "pyjwt"
+optional = false
+python-versions = "*"
+version = "1.7.1"
+
+[[package]]
category = "dev"
description = "pytest: simple powerful testing with Python"
name = "pytest"
@@ -239,17 +267,19 @@ python-versions = "*"
version = "2.2.1"
[metadata]
-content-hash = "e6689d7bbc818777fee652ee8c8b6d54bf9ceb5bbce6c67adf74b39add3ecd48"
+content-hash = "9a6fe4da6688473eccb96a2040775d70a02f90bf4e77527a88af7c8a0da31964"
python-versions = "^3.7"
[metadata.hashes]
alembic = ["505d41e01dc0c9e6d85c116d0d35dbb0a833dcb490bf483b75abeb06648864e8"]
atomicwrites = ["03472c30eb2c5d1ba9227e4c2ca66ab8287fbfbbda3888aa93dc2e28fc6811b4", "75a9445bac02d8d058d5e1fe689654ba5a6556a1dfd8ce6ec55a0ed79866cfa6"]
attrs = ["69c0dbf2ed392de1cb5ec704444b08a5ef81680a61cb899dc08127123af36a79", "f0b870f674851ecbfbbbd364d6b5cbdff9dcedbc7f3f5e18a6891057f21fe399"]
+blinker = ["471aee25f3992bd325afa3772f1063dbdbbca947a041b8b89466dc00d606f8b6"]
click = ["2335065e6395b9e67ca716de5f7526736bfa6ceead690adf616d925bdc622b13", "5b94b49521f6456670fdb30cd82a4eca9412788a93fa6dd6df72c94d5a8ff2d7"]
colorama = ["05eed71e2e327246ad6b38c540c4a3117230b19679b875190486ddd2d721422d", "f8ac84de7840f5b9c4e3347b3c1eaa50f7e49c2b07596221daec5edaabbd7c48"]
flask = ["2271c0070dbcb5275fad4a82e29f23ab92682dc45f9dfbc22c02ba9b9322ce48", "a080b744b7e345ccfcbc77954861cb05b3c63786e93f2b3875e0913d44b43f05"]
flask-login = ["c815c1ac7b3e35e2081685e389a665f2c74d7e077cb93cecabaea352da4752ec"]
+flask-mail = ["22e5eb9a940bf407bcf30410ecc3708f3c56cc44b29c34e1726fe85006935f41"]
flask-migrate = ["a361578cb829681f860e4de5ed2c48886264512f0c16144e404c36ddc95ab49c", "c24d105c5d6cc670de20f8cbfb909e04f4e04b8784d0df070005944de1f21549"]
flask-sqlalchemy = ["3bc0fac969dd8c0ace01b32060f0c729565293302f0c4269beed154b46bec50b", "5971b9852b5888655f11db634e87725a9031e170f37c0ce7851cf83497f56e53"]
flask-wtf = ["5d14d55cfd35f613d99ee7cba0fc3fbbe63ba02f544d349158c14ca15561cc36", "d9a9e366b32dcbb98ef17228e76be15702cd2600675668bca23f63a7947fd5ac"]
@@ -260,6 +290,7 @@ markupsafe = ["00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473"
more-itertools = ["0125e8f60e9e031347105eb1682cef932f5e97d7b9a1a28d9bf00c22a5daef40", "590044e3942351a1bdb1de960b739ff4ce277960f2425ad4509446dbace8d9d1"]
pluggy = ["19ecf9ce9db2fce065a7a0586e07cfb4ac8614fe96edf628a264b1c70116cf8f", "84d306a647cc805219916e62aab89caa97a33a1dd8c342e87a37f91073cd4746"]
py = ["64f65755aee5b381cea27766a3a147c3f15b9b6b9ac88676de66ba2ae36793fa", "dc639b046a6e2cff5bbe40194ad65936d6ba360b52b3c3fe1d08a82dd50b5e53"]
+pyjwt = ["5c6eca3c2940464d106b99ba83b00c6add741c9becaec087fb7ccdefea71350e", "8d59a976fb773f3e6a39c85636357c4f0e242707394cadadd9814f5cbaa20e96"]
pytest = ["3f193df1cfe1d1609d4c583838bea3d532b18d6160fd3f55c9447fdca30848ec", "e246cf173c01169b9617fc07264b7b1316e78d7a650055235d6d897bc80d9660"]
python-dateutil = ["7e6584c74aeed623791615e26efd690f29817a27c73085b78e4bad02493df2fb", "c89805f6f4d64db21ed966fda138f8a5ed7a4fdbc1a8ee329ce1b74e3c74da9e"]
python-editor = ["1bf6e860a8ad52a14c3ee1252d5dc25b2030618ed80c022598f00176adc8367d", "51fda6bcc5ddbbb7063b2af7509e43bd84bfc32a4ff71349ec7847713882327b", "5f98b069316ea1c2ed3f67e7f5df6c0d8f10b689964a4a811ff64f0106819ec8", "c3da2053dbab6b29c94e43c486ff67206eafbe7eb52dbec7390b5e2fb05aac77", "ea87e17f6ec459e780e4221f295411462e0d0810858e055fc514684350a2f522"]
diff --git a/pyproject.toml b/pyproject.toml
index 2372f40..c7c8391 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -11,6 +11,8 @@ flask-wtf = "^0.14.2"
flask-sqlalchemy = "^2.3"
flask-migrate = "^2.4"
flask-login = "^0.4.1"
+flask-mail = "^0.9.1"
+pyjwt = "^1.7"
[tool.poetry.dev-dependencies]
pytest = "^3.0"