From 5ac6ee9abe9b235fea6e8b57af901f933f7ff1d1 Mon Sep 17 00:00:00 2001 From: Fabian Freyer Date: Tue, 27 Oct 2020 17:53:20 +0100 Subject: [PATCH] refactor_mail_confirmation --- app/user/__init__.py | 12 -- app/user/admin.py | 6 +- app/user/helpers.py | 226 ++++++++++++++++------ app/user/models.py | 41 ---- app/user/templates/confirm_mail.j2 | 10 + app/user/templates/password_reset_mail.j2 | 10 + app/user/views.py | 86 +++----- config.py | 3 + 8 files changed, 218 insertions(+), 176 deletions(-) create mode 100644 app/user/templates/confirm_mail.j2 create mode 100644 app/user/templates/password_reset_mail.j2 diff --git a/app/user/__init__.py b/app/user/__init__.py index 880464b..d229c83 100644 --- a/app/user/__init__.py +++ b/app/user/__init__.py @@ -47,18 +47,6 @@ def login_required(f): def wrapped(*args, **kwargs): if not current_user or current_user.is_anonymous: return redirect(url_for("user.login")) - if not current_user.confirm_mail or not current_user.confirm_mail["confirmed"]: - if current_user.confirm_mail and current_user.confirm_mail["token"]: - flash("Your mail is not confirmed yet.", "warning") - return redirect(url_for("user.confirm_mail_resend")) - else: - current_user.confirm_mail_start() - flash( - "Your mail is not confirmed yet. A confirmation mail has been sent now", - "warning", - ) - logout_user() - return redirect(url_for("user.login")) return f(*args, **kwargs) return wrapped diff --git a/app/user/admin.py b/app/user/admin.py index 3fe75d2..e4b2b25 100644 --- a/app/user/admin.py +++ b/app/user/admin.py @@ -8,6 +8,7 @@ from . import groups_required, user_blueprint, login_required from .models import User from .views import MailInUseValidator +from .helpers import UserEmailChangeRequest class UserEditForm(FlaskForm): @@ -100,9 +101,10 @@ def edit_user(username, back_url=None): ) if not current_user.is_admin and old_mail != form.mail.data: - user.confirm_mail_start() + UserEmailChangeRequest(user.username, form.mail.data) flash( - "E-Mail changed, new confirmation required. Check your mails", "warning" + "A confirmation email will be sent to your updated email address.", + "warning", ) flash("User information changed", "success") diff --git a/app/user/helpers.py b/app/user/helpers.py index 805406b..2d353a3 100644 --- a/app/user/helpers.py +++ b/app/user/helpers.py @@ -1,60 +1,168 @@ -from flask import current_app, url_for +from flask import current_app, url_for, render_template, flash, redirect from flask_mail import Message - - -def send_password_reset_mail(user): - msg = Message( - f"{current_app.config['BRANDING']}: Passwort zurücksetzen", - recipients=[user.mail], - sender=current_app.config["MAIL_DEFAULT_SENDER"], - ) - url = url_for( - "user.reset_password_finish", - username=user.username, - token=user.reset_password[0], - _external=True, - ) - - msg.body = """Hallo Benutzer "{0.username}", - -du kannst dein Passwort auf folgender Website zurücksetzen: - -{1} - -Der Link ist für 1 Tag gültig. - -Viele Grüße -Dein {2}}""".format( - user, url, current_app.config["BRANDING"] - ) - - current_app.mail.send(msg) - - -def send_confirm_mail(user): - msg = Message( - f"{current_app.config['BRANDING']}: E-Mail bestätigen", - recipients=[user.mail], - sender=current_app.config["MAIL_DEFAULT_SENDER"], - ) - url = url_for( - "user.confirm_mail_finish", - username=user.username, - token=user.confirm_mail["token"], - _external=True, - ) - - msg.body = """Hallo Benutzer "{0.username}", - -um deine E-Mail zu bestätigen, gehe bitte auf folgende Website: - -{1} - -Der Link ist für 1 Tag gültig. - -Viele Grüße -Dein {2}}""".format( - user, url, current_app.config["BRANDING"] - ) - - current_app.mail.send(msg) +from .models import User + + +class EmailConfirmation(object): + _confirm_mail_token = None + TOKEN_NAMESPACE = "confirm_mail" + + @classmethod + def get(cls, token): + key = cls.cache_key(token) + print(key) + instance = current_app.cache.get(key) + return instance + + @classmethod + def cache_key(cls, token): + return "user/{namespace}/{token}".format( + namespace=cls.TOKEN_NAMESPACE, token=token + ) + + def __init__(self, username, mail, subject, body): + self.subject = subject + self.body = body + self.send_mail(username, mail) + cache_key = self.cache_key(self.confirm_mail_token) + print(cache_key) + current_app.cache.set(cache_key, self) + + @property + def confirm_mail_token(self): + if self._confirm_mail_token is None: + import secrets + + self._confirm_mail_token = secrets.token_urlsafe( + current_app.config["CONFIRM_MAIL_TOKEN_LENGTH"] + ) + return self._confirm_mail_token + + def send_mail(self, username, mail): + msg = Message( + self.subject, + recipients=[mail], + sender=current_app.config["MAIL_DEFAULT_SENDER"], + ) + + msg.body = self.body + + if current_app.config["MAIL_SUPPRESS_SEND"]: + # log mail instead + print(" * Would send the following mail:") + print(msg) + + current_app.mail.send(msg) + + def confirm(self): + raise NotImplementedError() + + +class UserCreationRequest(EmailConfirmation): + """ + Represents a user with an unconfirmed email. Not added to the LDAP yet, but + saved in the cache. + """ + + def __init__(self, username, password, mail, *args, **kwargs): + kwargs.update( + { + "username": username, + "password": password, + "mail": mail, + } + ) + + self.params = (args, kwargs) + subject = "{branding}: E-Mail bestätigen".format( + branding=current_app.config["BRANDING"] + ) + url = url_for( + "user.confirm_mail_finish", + token=self.confirm_mail_token, + _external=True, + ) + body = render_template( + "confirm_mail.j2", + username=username, + url=url, + branding=current_app.config["BRANDING"], + ) + super(UserCreationRequest, self).__init__(username, mail, subject, body) + + def confirm(self): + User.create(*self.params[0], **self.params[1]) + flash("Your user account has been created!", "success") + return redirect("/") + + +class UserEmailChangeRequest(EmailConfirmation): + def __init__(self, username, mail): + self.username = username + self.mail = mail + subject = "{branding}: E-Mail bestätigen".format( + branding=current_app.config["BRANDING"] + ) + + url = url_for( + "user.confirm_mail_finish", + token=self.confirm_mail_token, + _external=True, + ) + + body = render_template( + "confirm_mail.j2", + username=username, + url=url, + branding=current_app.config["BRANDING"], + ) + super(UserEmailChangeRequest, self).__init__(username, mail, subject, body) + + def confirm(self): + user = User.get(self.username) + + if not user: + raise Exception("Invalid user in email change request!") + + user.mail = self.mail + user.save() + flash("E-Mail confirmed", "success") + return redirect("/") + + +class PasswordResetRequest(EmailConfirmation): + TOKEN_NAMESPACE = "reset_password" + + def __init__(self, username): + self._user = None + self.username = username + subject = "{branding}: Passwort zurücksetzen".format( + branding=current_app.config["BRANDING"] + ) + url = url_for( + "user.reset_password_finish", + token=self.confirm_mail_token, + _external=True, + ) + + body = render_template( + "password_reset_mail.j2", + username=username, + url=url, + branding=current_app.config["BRANDING"], + ) + super(PasswordResetRequest, self).__init__( + username, self.user.mail, subject, body + ) + + @property + def user(self): + if not self._user: + self._user = User.get(self.username) + return self._user + + def confirm(self, password): + self.user.password = password + self.user.save() + flash("New password has been set.", "info") + return redirect(url_for("user.login")) diff --git a/app/user/models.py b/app/user/models.py index b6fa1f4..6dd21b7 100644 --- a/app/user/models.py +++ b/app/user/models.py @@ -4,11 +4,8 @@ import time from flask_login import UserMixin, AnonymousUserMixin from flask import current_app - from app.orm import LDAPOrm -from .helpers import send_password_reset_mail, send_confirm_mail - class AnonymousUser(AnonymousUserMixin): @property @@ -40,8 +37,6 @@ def __init__(self, username=None, dn=None, firstName=None, surname=None, mail=No self._full_name = None self._password = None self.mail = mail - self.reset_password = None - self.confirm_mail = None # FIXME: This could be simplified to just create a User object, populate it, @staticmethod @@ -126,7 +121,6 @@ def _orm_mapping_load(self, entry): self.surname = entry.sn.value self._full_name = entry.cn.value self.mail = entry.mail.value - self.data = entry.description.value def _orm_mapping_save(self, entry): # FIXME: It would be nice if the ORM could somehow automagically @@ -134,7 +128,6 @@ def _orm_mapping_save(self, entry): entry.sn = self.surname entry.cn = self.full_name entry.givenName = self.firstName - entry.description = self.data if self.password: entry.userPassword = self._password if self.mail: @@ -152,40 +145,6 @@ def full_name(self): firstName=self.firstName, surname=self.surname ) - @property - def data(self): - return json.dumps( - {"confirm_mail": self.confirm_mail, "reset_password": self.reset_password} - ) - - @data.setter - def data(self, value): - try: - jsonData = json.loads(value) - except: - self.confirm_mail = None - self.reset_password = None - return - self.confirm_mail = jsonData["confirm_mail"] - self.reset_password = jsonData["reset_password"] - - def reset_password_start(self): - self.reset_password = ( - binascii.hexlify(os.urandom(20)).decode("ascii"), - int(time.time()) + 24 * 60 * 60, - ) - send_password_reset_mail(self) - self.save() - - def confirm_mail_start(self): - self.confirm_mail = { - "confirmed": False, - "token": binascii.hexlify(os.urandom(20)).decode("ascii"), - "valid_till": int(time.time()) + 24 * 60 * 60, - } - send_confirm_mail(self) - self.save() - class Group(LDAPOrm): # This doesn't really work either, so we have to overload _basedn diff --git a/app/user/templates/confirm_mail.j2 b/app/user/templates/confirm_mail.j2 new file mode 100644 index 0000000..3bb7046 --- /dev/null +++ b/app/user/templates/confirm_mail.j2 @@ -0,0 +1,10 @@ +Hallo {{username}}, + +um deine E-Mail zu bestätigen, gehe bitte auf folgende Website: + +{{url}} + +Der Link ist für 1 Tag gültig. + +Viele Grüße +Dein {{branding}} \ No newline at end of file diff --git a/app/user/templates/password_reset_mail.j2 b/app/user/templates/password_reset_mail.j2 new file mode 100644 index 0000000..8a984ef --- /dev/null +++ b/app/user/templates/password_reset_mail.j2 @@ -0,0 +1,10 @@ +Hallo {{username}}, + +du kannst dein Passwort auf folgender Website zurücksetzen: + +{{ url }} + +Der Link ist für 1 Tag gültig. + +Viele Grüße +Dein {{ branding }} \ No newline at end of file diff --git a/app/user/views.py b/app/user/views.py index 9fe2c72..daceb30 100644 --- a/app/user/views.py +++ b/app/user/views.py @@ -10,6 +10,7 @@ import time from app.views import is_safe_url, get_redirect_target from . import login_required +from .helpers import UserCreationRequest, UserEmailChangeRequest, PasswordResetRequest class UsernameInUseValidator(object): @@ -98,10 +99,6 @@ class PasswordResetFinishForm(FlaskForm): submit = SubmitField("Set new password") -class ResendConfirmMailForm(FlaskForm): - submit = SubmitField("Resend confirmation mail") - - @user_blueprint.route("/") @login_required def home(): @@ -131,17 +128,17 @@ def login(): def signup(): form = SignUpForm() if form.validate_on_submit(): - user = User.create( + UserCreationRequest( username=form.username.data, password=form.password.data, givenName=form.givenName.data, surname=form.surname.data, mail=form.mail.data, ) - user.confirm_mail_start() - current_app.logger.info("creating user: {}".format(user)) - flash("Your user account has been created.", "info") - flash("Your E-Mail has to be confirmed before you can login!", "warning") + flash( + "Your E-Mail has to be confirmed before your user account is created!", + "warning", + ) return form.redirect() return render_template("/signup.html", form=form) @@ -180,7 +177,8 @@ def reset_password_start(): if len(users) > 0: user = users[0] if user: - user.reset_password_start() + PasswordResetRequest(username=user.username) + flash( "If your username or mail is valid, you should recive a mail with instructions soon!", "info", @@ -189,66 +187,30 @@ def reset_password_start(): return render_template("reset_password_start.html", form=form) -@user_blueprint.route( - "/user/reset_password//", methods=["GET", "POST"] -) -def reset_password_finish(username, token): - user = User.get(username) - - if not user: +@user_blueprint.route("/user/reset_password/", methods=["GET", "POST"]) +def reset_password_finish(token): + valid_request = PasswordResetRequest.get(token) + print(valid_request) + if not valid_request: + flash("Invalid token!") return redirect(url_for("user.login")) - if ( - not user.reset_password - or user.reset_password[0] != token - or user.reset_password[1] < int(time.time()) - ): - flash("Your password reset token is invalid or expired.", "error") - return redirect(url_for("user.home")) form = PasswordResetFinishForm() if form.validate_on_submit(): - user.password = form.password.data - user.reset_password = None - user.save() - flash("New password has been set.", "info") - return redirect(url_for("user.login")) + return valid_request.confirm(form.password.data) return render_template("reset_password_finish.html", form=form) -@user_blueprint.route("/user/confirm_mail", methods=["GET", "POST"]) -@flask_login.login_required -def confirm_mail_resend(): - form = ResendConfirmMailForm() - if form.validate_on_submit(): - current_user.confirm_mail_start() - logout_user() - return redirect(url_for("user.login")) - return render_template("resend_confirm_mail.html", form=form) - - -@user_blueprint.route( - "/user/confirm_mail//", methods=["GET", "POST"] -) -def confirm_mail_finish(username, token): - user = User.get(username) - - if not user: - return redirect(url_for("user.login")) - if ( - not user.confirm_mail - or user.confirm_mail["token"] != token - or user.confirm_mail["valid_till"] < int(time.time()) - ): - flash("Your mail confirmation token is invalid or expired.", "error") +@user_blueprint.route("/user/confirm_mail/", methods=["GET", "POST"]) +def confirm_mail_finish(token): + confirmation_request = current_app.cache.get( + "user/confirm_mail/{token}".format(token=token) + ) + print(confirmation_request) + if not confirmation_request: + flash("Invalid token!") return redirect(url_for("user.login")) - user.confirm_mail["confirmed"] = True - user.confirm_mail["token"] = None - user.confirm_mail["valid_till"] = None - user.save() - - flash("E-Mail confirmed", "success") - - return redirect("/") + return confirmation_request.confirm() diff --git a/config.py b/config.py index 92c4d58..b2c77db 100644 --- a/config.py +++ b/config.py @@ -44,6 +44,8 @@ class Config: PASSWORD_HASHING_FUNC = ldap3.HASHED_SALTED_SHA384 + CONFIRM_MAIL_TOKEN_LENGTH = 20 + @staticmethod def init_app(app): pass @@ -65,6 +67,7 @@ class DevelopmentConfig(Config): PASSWORD_HASHING_FUNC = ldap3.HASHED_SALTED_SHA # MOCKSERVER = True + MAIL_SUPPRESS_SEND = True class ProductionConfig(Config):