Skip to content

Commit

Permalink
Merge branch 'registration' into 'main'
Browse files Browse the repository at this point in the history
Registration

Closes #55

See merge request yaal/canaille!148
  • Loading branch information
azmeuk committed Aug 15, 2023
2 parents f73e9c3 + 5a9df64 commit 5659759
Show file tree
Hide file tree
Showing 20 changed files with 512 additions and 83 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
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
5 changes: 5 additions & 0 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
164 changes: 120 additions & 44 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 Down Expand Up @@ -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__)
Expand All @@ -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:
Expand All @@ -93,7 +159,7 @@ def users(user):


@dataclass
class Verification:
class VerificationPayload:
creation_date_isoformat: str

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

Expand All @@ -169,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 @@ -201,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 @@ -273,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 @@ -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))


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

user.load_permissions()

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

return user


Expand Down Expand Up @@ -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,
Expand Down
15 changes: 15 additions & 0 deletions canaille/core/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": _("[email protected]"),
"spellcheck": "false",
"autocorrect": "off",
},
)


class InvitationForm(Form):
user_name = wtforms.StringField(
_("Username"),
Expand Down
31 changes: 31 additions & 0 deletions canaille/core/mails.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
3 changes: 3 additions & 0 deletions canaille/core/templates/forgotten-password.html
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ <h3 class="ui top attached header">

<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" %}
Expand Down
Loading

0 comments on commit 5659759

Please sign in to comment.