diff --git a/app/templates/base.html b/app/templates/base.html index 1f76832..2c0bc3a 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -18,7 +18,7 @@ - ZaPF-Auth + {{config["BRANDING"]}} 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 8acc3bd..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( - "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")) 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 05818ad..b2c77db 100644 --- a/config.py +++ b/config.py @@ -6,6 +6,7 @@ class Config: FLASK_COVERAGE = 0 MOCKSERVER = False + BRANDING = "Auth" LDAP_HOST = "localhost" LDAP_PORT = 8369 LDAP_BASE_DN = "dc=my-domain,dc=com" @@ -34,8 +35,7 @@ class Config: ] MAIL_USE_TLS = True - MAIL_DEFAULT_SENDER = "topf@zapf.in" - MAIL_NEXT_ZAPF_ORGA = "topf@zapf.in" + MAIL_DEFAULT_SENDER = "mail@example.com" CACHE_TYPE = "simple" @@ -44,18 +44,30 @@ class Config: PASSWORD_HASHING_FUNC = ldap3.HASHED_SALTED_SHA384 + CONFIRM_MAIL_TOKEN_LENGTH = 20 + @staticmethod def init_app(app): pass class DevelopmentConfig(Config): + print(" * Using development config!") + SECRET_KEY = "secrets" DEBUG = True RECAPTCHA_PUBLIC_KEY = "6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI" RECAPTCHA_PRIVATE_KEY = "6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe" RECAPTCHA_USE_SSL = False + + LDAP_BASE_DN = "dc=example,dc=com" + LDAP_BIND_USER_DN = "uid=auth,dc=example,dc=com" + LDAP_BIND_USER_PASSWORD = "test" + import ldap3 + + PASSWORD_HASHING_FUNC = ldap3.HASHED_SALTED_SHA # MOCKSERVER = True + MAIL_SUPPRESS_SEND = True class ProductionConfig(Config): diff --git a/docker-compose.yml b/docker-compose.yml index 9f1e8f5..a71e101 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,8 +3,8 @@ services: openldap: image: osixia/openldap environment: - LDAP_ORGANISATION: "ZaPF" - LDAP_DOMAIN: "zapf.in" + LDAP_ORGANISATION: "Example Org." + LDAP_DOMAIN: "example.com" LDAP_BACKEND: "mdb" expose: - "389" @@ -22,9 +22,9 @@ services: command: --copy-service auth: build: . - image: zapf-auth:latest + image: auth:latest ports: - - "80" + - "5000:80" volumes: - type: bind source: ./docker/auth.conf diff --git a/docker.md b/docker.md index b43b6c8..b90fb42 100644 --- a/docker.md +++ b/docker.md @@ -13,7 +13,7 @@ An OpenLDAP server is provided using the [osixia/openldap] docker image. **Make sure to change the passwords for the following default accounts:** -The admin DN for the main tree at `dc=zapf,dc=in` is `cn=admin,dc=zapf,dc=in` +The admin DN for the main tree at `dc=example,dc=com` is `cn=admin,dc=example,dc=com` with the password specified in `LDAP_ADMIN_PASSWORD` environment variable. This defaults to `admin`. @@ -21,7 +21,7 @@ The admin DN for the config tree at `cn=config` is `cn=admin,cn=config` with the password specified in `LDAP_CONFIG_PASSWORD`. This defaults to `config`. -The bind user for the auth application is `uid=zapf-auth,dc=zapf,dc=in`. +The bind user for the auth application is `uid=auth,dc=example,dc=com`. The password is specified in the [`docker/bootstrap_openldap/ldif/bootstrap.ldif`] file. This defaults to `test`. @@ -29,8 +29,8 @@ When changing this password, hash it using `slappasswd`. ### Tree Structure -The OU entries for `ou=people,dc=zapf,dc=in`, `ou=groups,dc=zapf,dc=in` and -`ou=oauth,dc=zapf,dc=in` are created on startup. +The OU entries for `ou=people,dc=example,dc=com`, `ou=groups,dc=example,dc=com` and +`ou=oauth,dc=example,dc=com` are created on startup. ## App configuration @@ -46,8 +46,8 @@ BOOTSTRAP_SERVE_LOCAL = True # LDAP LDAP_HOST = 'openldap' LDAP_PORT = 389 -LDAP_BASE_DN = 'dc=zapf,dc=in' -LDAP_BIND_USER_DN = 'uid=zapf-auth,dc=zapf,dc=in' +LDAP_BASE_DN = 'dc=example,dc=com' +LDAP_BIND_USER_DN = 'uid=auth,dc=example,dc=com' LDAP_BIND_USER_PASSWORD = 'test' import ldap3 PASSWORD_HASHING_FUNC = ldap3.HASHED_SALTED_SHA @@ -62,7 +62,7 @@ MAIL_SERVER='smtp.example.org' MAIL_PORT=465 MAIL_USE_TLS = False MAIL_USE_SSL=True -MAIL_USERNAME='zapf-auth-sender' +MAIL_USERNAME='auth-sender' MAIL_PASSWORD='CHANGEME' # To prevent open redirects in OAuth logout diff --git a/docker/bootstrap_openldap/acl.ldif b/docker/bootstrap_openldap/acl.ldif index 3ea6a79..ab70e32 100644 --- a/docker/bootstrap_openldap/acl.ldif +++ b/docker/bootstrap_openldap/acl.ldif @@ -5,9 +5,9 @@ delete: olcAccess add: olcAccess olcAccess: {0}to dn.exact="" by * read olcAccess: {1}to dn.base="cn=Subschema" by * read -olcAccess: {2}to attrs=userpassword by self =xw by dn.base="uid=zapf-auth,dc=zapf,dc=in" break by anonymous auth by * none -olcAccess: {3}to dn.subtree="ou=users,dc=zapf,dc=in" by self write by dn.base="uid=zapf-auth,dc=zapf,dc=in" write by dn.subtree="ou=users,dc=zapf,dc=in" search -olcAccess: {4}to dn.children="ou=groups,dc=zapf,dc=in" attrs=cn,entry by dn.base="uid=zapf-auth,dc=zapf,dc=in" break by dnattr=member read -olcAccess: {5}to dn.subtree="ou=groups,dc=zapf,dc=in" by dn.base="uid=zapf-auth,dc=zapf,dc=in" write by dn.subtree="ou=users,dc=zapf,dc=in" search -olcAccess: {6}to dn.subtree="ou=oauth2,dc=zapf,dc=in" by dn.base="uid=zapf-auth,dc=zapf,dc=in" write -olcAccess: {7}to dn.subtree="dc=zapf,dc=in" by dn.base="uid=zapf-auth,dc=zapf,dc=in" search +olcAccess: {2}to attrs=userpassword by self =xw by dn.base="uid=auth,dc=example,dc=com" break by anonymous auth by * none +olcAccess: {3}to dn.subtree="ou=users,dc=example,dc=com" by self write by dn.base="uid=auth,dc=example,dc=com" write by dn.subtree="ou=users,dc=example,dc=com" search +olcAccess: {4}to dn.children="ou=groups,dc=example,dc=com" attrs=cn,entry by dn.base="uid=auth,dc=example,dc=com" break by dnattr=member read +olcAccess: {5}to dn.subtree="ou=groups,dc=example,dc=com" by dn.base="uid=auth,dc=example,dc=com" write by dn.subtree="ou=users,dc=example,dc=com" search +olcAccess: {6}to dn.subtree="ou=oauth2,dc=example,dc=com" by dn.base="uid=auth,dc=example,dc=com" write +olcAccess: {7}to dn.subtree="dc=example,dc=com" by dn.base="uid=auth,dc=example,dc=com" search diff --git a/docker/bootstrap_openldap/ldif/bootstrap.ldif b/docker/bootstrap_openldap/ldif/bootstrap.ldif index 2647d3b..bc89146 100644 --- a/docker/bootstrap_openldap/ldif/bootstrap.ldif +++ b/docker/bootstrap_openldap/ldif/bootstrap.ldif @@ -1,25 +1,25 @@ -dn: uid=zapf-auth,dc=zapf,dc=in +dn: uid=auth,dc=example,dc=com changetype: add -uid: zapf-auth +uid: auth objectClass: top objectClass: simpleSecurityObject objectClass: account description: Bind user for ZaPF-Auth userPassword: {SSHA}98H1A4YLoIZaceCtVoXIekAalnwlFsQd -dn: ou=groups,dc=zapf,dc=in +dn: ou=groups,dc=example,dc=com changetype: add objectClass: organizationalUnit ou: groups description: Automagically added by quasisentient sanity checks -dn: ou=users,dc=zapf,dc=in +dn: ou=users,dc=example,dc=com changetype: add objectClass: organizationalUnit ou: users description: Automagically added by quasisentient sanity checks -dn: ou=oauth2,dc=zapf,dc=in +dn: ou=oauth2,dc=example,dc=com changetype: add objectClass: organizationalUnit ou: oauth2 diff --git a/docker/test.conf b/docker/test.conf index 1e9c3e6..2ea69c8 100644 --- a/docker/test.conf +++ b/docker/test.conf @@ -5,8 +5,8 @@ BOOTSTRAP_SERVE_LOCAL = True # LDAP LDAP_HOST = 'openldap' LDAP_PORT = 389 -LDAP_BASE_DN = 'dc=zapf,dc=in' -LDAP_BIND_USER_DN = 'uid=zapf-auth,dc=zapf,dc=in' +LDAP_BASE_DN = 'dc=example,dc=com' +LDAP_BIND_USER_DN = 'uid=auth,dc=example,dc=com' LDAP_BIND_USER_PASSWORD = 'test' import ldap3 PASSWORD_HASHING_FUNC = ldap3.HASHED_SALTED_SHA @@ -21,7 +21,7 @@ MAIL_SERVER='smtp.example.org' MAIL_PORT=465 MAIL_USE_TLS = False MAIL_USE_SSL=True -MAIL_USERNAME='zapf-auth-sender' +MAIL_USERNAME='auth-sender' MAIL_PASSWORD='CHANGEME' # To prevent open redirects in OAuth logout diff --git a/manage.py b/manage.py index 4fccdd6..a2fdda6 100755 --- a/manage.py +++ b/manage.py @@ -4,7 +4,7 @@ from flask_migrate import Migrate, MigrateCommand from app.db import db -app = create_app() +app = create_app("development") manager = Manager(app) migrate = Migrate(app, db)