forked from Github-Mirrors/canaille
feat: implement registration process
This commit is contained in:
parent
29b50dc25e
commit
5a9df64f68
19 changed files with 401 additions and 166 deletions
|
@ -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
|
||||
*****
|
||||
|
|
|
@ -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")),
|
||||
|
|
|
@ -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("&")
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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"),
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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" %}
|
||||
|
|
51
canaille/core/templates/join.html
Normal file
51
canaille/core/templates/join.html
Normal 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 %}
|
|
@ -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 %}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
160
tests/core/test_registration.py
Normal file
160
tests/core/test_registration.py
Normal 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 '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
|
Loading…
Reference in a new issue