Skip to content

Commit

Permalink
feat: implement registration process
Browse files Browse the repository at this point in the history
  • Loading branch information
azmeuk committed Aug 15, 2023
1 parent 29b50dc commit 5a9df64
Show file tree
Hide file tree
Showing 19 changed files with 399 additions and 164 deletions.
1 change: 1 addition & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
*****
Expand Down
1 change: 1 addition & 0 deletions canaille/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")),
Expand Down
25 changes: 0 additions & 25 deletions canaille/app/flask.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,31 +85,6 @@ def decorator(*args, **kwargs):
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("&")
Expand Down
4 changes: 2 additions & 2 deletions canaille/app/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
11 changes: 5 additions & 6 deletions canaille/config.sample.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -241,9 +246,3 @@ WRITE = [
# LOGIN = ""
# PASSWORD = ""
# FROM_ADDR = "[email protected]"

# 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
176 changes: 101 additions & 75 deletions canaille/core/account.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import binascii
import datetime
import io
from dataclasses import astuple
Expand All @@ -13,7 +14,6 @@
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
Expand Down Expand Up @@ -44,6 +44,7 @@
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
Expand Down Expand Up @@ -71,50 +72,65 @@ def index():


@bp.route("/join", methods=("GET", "POST"))
@smtp_needed()
@registration_needed()
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():
return redirect(
url_for("account.profile_edition", username=current_user().user_name[0])
)
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"))
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(
"profile_add.html",
form=form,
form_validated=form_validated,
email_sent=email_sent,
edited_user=None,
self_deletion=False,
menuitem="users",
)
return render_template("join.html", form=form)


@bp.route("/about")
Expand Down Expand Up @@ -143,7 +159,7 @@ def users(user):


@dataclass
class Verification:
class VerificationPayload:
creation_date_isoformat: str

@property
Expand All @@ -168,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
Expand All @@ -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,
Expand All @@ -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,
)

Expand All @@ -219,30 +235,46 @@ def user_invitation(user):
)


@bp.route("/register", methods=["GET", "POST"])
@bp.route("/register/<data>/<hash>", 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(
Expand All @@ -251,34 +283,29 @@ 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
)
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()],
)
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:
Expand Down Expand Up @@ -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."),
Expand Down Expand Up @@ -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))


Expand Down Expand Up @@ -431,8 +459,6 @@ def profile_create(current_app, form):

user.load_permissions()

flash(_("User account creation succeed."), "success")

return user


Expand Down Expand Up @@ -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,
Expand Down
Loading

0 comments on commit 5a9df64

Please sign in to comment.