feat: implement registration process

This commit is contained in:
Éloi Rivard 2023-08-15 16:17:19 +02:00
parent 29b50dc25e
commit 5a9df64f68
No known key found for this signature in database
GPG key ID: 7EDA204EA57DD184
19 changed files with 401 additions and 166 deletions

View file

@ -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
*****

View file

@ -109,6 +109,7 @@ def setup_flask(app):
"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")),

View file

@ -85,31 +85,6 @@ def smtp_needed():
return wrapper
def registration_needed():
def wrapper(view_function):
@wraps(view_function)
def decorator(*args, **kwargs):
if "REGISTRATION" in current_app.config:
return view_function(*args, **kwargs)
message = _("Registration has not been enabled")
logging.warning(message)
return (
render_template(
"error.html",
error=500,
icon="tools",
debug=current_app.config.get("DEBUG", False),
description=message,
),
500,
)
return decorator
return wrapper
def set_parameter_in_url_query(url, **kwargs):
split = list(urlsplit(url))
pairs = split[3].split("&")

View file

@ -237,8 +237,8 @@ class ReadOnly:
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):

View file

@ -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
@ -241,9 +246,3 @@ WRITE = [
# 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_ATTRIBUTE>=group_name,<GROUP_BASE>", ...]
# [REGISTRATION]
# GROUPS=[]
# CAN_EDIT_USERNAME = false

View file

@ -1,3 +1,4 @@
import binascii
import datetime
import io
from dataclasses import astuple
@ -13,7 +14,6 @@ from canaille.app import models
from canaille.app import obj_to_b64
from canaille.app.flask import current_user
from canaille.app.flask import permissions_needed
from canaille.app.flask import registration_needed
from canaille.app.flask import render_htmx_template
from canaille.app.flask import request_is_htmx
from canaille.app.flask import smtp_needed
@ -44,6 +44,7 @@ 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
@ -71,51 +72,66 @@ def index():
@bp.route("/join", methods=("GET", "POST"))
@smtp_needed()
@registration_needed()
def join():
if current_user():
return redirect(
url_for("account.profile_edition", username=current_user().user_name[0])
)
form = JoinForm(request.form or None)
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)
email_sent = None
form_validated = False
if request.form and form.validate():
form_validated = True
invitation = Invitation(
datetime.datetime.now(datetime.timezone.utc).isoformat(),
form.user_name.data,
current_app.config["REGISTRATION"].get("CAN_EDIT_USERNAME", False),
form.email.data,
current_app.config["REGISTRATION"].get("GROUPS", []),
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(
"account.registration",
data=invitation.b64(),
hash=invitation.profile_hash(),
"core.account.registration",
data=payload.b64(),
hash=payload.build_hash(),
_external=True,
)
email_sent = send_registration_mail(form.email.data, registration_url)
if email_sent:
if send_registration_mail(form.email.data, registration_url):
flash(
_("You've received an email to continue the registration process."),
_(
"You will receive soon an email to continue the registration process."
),
"success",
)
return redirect(url_for("account.login"))
return render_template(
"profile_add.html",
form=form,
form_validated=form_validated,
email_sent=email_sent,
edited_user=None,
self_deletion=False,
menuitem="users",
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():
@ -143,7 +159,7 @@ def users(user):
@dataclass
class Verification:
class VerificationPayload:
creation_date_isoformat: str
@property
@ -168,13 +184,13 @@ class Verification:
@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
@ -192,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,
@ -201,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,
)
@ -219,31 +235,47 @@ def user_invitation(user):
)
@bp.route("/register", methods=["GET", "POST"])
@bp.route("/register/<data>/<hash>", methods=["GET", "POST"])
def registration(data, hash):
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:
invitation = Invitation(*b64_to_obj(data))
except:
payload = RegistrationPayload(*b64_to_obj(data))
except binascii.Error:
flash(
_("The invitation link that brought you here was invalid."),
_("The registration link that brought you here was invalid."),
"error",
)
return redirect(url_for("core.account.index"))
if invitation.has_expired():
if payload.has_expired():
flash(
_("The invitation link that brought you here has expired."),
_("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):
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(
_("You are already logged in, you cannot create an account."),
@ -251,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": invitation.user_name,
"emails": [invitation.email],
"groups": invitation.groups,
"user_name": payload.user_name,
"emails": [payload.email],
"groups": payload.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
@ -270,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()],
@ -278,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:
@ -323,7 +350,7 @@ def registration(data, hash):
@bp.route("/email-confirmation/<data>/<hash>")
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."),
@ -403,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))
@ -431,8 +459,6 @@ def profile_create(current_app, form):
user.load_permissions()
flash(_("User account creation succeed."), "success")
return user
@ -517,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,

View file

@ -353,17 +353,11 @@ class EditGroupForm(Form):
class JoinForm(Form):
user_name = wtforms.StringField(
_("Username"),
render_kw={"placeholder": _("jdoe")},
validators=[wtforms.validators.DataRequired(), unique_login],
)
email = wtforms.EmailField(
_("Email address"),
validators=[
wtforms.validators.DataRequired(),
wtforms.validators.Email(),
unique_email,
],
render_kw={
"placeholder": _("jane@doe.com"),

View file

@ -181,24 +181,25 @@ def send_confirmation_email(email, confirmation_url):
def send_registration_mail(email, registration_url):
base_url = url_for("account.index", _external=True)
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(
"mail/registration.txt",
site_name=current_app.config.get("NAME", registration_url),
"mails/registration.txt",
site_name=current_app.config.get("NAME", base_url),
site_url=base_url,
registration_url=registration_url,
)
html_body = render_template(
"mail/registration.html",
site_name=current_app.config.get("NAME", registration_url),
"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(

View file

@ -29,6 +29,9 @@
<div class="ui right aligned container">
<div class="ui stackable buttons">
{% if has_registration %}
<a type="button" class="ui right floated button" href="{{ url_for('core.account.join') }}">{{ _("Create an account") }}</a>
{% endif %}
<a type="button" class="ui right floated button" href="{{ url_for('core.auth.login') }}">{{ _("Login page") }}</a>
<button type="submit" class="ui right floated {% if request.method != "POST" or form.errors %}primary {% endif %}button">
{% if request.method == "POST" %}

View file

@ -0,0 +1,51 @@
{% extends theme('base.html') %}
{% import 'macro/form.html' as fui %}
{% import 'partial/profile_field.html' as profile %}
{%- block title -%}
{%- trans %}User creation{% endtrans -%}
{%- endblock -%}
{% block content %}
<div class="joinform">
<h2 class="ui top attached header">
{% if logo_url %}
<img class="ui image" src="{{ logo_url }}">
{% endif %}
<div class="content">
{% trans %}User creation{% endtrans %}
<div class="sub header">
{% trans %}Create a new user account{% endtrans %}
</div>
</div>
</h2>
<div class="ui attached message">
{% trans %}
Before you can create an account, please enter a valid email address.
Then you will receive an email containing a link that will allow you to
finish your registration.
{% endtrans %}
</div>
<div class="ui attached clearing segment">
{% call fui.render_form(form, class_="profile-form info") %}
{% if "email" in form %}
{{ profile.render_field(form.email) }}
{% endif %}
<div class="ui right aligned container">
<div class="ui stackable buttons">
<a type="button" class="ui right floated button" href="{{ url_for('core.auth.login') }}">{{ _("Login page") }}</a>
{% if has_smtp and has_password_recovery %}
<a type="button" class="ui right floated button" href="{{ url_for('core.auth.forgotten') }}">{{ _("Forgotten password") }}</a>
{% endif %}
<button type="submit" class="ui right floated primary button" name="action" value="create-profile" id="create-profile">
{{ _("Submit") }}
</button>
</div>
</div>
{% endcall %}
</div>
{% endblock %}

View file

@ -33,6 +33,9 @@
<div class="ui right aligned container">
<div class="ui stackable buttons">
{% if has_registration %}
<a type="button" class="ui right floated button" href="{{ url_for('core.account.join') }}">{{ _("Create an account") }}</a>
{% endif %}
{% if has_smtp and has_password_recovery %}
<a type="button" class="ui right floated button" href="{{ url_for('core.auth.forgotten') }}">{{ _("Forgotten password") }}</a>
{% endif %}

View file

@ -189,6 +189,9 @@
<div class="ui right aligned container">
<div class="ui stackable buttons">
{% if not user %}
<a type="button" class="ui right floated button" href="{{ url_for('core.auth.login') }}">{{ _("Login page") }}</a>
{% endif %}
<button type="submit" class="ui right floated primary button" name="action" value="create-profile" id="create-profile">
{{ _("Submit") }}
</button>

View file

@ -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
@ -238,7 +243,7 @@ DYNAMIC_CLIENT_REGISTRATION_TOKENS = [
# WEBSITE = "{{ user.profile_url[0] }}"
# The SMTP server options. If not set, mail related features such as
# user invitations, password reset emails, and registration will be disabled.
# user invitations, and password reset emails, will be disabled.
[SMTP]
# HOST = "localhost"
# PORT = 25

View file

@ -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
@ -251,10 +256,3 @@ 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_ATTRIBUTE>=group_name,<GROUP_BASE>", ...]
# [REGISTRATION]
# GROUPS=[]
# CAN_EDIT_USERNAME = false

View file

@ -57,6 +57,12 @@ Canaille is based on Flask, so any `flask configuration <https://flask.palletspr
This needs the ``sentry_sdk`` python package to be installed.
This is useful if you want to collect the canaille exceptions in a production environment.
:ENABLE_REGISTRATION:
*Optional.* If true, then users can freely create an account
at this instance. If ``EMAIL_CONFIRMATION`` is true, users must confirm
their email before the account is created.
Defaults to false.
:EMAIL_CONFIRMATION:
*Optional.* If set to true, users will need to click on
a confirmation link sent by email when they want to add a new email. By default,

View file

@ -254,4 +254,13 @@ Apache
RewriteRule "^/.well-know/webfinger" "https://auth.mydomain.tld/.well-known/webfinger" [R,L]
</VirtualHost>
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

View file

@ -2,8 +2,8 @@ import datetime
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

View file

@ -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)

View file

@ -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 &#39;john@doe.com&#39; 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