From 98f0fcdcbdbbd91a2a4da6b44229a178ddb38d31 Mon Sep 17 00:00:00 2001 From: Gertjan van den Burg Date: Tue, 26 Mar 2019 16:17:11 +0000 Subject: Add support for email confirmation --- app/admin/decorators.py | 21 ----------- app/admin/routes.py | 2 +- app/auth/email.py | 16 +++++++++ app/auth/routes.py | 64 +++++++++++++++++++++++++++++++--- app/decorators.py | 40 +++++++++++++++++++++ app/main/routes.py | 3 +- app/models.py | 17 +++++++++ app/templates/auth/not_confirmed.html | 9 +++++ app/templates/email/confirm_email.html | 8 +++++ app/templates/email/confirm_email.txt | 11 ++++++ 10 files changed, 164 insertions(+), 27 deletions(-) delete mode 100644 app/admin/decorators.py create mode 100644 app/decorators.py create mode 100644 app/templates/auth/not_confirmed.html create mode 100644 app/templates/email/confirm_email.html create mode 100644 app/templates/email/confirm_email.txt (limited to 'app') diff --git a/app/admin/decorators.py b/app/admin/decorators.py deleted file mode 100644 index f42b582..0000000 --- a/app/admin/decorators.py +++ /dev/null @@ -1,21 +0,0 @@ -# -*- coding: utf-8 -*- - -from functools import wraps - -from flask import current_app, request -from flask_login import current_user -from flask_login.config import EXEMPT_METHODS - - -def admin_required(func): - @wraps(func) - def decorated_view(*args, **kwargs): - if request.method in EXEMPT_METHODS: - return func(*args, **kwargs) - elif current_app.config.get("LOGIN_DISABLED"): - return func(*args, **kwargs) - elif not current_user.is_admin: - return current_app.login_manager.unauthorized() - return func(*args, **kwargs) - - return decorated_view diff --git a/app/admin/routes.py b/app/admin/routes.py index 8657c35..06a7795 100644 --- a/app/admin/routes.py +++ b/app/admin/routes.py @@ -9,7 +9,7 @@ from werkzeug.utils import secure_filename from app import db from app.admin import bp from app.admin.datasets import get_name_from_dataset, md5sum -from app.admin.decorators import admin_required +from app.decorators import admin_required from app.admin.forms import AdminManageTaskForm, AdminAddDatasetForm from app.models import User, Dataset, Task, Annotation diff --git a/app/auth/email.py b/app/auth/email.py index c071518..581c9ce 100644 --- a/app/auth/email.py +++ b/app/auth/email.py @@ -4,6 +4,7 @@ from flask import current_app, render_template from app.email import send_email + def send_password_reset_email(user): token = user.get_reset_password_token() send_email( @@ -17,3 +18,18 @@ def send_password_reset_email(user): "email/reset_password.html", user=user, token=token ), ) + + +def send_email_confirmation_email(user): + token = user.get_email_confirmation_token() + send_email( + "[AnnotateChange] Confirm your email", + sender=current_app.config["ADMINS"][0], + recipients=[user.email], + text_body=render_template( + "email/confirm_email.txt", user=user, token=token + ), + html_body=render_template( + "email/confirm_email.html", user=user, token=token + ), + ) diff --git a/app/auth/routes.py b/app/auth/routes.py index 7f7229e..7de091e 100644 --- a/app/auth/routes.py +++ b/app/auth/routes.py @@ -3,7 +3,7 @@ import datetime from flask import render_template, flash, redirect, url_for, request -from flask_login import current_user, login_user, logout_user, login_required +from flask_login import current_user, login_user, logout_user from werkzeug.urls import url_parse @@ -16,8 +16,12 @@ from app.auth.forms import ( ResetPasswordRequestForm, ResetPasswordForm, ) +from app.decorators import login_required from app.models import User -from app.auth.email import send_password_reset_email +from app.auth.email import ( + send_password_reset_email, + send_email_confirmation_email, +) @bp.route("/login", methods=("GET", "POST")) @@ -33,6 +37,8 @@ def login(): flash("Invalid username or password", "error") return redirect(url_for("auth.login")) login_user(user, remember=form.remember_me.data) + if not user.is_confirmed: + return redirect(url_for("auth.not_confirmed")) next_page = request.args.get("next") if not next_page or url_parse(next_page).netloc != "": next_page = url_for("main.index") @@ -56,8 +62,14 @@ def register(): user.set_password(form.password.data) db.session.add(user) db.session.commit() - flash("Thank you, you are now a registered user!", "info") - return redirect(url_for("auth.login")) + + send_email_confirmation_email(user) + flash( + "An email has been sent to confirm your account, please check your email.", + "info", + ) + + return redirect(url_for("auth.not_confirmed")) return render_template("auth/register.html", title="Register", form=form) @@ -94,3 +106,47 @@ def reset_password(token): flash("Your password has been reset.", "info") return redirect(url_for("auth.login")) return render_template("auth/reset_password.html", form=form) + + +@bp.route("/confirm/") +def confirm_email(token): + if current_user.is_authenticated and current_user.is_confirmed: + flash("Account is already confirmed.") + return redirect(url_for("main.index")) + user = User.verify_email_confirmation_token(token) + if not user: + flash("The confirmation link is invalid or has expired.", "error") + return redirect(url_for("main.index")) + if user.is_confirmed: + flash("Account is already confirmed, please login.", "success") + else: + user.is_confirmed = True + db.session.commit() + flash("Account confirmed successfully. Thank you!", "success") + return redirect(url_for("main.index")) + + +@bp.route("/not_confirmed") +def not_confirmed(): + if current_user.is_anonymous: + flash("Please login before accessing this page.") + return redirect(url_for("auth.login")) + if current_user.is_confirmed: + flash("Account is already confirmed.") + return redirect(url_for("main.index")) + flash("Please confirm your account before moving on.", "info") + return render_template("auth/not_confirmed.html") + + +@bp.route("/resend") +def resend_confirmation(): + if current_user.is_anonymous: + flash("Please login before accessing this page.") + return redirect(url_for("auth.login")) + if current_user.is_confirmed: + flash("Account is already confirmed.") + return redirect(url_for("main.index")) + send_email_confirmation_email(current_user) + email = current_user.email + flash("A new confirmation has been sent to %s." % email, "success") + return redirect(url_for("auth.not_confirmed")) diff --git a/app/decorators.py b/app/decorators.py new file mode 100644 index 0000000..d5b8821 --- /dev/null +++ b/app/decorators.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- + +from functools import wraps + +from flask import current_app, request, redirect, flash, url_for +from flask_login import current_user +from flask_login.config import EXEMPT_METHODS + + +def admin_required(func): + @wraps(func) + def decorated_view(*args, **kwargs): + if request.method in EXEMPT_METHODS: + return func(*args, **kwargs) + elif current_app.config.get("LOGIN_DISABLED"): + return func(*args, **kwargs) + elif not current_user.is_authenticated: + return current_app.login_manager.unauthorized() + elif not current_user.is_admin: + return current_app.login_manager.unauthorized() + return func(*args, **kwargs) + + return decorated_view + + +def login_required(func): + @wraps(func) + def decorated_view(*args, **kwargs): + if request.method in EXEMPT_METHODS: + return func(*args, **kwargs) + elif current_app.config.get("LOGIN_DISABLED"): + return func(*args, **kwargs) + elif not current_user.is_authenticated: + return current_app.login_manager.unauthorized() + elif not current_user.is_confirmed: + flash("hello world") + return redirect(url_for("auth.not_confirmed")) + return func(*args, **kwargs) + + return decorated_view diff --git a/app/main/routes.py b/app/main/routes.py index 5879b28..7e9505a 100644 --- a/app/main/routes.py +++ b/app/main/routes.py @@ -3,9 +3,10 @@ import datetime from flask import render_template, flash, url_for, redirect, request -from flask_login import current_user, login_required +from flask_login import current_user from app import db +from app.decorators import login_required from app.main import bp from app.models import Annotation, Task from app.main.datasets import load_data_for_chart diff --git a/app/models.py b/app/models.py index 86e15f0..3bc394c 100644 --- a/app/models.py +++ b/app/models.py @@ -50,6 +50,23 @@ class User(UserMixin, db.Model): return None return User.query.get(_id) + def get_email_confirmation_token(self, expires_in=3600): + return jwt.encode( + {"email": self.email, "exp": time.time() + expires_in}, + current_app.config["SECRET_KEY"], + algorithm="HS256", + ).decode("utf-8") + + @staticmethod + def verify_email_confirmation_token(token): + try: + _email = jwt.decode( + token, current_app.config["SECRET_KEY"], algorithms=["HS256"] + )["email"] + except: + return None + return User.query.filter_by(email=_email).first() + class Dataset(db.Model): id = db.Column(db.Integer, primary_key=True) diff --git a/app/templates/auth/not_confirmed.html b/app/templates/auth/not_confirmed.html new file mode 100644 index 0000000..3b55afd --- /dev/null +++ b/app/templates/auth/not_confirmed.html @@ -0,0 +1,9 @@ +{% extends "base.html" %} + +{% block app_content %} +

Welcome to AnnotateChange

+

You have not yet confirmed your email address. Please check your inbox for +the confirmation email. The email might have landed in your spam folder.

+

If you haven't received and email, please click here to resend it.

+ +{% endblock %} diff --git a/app/templates/email/confirm_email.html b/app/templates/email/confirm_email.html new file mode 100644 index 0000000..4581804 --- /dev/null +++ b/app/templates/email/confirm_email.html @@ -0,0 +1,8 @@ +

Dear {{ user.username }}, +

Welcome to AnnotateChange!

+

Please confirm your email by clicking here. +

+

Alternatively, you can paste the following link in your browser's address bar:

+

{{ url_for('auth.confirm_email', token=token, _external=True) }}

+

Sincerely,

+

The AnnotateChange Team

diff --git a/app/templates/email/confirm_email.txt b/app/templates/email/confirm_email.txt new file mode 100644 index 0000000..b17c66d --- /dev/null +++ b/app/templates/email/confirm_email.txt @@ -0,0 +1,11 @@ +Dear {{ user.username }}, + +Welcome to AnnotateChange! + +Please confirm your email by clicking on the following link: + +{{ url_for('auth.confirm_email', token=token, _external=True) }} + +Sincerely, + +The AnnotateChange Team -- cgit v1.2.3