diff options
Diffstat (limited to 'app')
| -rw-r--r-- | app/__init__.py | 47 | ||||
| -rw-r--r-- | app/config.py | 7 | ||||
| -rw-r--r-- | app/email.py | 36 | ||||
| -rw-r--r-- | app/errors.py | 13 | ||||
| -rw-r--r-- | app/forms.py | 13 | ||||
| -rw-r--r-- | app/models.py | 20 | ||||
| -rw-r--r-- | app/routes.py | 41 | ||||
| -rw-r--r-- | app/templates/404.html | 6 | ||||
| -rw-r--r-- | app/templates/500.html | 7 | ||||
| -rw-r--r-- | app/templates/email/reset_password.html | 12 | ||||
| -rw-r--r-- | app/templates/email/reset_password.txt | 11 | ||||
| -rw-r--r-- | app/templates/login.html | 4 | ||||
| -rw-r--r-- | app/templates/reset_password.html | 23 | ||||
| -rw-r--r-- | app/templates/reset_password_request.html | 15 |
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 %} |
