aboutsummaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-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
14 files changed, 252 insertions, 3 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 %}