diff --git a/CHANGES.rst b/CHANGES.rst index f397f97f..14ba59c6 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -9,6 +9,7 @@ Added - Configuration option to disable the forced usage of OIDC nonce :pr:`143` - Validate phone numbers with a regex :pr:`146` - Email verification :issue:`41` :pr:`147` +- Account registration :issue:`55` :pr:`133` :pr:`148` Fixed ***** diff --git a/canaille/__init__.py b/canaille/__init__.py index 0baf8ac1..47293a04 100644 --- a/canaille/__init__.py +++ b/canaille/__init__.py @@ -109,6 +109,7 @@ def global_processor(): "debug": app.debug or app.config.get("TESTING", False), "has_smtp": "SMTP" in app.config, "has_password_recovery": app.config.get("ENABLE_PASSWORD_RECOVERY", True), + "has_registration": app.config.get("ENABLE_REGISTRATION", False), "has_account_lockability": app.backend.get().has_account_lockability(), "logo_url": app.config.get("LOGO"), "favicon_url": app.config.get("FAVICON", app.config.get("LOGO")), diff --git a/canaille/app/forms.py b/canaille/app/forms.py index 29da25eb..10fd0146 100644 --- a/canaille/app/forms.py +++ b/canaille/app/forms.py @@ -237,8 +237,8 @@ def __init__(self): self.field_flags = {"readonly": True} def __call__(self, form, field): - if field.data != field.object_data: - raise wtforms.ValidationError(field.gettext("This field cannot be edited")) + if field.data and field.object_data and field.data != field.object_data: + raise wtforms.ValidationError(_("This field cannot be edited")) def is_readonly(field): diff --git a/canaille/config.sample.toml b/canaille/config.sample.toml index 2f04b758..b2b7b8f0 100644 --- a/canaille/config.sample.toml +++ b/canaille/config.sample.toml @@ -45,6 +45,11 @@ SECRET_KEY = "change me before you go in production" # will be read-only. # EMAIL_CONFIRMATION = +# If ENABLE_REGISTRATION is true, then users can freely create an account +# at this instance. If email verification is available, users must confirm +# their email before the account is created. +# ENABLE_REGISTRATION = false + # If HIDE_INVALID_LOGINS is set to true (the default), when a user # tries to sign in with an invalid login, a message is shown indicating # that the password is wrong, but does not give a clue wether the login diff --git a/canaille/core/account.py b/canaille/core/account.py index db3797b8..9cf6fea6 100644 --- a/canaille/core/account.py +++ b/canaille/core/account.py @@ -1,3 +1,4 @@ +import binascii import datetime import io from dataclasses import astuple @@ -40,12 +41,15 @@ from .forms import build_profile_form from .forms import EmailConfirmationForm from .forms import InvitationForm +from .forms import JoinForm from .forms import MINIMUM_PASSWORD_LENGTH from .forms import PROFILE_FORM_FIELDS +from .forms import unique_email from .mails import send_confirmation_email from .mails import send_invitation_mail from .mails import send_password_initialization_mail from .mails import send_password_reset_mail +from .mails import send_registration_mail bp = Blueprint("account", __name__) @@ -67,6 +71,68 @@ def index(): return redirect(url_for("core.account.about")) +@bp.route("/join", methods=("GET", "POST")) +def join(): + if not current_app.config.get("ENABLE_REGISTRATION", False): + abort(404) + + if not current_app.config.get("EMAIL_CONFIRMATION", True): + return redirect(url_for(".registration")) + + if current_user(): + abort(403) + + form = JoinForm(request.form or None) + if not current_app.config.get("HIDE_INVALID_LOGINS", True): + form.email.validators.append(unique_email) + + if request.form and form.validate(): + if models.User.query(emails=form.email.data): + flash( + _( + "You will receive soon an email to continue the registration process." + ), + "success", + ) + return render_template("join.html", form=form) + + payload = RegistrationPayload( + creation_date_isoformat=datetime.datetime.now( + datetime.timezone.utc + ).isoformat(), + user_name="", + user_name_editable=True, + email=form.email.data, + groups=[], + ) + + registration_url = url_for( + "core.account.registration", + data=payload.b64(), + hash=payload.build_hash(), + _external=True, + ) + + if send_registration_mail(form.email.data, registration_url): + flash( + _( + "You will receive soon an email to continue the registration process." + ), + "success", + ) + else: + flash( + _( + "An error happened while sending your registration mail. " + "Please try again in a few minutes. " + "If this still happens, please contact the administrators." + ), + "error", + ) + + return render_template("join.html", form=form) + + @bp.route("/about") def about(): try: @@ -93,7 +159,7 @@ def users(user): @dataclass -class Verification: +class VerificationPayload: creation_date_isoformat: str @property @@ -118,13 +184,13 @@ def build_hash(self): @dataclass -class EmailConfirmationObject(Verification): +class EmailConfirmationPayload(VerificationPayload): identifier: str email: str @dataclass -class Invitation(Verification): +class RegistrationPayload(VerificationPayload): user_name: str user_name_editable: bool email: str @@ -142,7 +208,7 @@ def user_invitation(user): form_validated = False if request.form and form.validate(): form_validated = True - invitation = Invitation( + payload = RegistrationPayload( datetime.datetime.now(datetime.timezone.utc).isoformat(), form.user_name.data, form.user_name_editable.data, @@ -151,8 +217,8 @@ def user_invitation(user): ) registration_url = url_for( "core.account.registration", - data=invitation.b64(), - hash=invitation.build_hash(), + data=payload.b64(), + hash=payload.build_hash(), _external=True, ) @@ -169,30 +235,46 @@ def user_invitation(user): ) +@bp.route("/register", methods=["GET", "POST"]) @bp.route("/register//", methods=["GET", "POST"]) -def registration(data, hash): - try: - invitation = Invitation(*b64_to_obj(data)) - except: - flash( - _("The invitation link that brought you here was invalid."), - "error", - ) - return redirect(url_for("core.account.index")) +def registration(data=None, hash=None): + if not data: + payload = None + if not current_app.config.get( + "ENABLE_REGISTRATION", False + ) or current_app.config.get("EMAIL_CONFIRMATION", True): + abort(403) + + else: + try: + payload = RegistrationPayload(*b64_to_obj(data)) + except binascii.Error: + flash( + _("The registration link that brought you here was invalid."), + "error", + ) + return redirect(url_for("core.account.index")) - if invitation.has_expired(): - flash( - _("The invitation link that brought you here has expired."), - "error", - ) - return redirect(url_for("core.account.index")) + if payload.has_expired(): + flash( + _("The registration link that brought you here has expired."), + "error", + ) + return redirect(url_for("core.account.index")) - if models.User.get_from_login(invitation.user_name): - flash( - _("Your account has already been created."), - "error", - ) - return redirect(url_for("core.account.index")) + if payload.user_name and models.User.get_from_login(payload.user_name): + flash( + _("Your account has already been created."), + "error", + ) + return redirect(url_for("core.account.index")) + + if hash != payload.build_hash(): + flash( + _("The registration link that brought you here was invalid."), + "error", + ) + return redirect(url_for("core.account.index")) if current_user(): flash( @@ -201,18 +283,13 @@ def registration(data, hash): ) return redirect(url_for("core.account.index")) - if hash != invitation.build_hash(): - flash( - _("The invitation link that brought you here was invalid."), - "error", - ) - return redirect(url_for("core.account.index")) + if payload: + data = { + "user_name": payload.user_name, + "emails": [payload.email], + "groups": payload.groups, + } - data = { - "user_name": invitation.user_name, - "emails": [invitation.email], - "groups": invitation.groups, - } has_smtp = "SMTP" in current_app.config emails_readonly = current_app.config.get("EMAIL_CONFIRMATION") is True or ( current_app.config.get("EMAIL_CONFIRMATION") is None and has_smtp @@ -220,7 +297,7 @@ def registration(data, hash): readable_fields, writable_fields = default_fields() form = build_profile_form(writable_fields, readable_fields) - if "groups" not in form and invitation.groups: + if "groups" not in form and payload and payload.groups: form["groups"] = wtforms.SelectMultipleField( _("Groups"), choices=[(group.id, group.display_name) for group in models.Group.query()], @@ -228,7 +305,7 @@ def registration(data, hash): set_readonly(form["groups"]) form.process(CombinedMultiDict((request.files, request.form)) or None, data=data) - if is_readonly(form["user_name"]) and invitation.user_name_editable: + if is_readonly(form["user_name"]) and (not payload or payload.user_name_editable): set_writable(form["user_name"]) if not is_readonly(form["emails"]) and emails_readonly: @@ -273,7 +350,7 @@ def registration(data, hash): @bp.route("/email-confirmation//") def email_confirmation(data, hash): try: - confirmation_obj = EmailConfirmationObject(*b64_to_obj(data)) + confirmation_obj = EmailConfirmationPayload(*b64_to_obj(data)) except: flash( _("The email confirmation link that brought you here is invalid."), @@ -353,6 +430,7 @@ def profile_creation(user): ) user = profile_create(current_app, form) + flash(_("User account creation succeed."), "success") return redirect(url_for("core.account.profile_edition", edited_user=user)) @@ -381,8 +459,6 @@ def profile_create(current_app, form): user.load_permissions() - flash(_("User account creation succeed."), "success") - return user @@ -467,7 +543,7 @@ def profile_edition_emails_form(user, edited_user, has_smtp): def profile_edition_add_email(user, edited_user, emails_form): - email_confirmation = EmailConfirmationObject( + email_confirmation = EmailConfirmationPayload( datetime.datetime.now(datetime.timezone.utc).isoformat(), edited_user.identifier, emails_form.new_email.data, diff --git a/canaille/core/forms.py b/canaille/core/forms.py index 63aa0989..6cd76a46 100644 --- a/canaille/core/forms.py +++ b/canaille/core/forms.py @@ -352,6 +352,21 @@ class EditGroupForm(Form): ) +class JoinForm(Form): + email = wtforms.EmailField( + _("Email address"), + validators=[ + wtforms.validators.DataRequired(), + wtforms.validators.Email(), + ], + render_kw={ + "placeholder": _("jane@doe.com"), + "spellcheck": "false", + "autocorrect": "off", + }, + ) + + class InvitationForm(Form): user_name = wtforms.StringField( _("Username"), diff --git a/canaille/core/mails.py b/canaille/core/mails.py index c5d1e649..78265788 100644 --- a/canaille/core/mails.py +++ b/canaille/core/mails.py @@ -178,3 +178,34 @@ def send_confirmation_email(email, confirmation_url): html=html_body, attachements=[(logo_cid, logo_filename, logo_raw)] if logo_filename else None, ) + + +def send_registration_mail(email, registration_url): + base_url = url_for("core.account.index", _external=True) + logo_cid, logo_filename, logo_raw = logo() + + subject = _("Continue your registration on {website_name}").format( + website_name=current_app.config.get("NAME", registration_url) + ) + text_body = render_template( + "mails/registration.txt", + site_name=current_app.config.get("NAME", base_url), + site_url=base_url, + registration_url=registration_url, + ) + html_body = render_template( + "mails/registration.html", + site_name=current_app.config.get("NAME", base_url), + site_url=base_url, + registration_url=registration_url, + logo=f"cid:{logo_cid[1:-1]}" if logo_cid else None, + title=subject, + ) + + return send_email( + subject=subject, + recipient=email, + text=text_body, + html=html_body, + attachements=[(logo_cid, logo_filename, logo_raw)] if logo_filename else None, + ) diff --git a/canaille/core/templates/forgotten-password.html b/canaille/core/templates/forgotten-password.html index 8ebe44d5..d9144987 100644 --- a/canaille/core/templates/forgotten-password.html +++ b/canaille/core/templates/forgotten-password.html @@ -29,6 +29,9 @@

+ {% if has_registration %} + {{ _("Create an account") }} + {% endif %} {{ _("Login page") }} +
+
+ + {% endcall %} + +{% endblock %} diff --git a/canaille/core/templates/login.html b/canaille/core/templates/login.html index 3cce23f0..4406a2ed 100644 --- a/canaille/core/templates/login.html +++ b/canaille/core/templates/login.html @@ -33,6 +33,9 @@

+ {% if has_registration %} + {{ _("Create an account") }} + {% endif %} {% if has_smtp and has_password_recovery %} {{ _("Forgotten password") }} {% endif %} diff --git a/canaille/core/templates/mails/registration.html b/canaille/core/templates/mails/registration.html new file mode 100644 index 00000000..735285ec --- /dev/null +++ b/canaille/core/templates/mails/registration.html @@ -0,0 +1,43 @@ + + + + + + + {{ title }} + + + + + + + + + + + + + + + + +
+

+ {% if logo %} + {{ site_name }} + {% endif %} +
+ {% trans %}Registration{% endtrans %} +
+

+
+ {% trans %} + You've started the registration process at {{ site_name }}. To proceed you can click on the "Continue creating my account" button below and follow the instructions. + {% endtrans %} +
+ {{ site_name }} + + {% trans %}Continue creating my account{% endtrans %} +
+ + diff --git a/canaille/core/templates/mails/registration.txt b/canaille/core/templates/mails/registration.txt new file mode 100644 index 00000000..99c32399 --- /dev/null +++ b/canaille/core/templates/mails/registration.txt @@ -0,0 +1,6 @@ +# {% trans %}Registration{% endtrans %} + +{% trans %}You've started the registration process at {{ site_name }}. To proceed you can click on the link below and follow the instructions.{% endtrans %} + +{% trans %}Continue{% endtrans %}: {{ registration_url }} +{{ site_name }}: {{ site_url }} diff --git a/canaille/core/templates/profile_add.html b/canaille/core/templates/profile_add.html index 82fdba9d..730ce706 100644 --- a/canaille/core/templates/profile_add.html +++ b/canaille/core/templates/profile_add.html @@ -189,6 +189,9 @@

{% trans %}Account settings{% endtrans %}

+ {% if not user %} + {{ _("Login page") }} + {% endif %} diff --git a/demo/conf-docker/canaille.toml b/demo/conf-docker/canaille.toml index d0fd92d1..fb0dc5d6 100644 --- a/demo/conf-docker/canaille.toml +++ b/demo/conf-docker/canaille.toml @@ -45,6 +45,11 @@ FAVICON = "/static/img/canaille-c.png" # will be read-only. # EMAIL_CONFIRMATION = +# If ENABLE_REGISTRATION is true, then users can freely create an account +# at this instance. If email verification is available, users must confirm +# their email before the account is created. +ENABLE_REGISTRATION = true + # If HIDE_INVALID_LOGINS is set to true (the default), when a user # tries to sign in with an invalid login, a message is shown indicating # that the password is wrong, but does not give a clue wether the login @@ -247,3 +252,9 @@ DYNAMIC_CLIENT_REGISTRATION_TOKENS = [ # LOGIN = "" # PASSWORD = "" # FROM_ADDR = "admin@mydomain.tld" + +# The registration options. If not set, registration will be disabled. Requires SMTP to work. +# Groups should be formatted like this: ["=group_name,", ...] +# [REGISTRATION] +# GROUPS=[] +# CAN_EDIT_USERNAME = false diff --git a/demo/conf/canaille.toml b/demo/conf/canaille.toml index 464883cb..e0ba611a 100644 --- a/demo/conf/canaille.toml +++ b/demo/conf/canaille.toml @@ -45,6 +45,11 @@ FAVICON = "/static/img/canaille-c.png" # will be read-only. # EMAIL_CONFIRMATION = +# If ENABLE_REGISTRATION is true, then users can freely create an account +# at this instance. If email verification is available, users must confirm +# their email before the account is created. +ENABLE_REGISTRATION = true + # If HIDE_INVALID_LOGINS is set to true (the default), when a user # tries to sign in with an invalid login, a message is shown indicating # that the password is wrong, but does not give a clue wether the login diff --git a/doc/configuration.rst b/doc/configuration.rst index af72c145..961c0e06 100644 --- a/doc/configuration.rst +++ b/doc/configuration.rst @@ -57,6 +57,12 @@ Canaille is based on Flask, so any `flask configuration +Create your first user +====================== + +Once canaille is installed, you have several ways to populate the database. The obvious one is by adding +directly users and group into your LDAP directory. You might also want to temporarily enable then +``ENABLE_REGISTRATION`` configuration parameter to allow you to create your first users. Then, if you +have configured your ACLs properly then you will be able to manage users and groups through the Canaille +interface. + .. _WebFinger: https://www.rfc-editor.org/rfc/rfc7033.html diff --git a/tests/core/test_email_confirmation.py b/tests/core/test_email_confirmation.py index c87e3649..03e25079 100644 --- a/tests/core/test_email_confirmation.py +++ b/tests/core/test_email_confirmation.py @@ -2,8 +2,8 @@ from unittest import mock import freezegun -from canaille.core.account import EmailConfirmationObject -from canaille.core.account import Invitation +from canaille.core.account import EmailConfirmationPayload +from canaille.core.account import RegistrationPayload from flask import url_for @@ -162,7 +162,7 @@ def test_confirmation_unset_smtp_enabled_email_user_validation( ) ] - email_confirmation = EmailConfirmationObject( + email_confirmation = EmailConfirmationPayload( "2020-01-01T02:00:00+00:00", "user", "new_email@mydomain.tld", @@ -258,7 +258,7 @@ def test_confirmation_expired_link(testclient, backend, user): """ Expired valid confirmation links should fail. """ - email_confirmation = EmailConfirmationObject( + email_confirmation = EmailConfirmationPayload( "2020-01-01T01:00:00+00:00", "user", "new_email@mydomain.tld", @@ -285,7 +285,7 @@ def test_confirmation_invalid_hash_link(testclient, backend, user): """ Confirmation link with invalid hashes should fail. """ - email_confirmation = EmailConfirmationObject( + email_confirmation = EmailConfirmationPayload( "2020-01-01T01:00:00+00:00", "user", "new_email@mydomain.tld", @@ -314,7 +314,7 @@ def test_confirmation_invalid_user_link(testclient, backend, user): For instance, when the user account has been deleted between the mail is sent and the link is clicked. """ - email_confirmation = EmailConfirmationObject( + email_confirmation = EmailConfirmationPayload( "2020-01-01T01:00:00+00:00", "invalid-user", "new_email@mydomain.tld", @@ -341,7 +341,7 @@ def test_confirmation_email_already_confirmed_link(testclient, backend, user, ad """ Clicking twice on a confirmation link should fail. """ - email_confirmation = EmailConfirmationObject( + email_confirmation = EmailConfirmationPayload( "2020-01-01T01:00:00+00:00", "user", "john@doe.com", @@ -370,7 +370,7 @@ def test_confirmation_email_already_used_link(testclient, backend, user, admin): to another account. For instance, if an administrator already put this email to someone else's profile. """ - email_confirmation = EmailConfirmationObject( + email_confirmation = EmailConfirmationPayload( "2020-01-01T01:00:00+00:00", "user", "jane@doe.com", @@ -489,15 +489,15 @@ def test_invitation_form_mail_field_readonly(testclient): """ testclient.app.config["EMAIL_CONFIRMATION"] = True - invitation = Invitation( + payload = RegistrationPayload( datetime.datetime.now(datetime.timezone.utc).isoformat(), "someoneelse", False, "someone@mydomain.tld", [], ) - hash = invitation.build_hash() - b64 = invitation.b64() + hash = payload.build_hash() + b64 = payload.b64() res = testclient.get(f"/register/{b64}/{hash}") assert "readonly" in res.form["emails-0"].attrs @@ -510,15 +510,15 @@ def test_invitation_form_mail_field_writable(testclient): """ testclient.app.config["EMAIL_CONFIRMATION"] = False - invitation = Invitation( + payload = RegistrationPayload( datetime.datetime.now(datetime.timezone.utc).isoformat(), "someoneelse", False, "someone@mydomain.tld", [], ) - hash = invitation.build_hash() - b64 = invitation.b64() + hash = payload.build_hash() + b64 = payload.b64() res = testclient.get(f"/register/{b64}/{hash}") assert "readonly" not in res.form["emails-0"].attrs diff --git a/tests/core/test_invitation.py b/tests/core/test_invitation.py index d66238ed..bdc19af7 100644 --- a/tests/core/test_invitation.py +++ b/tests/core/test_invitation.py @@ -1,7 +1,7 @@ import datetime from canaille.app import models -from canaille.core.account import Invitation +from canaille.core.account import RegistrationPayload from flask import g @@ -161,29 +161,29 @@ def test_invitation_login_already_taken(testclient, logged_admin): def test_registration(testclient, foo_group): - invitation = Invitation( + payload = RegistrationPayload( datetime.datetime.now(datetime.timezone.utc).isoformat(), "someoneelse", False, "someone@mydomain.tld", [foo_group.id], ) - b64 = invitation.b64() - hash = invitation.build_hash() + b64 = payload.b64() + hash = payload.build_hash() testclient.get(f"/register/{b64}/{hash}", status=200) def test_registration_formcontrol(testclient): - invitation = Invitation( + payload = RegistrationPayload( datetime.datetime.now(datetime.timezone.utc).isoformat(), "someoneelse", False, "someone@mydomain.tld", [], ) - b64 = invitation.b64() - hash = invitation.build_hash() + b64 = payload.b64() + hash = payload.build_hash() res = testclient.get(f"/register/{b64}/{hash}", status=200) assert "emails-1" not in res.form.fields @@ -194,23 +194,23 @@ def test_registration_formcontrol(testclient): def test_registration_invalid_hash(testclient, foo_group): now = datetime.datetime.now(datetime.timezone.utc).isoformat() - invitation = Invitation( + payload = RegistrationPayload( now, "anything", False, "someone@mydomain.tld", [foo_group.id] ) - b64 = invitation.b64() + b64 = payload.b64() testclient.get(f"/register/{b64}/invalid", status=302) def test_registration_invalid_data(testclient, foo_group): - invitation = Invitation( + payload = RegistrationPayload( datetime.datetime.now(datetime.timezone.utc).isoformat(), "someoneelse", False, "someone@mydomain.tld", [foo_group.id], ) - hash = invitation.build_hash() + hash = payload.build_hash() testclient.get(f"/register/invalid/{hash}", status=302) @@ -219,29 +219,29 @@ def test_registration_more_than_48_hours_after_invitation(testclient, foo_group) two_days_ago = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta( hours=48 ) - invitation = Invitation( + payload = RegistrationPayload( two_days_ago.isoformat(), "someoneelse", False, "someone@mydomain.tld", [foo_group.id], ) - hash = invitation.build_hash() - b64 = invitation.b64() + hash = payload.build_hash() + b64 = payload.b64() testclient.get(f"/register/{b64}/{hash}", status=302) def test_registration_no_password(testclient, foo_group): - invitation = Invitation( + payload = RegistrationPayload( datetime.datetime.now(datetime.timezone.utc).isoformat(), "someoneelse", False, "someone@mydomain.tld", [foo_group.id], ) - hash = invitation.build_hash() - b64 = invitation.b64() + hash = payload.build_hash() + b64 = payload.b64() url = f"/register/{b64}/{hash}" res = testclient.get(url, status=200) @@ -258,15 +258,15 @@ def test_registration_no_password(testclient, foo_group): def test_no_registration_if_logged_in(testclient, logged_user, foo_group): - invitation = Invitation( + payload = RegistrationPayload( datetime.datetime.now(datetime.timezone.utc).isoformat(), "someoneelse", False, "someone@mydomain.tld", [foo_group.id], ) - hash = invitation.build_hash() - b64 = invitation.b64() + hash = payload.build_hash() + b64 = payload.b64() url = f"/register/{b64}/{hash}" testclient.get(url, status=302) @@ -295,15 +295,15 @@ def test_groups_are_saved_even_when_user_does_not_have_read_permission( "user_name" ] # remove groups from default read permissions - invitation = Invitation( + payload = RegistrationPayload( datetime.datetime.now(datetime.timezone.utc).isoformat(), "someoneelse", False, "someone@mydomain.tld", [foo_group.id], ) - b64 = invitation.b64() - hash = invitation.build_hash() + b64 = payload.b64() + hash = payload.build_hash() res = testclient.get(f"/register/{b64}/{hash}", status=200) diff --git a/tests/core/test_registration.py b/tests/core/test_registration.py new file mode 100644 index 00000000..ed8c7eff --- /dev/null +++ b/tests/core/test_registration.py @@ -0,0 +1,160 @@ +from unittest import mock + +import freezegun +from canaille.app import models +from canaille.core.account import RegistrationPayload +from flask import url_for + + +def test_registration_without_email_validation(testclient, backend): + """ + Tests a nominal registration without email validation. + """ + testclient.app.config["ENABLE_REGISTRATION"] = True + testclient.app.config["EMAIL_CONFIRMATION"] = False + + assert not models.User.query() + res = testclient.get(url_for("core.account.registration"), status=200) + res.form["user_name"] = "newuser" + res.form["password1"] = "password" + res.form["password2"] = "password" + res.form["family_name"] = "newuser" + res.form["emails-0"] = "newuser@example.com" + res = res.form.submit() + + user = models.User.get() + assert user + user.delete() + + +def test_registration_with_email_validation(testclient, backend, smtpd): + """ + Tests a nominal registration without email validation. + """ + testclient.app.config["ENABLE_REGISTRATION"] = True + + with freezegun.freeze_time("2020-01-01 02:00:00"): + res = testclient.get(url_for("core.account.join")) + res.form["email"] = "foo@bar.com" + res = res.form.submit() + + assert res.flashes == [ + ( + "success", + "You will receive soon an email to continue the registration process.", + ) + ] + assert len(smtpd.messages) == 1 + + payload = RegistrationPayload( + creation_date_isoformat="2020-01-01T02:00:00+00:00", + user_name="", + user_name_editable=True, + email="foo@bar.com", + groups=[], + ) + registration_url = url_for( + "core.account.registration", + data=payload.b64(), + hash=payload.build_hash(), + _external=True, + ) + text_mail = str(smtpd.messages[0].get_payload()[0]).replace("=\n", "") + assert registration_url in text_mail + + assert not models.User.query() + with freezegun.freeze_time("2020-01-01 02:01:00"): + res = testclient.get(registration_url, status=200) + res.form["user_name"] = "newuser" + res.form["password1"] = "password" + res.form["password2"] = "password" + res.form["family_name"] = "newuser" + res = res.form.submit() + + user = models.User.get() + assert user + user.delete() + + +def test_registration_with_email_already_taken(testclient, backend, smtpd, user): + """ + Be sure to not leak email existence if HIDE_INVALID_LOGINS is true. + """ + testclient.app.config["ENABLE_REGISTRATION"] = True + + testclient.app.config["HIDE_INVALID_LOGINS"] = True + res = testclient.get(url_for("core.account.join")) + res.form["email"] = "john@doe.com" + res = res.form.submit() + assert res.flashes == [ + ( + "success", + "You will receive soon an email to continue the registration process.", + ) + ] + + testclient.app.config["HIDE_INVALID_LOGINS"] = False + res = testclient.get(url_for("core.account.join")) + res.form["email"] = "john@doe.com" + res = res.form.submit() + assert res.flashes == [] + res.mustcontain("The email 'john@doe.com' is already used") + + +def test_registration_with_email_validation_needs_a_valid_link( + testclient, backend, smtpd +): + """ + Tests a nominal registration without email validation. + """ + testclient.app.config["ENABLE_REGISTRATION"] = True + testclient.get(url_for("core.account.registration"), status=403) + + +def test_join_page_registration_disabled(testclient, backend, smtpd): + """ + The join page should not be available if registration is disabled. + """ + testclient.app.config["ENABLE_REGISTRATION"] = False + testclient.get(url_for("core.account.join"), status=404) + + +def test_join_page_email_confirmation_disabled(testclient, backend, smtpd): + """ + The join page should directly redirect to the registration page if + email confirmation is disabled. + """ + testclient.app.config["ENABLE_REGISTRATION"] = True + testclient.app.config["EMAIL_CONFIRMATION"] = False + res = testclient.get(url_for("core.account.join"), status=302) + assert res.location == url_for("core.account.registration") + + +def test_join_page_already_logged_in(testclient, backend, logged_user): + """ + The join page should not be accessible for logged users. + """ + testclient.app.config["ENABLE_REGISTRATION"] = True + testclient.get(url_for("core.account.join"), status=403) + + +@mock.patch("smtplib.SMTP") +def test_registration_mail_error(SMTP, testclient, backend, smtpd): + """ + Display an error message if the registration mail could not be sent. + """ + testclient.app.config["ENABLE_REGISTRATION"] = True + SMTP.side_effect = mock.Mock(side_effect=OSError("unit test mail error")) + res = testclient.get(url_for("core.account.join")) + res.form["email"] = "foo@bar.com" + res = res.form.submit(expect_errors=True) + + assert res.flashes == [ + ( + "error", + "An error happened while sending your registration mail. " + "Please try again in a few minutes. " + "If this still happens, please contact the administrators.", + ) + ] + assert len(smtpd.messages) == 0