Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor email confirmation #71

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/templates/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
<span class="icon-bar"></span>
</button>
<a class="navbar-brand" href="{{url_for('user.home')}}">
<span class="glyphicon glyphicon-lock" aria-hidden="true"></span> ZaPF-Auth
<span class="glyphicon glyphicon-lock" aria-hidden="true"></span> {{config["BRANDING"]}}
</a>
</div>

Expand Down
12 changes: 0 additions & 12 deletions app/user/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions app/user/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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")
Expand Down
226 changes: 167 additions & 59 deletions app/user/helpers.py
Original file line number Diff line number Diff line change
@@ -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(
"ZaPF-Auth-System: 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 ZaPF-Auth-System""".format(
user, url
)

current_app.mail.send(msg)


def send_confirm_mail(user):
msg = Message(
"ZaPF-Auth-System: 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 ZaPF-Auth-System""".format(
user, url
)

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"))
41 changes: 0 additions & 41 deletions app/user/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -126,15 +121,13 @@ 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
# build up this mapping.
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:
Expand All @@ -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
Expand Down
10 changes: 10 additions & 0 deletions app/user/templates/confirm_mail.j2
Original file line number Diff line number Diff line change
@@ -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}}
10 changes: 10 additions & 0 deletions app/user/templates/password_reset_mail.j2
Original file line number Diff line number Diff line change
@@ -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 }}
Loading