feat: implement email verification

This commit is contained in:
Éloi Rivard 2023-07-20 18:43:28 +02:00
parent 29b1e3c411
commit fd24c704c0
No known key found for this signature in database
GPG key ID: 7EDA204EA57DD184
23 changed files with 1242 additions and 241 deletions

View file

@ -120,6 +120,8 @@ def setup_flask(app):
"user": current_user(),
"menu": True,
"is_boosted": request.headers.get("HX-Boosted", False),
"has_email_confirmation": app.config.get("EMAIL_CONFIRMATION") is True
or (app.config.get("EMAIL_CONFIRMATION") is None and "SMTP" in app.config),
}
@app.errorhandler(400)

View file

@ -15,7 +15,7 @@ def b64_to_obj(string):
return json.loads(base64.b64decode(string.encode("utf-8")).decode("utf-8"))
def profile_hash(*args):
def build_hash(*args):
return hashlib.sha256(
current_app.config["SECRET_KEY"].encode("utf-8")
+ obj_to_b64(args).encode("utf-8")

View file

@ -38,6 +38,13 @@ SECRET_KEY = "change me before you go in production"
# Accelerates webpages with async requests
# HTMX = true
# If EMAIL_CONFIRMATION is 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, this is true if SMTP is configured, else this is false.
# If explicitely set to true and SMTP is disabled, the email field
# will be read-only.
# EMAIL_CONFIRMATION =
# 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

View file

@ -7,10 +7,10 @@ from typing import List
import pkg_resources
import wtforms
from canaille.app import b64_to_obj
from canaille.app import build_hash
from canaille.app import default_fields
from canaille.app import models
from canaille.app import obj_to_b64
from canaille.app import profile_hash
from canaille.app.flask import current_user
from canaille.app.flask import permissions_needed
from canaille.app.flask import render_htmx_template
@ -37,6 +37,8 @@ from flask_themer import render_template
from werkzeug.datastructures import CombinedMultiDict
from werkzeug.datastructures import FileStorage
from .forms import build_profile_form
from .forms import EmailConfirmationForm
from .forms import FirstLoginForm
from .forms import ForgottenPasswordForm
from .forms import InvitationForm
@ -44,8 +46,8 @@ from .forms import LoginForm
from .forms import MINIMUM_PASSWORD_LENGTH
from .forms import PasswordForm
from .forms import PasswordResetForm
from .forms import profile_form
from .forms import PROFILE_FORM_FIELDS
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
@ -206,12 +208,8 @@ def users(user):
@dataclass
class Invitation:
class Verification:
creation_date_isoformat: str
user_name: str
user_name_editable: bool
email: str
groups: List[str]
@property
def creation_date(self):
@ -230,8 +228,22 @@ class Invitation:
def b64(self):
return obj_to_b64(astuple(self))
def profile_hash(self):
return profile_hash(*astuple(self))
def build_hash(self):
return build_hash(*astuple(self))
@dataclass
class EmailConfirmationObject(Verification):
identifier: str
email: str
@dataclass
class Invitation(Verification):
user_name: str
user_name_editable: bool
email: str
groups: List[str]
@bp.route("/invite", methods=["GET", "POST"])
@ -255,7 +267,7 @@ def user_invitation(user):
registration_url = url_for(
"account.registration",
data=invitation.b64(),
hash=invitation.profile_hash(),
hash=invitation.build_hash(),
_external=True,
)
@ -304,7 +316,7 @@ def registration(data, hash):
)
return redirect(url_for("account.index"))
if hash != invitation.profile_hash():
if hash != invitation.build_hash():
flash(
_("The invitation link that brought you here was invalid."),
"error",
@ -316,10 +328,13 @@ def registration(data, hash):
"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 = profile_form(writable_fields, readable_fields)
form = build_profile_form(writable_fields, readable_fields)
if "groups" not in form and invitation.groups:
form["groups"] = wtforms.SelectMultipleField(
_("Groups"),
@ -331,6 +346,9 @@ def registration(data, hash):
if is_readonly(form["user_name"]) and invitation.user_name_editable:
set_writable(form["user_name"])
if not is_readonly(form["emails"]) and emails_readonly:
set_readonly(form["emails"])
form["password1"].validators = [
wtforms.validators.DataRequired(),
wtforms.validators.Length(min=MINIMUM_PASSWORD_LENGTH),
@ -367,10 +385,63 @@ def registration(data, hash):
return redirect(url_for("account.profile_edition", edited_user=user))
@bp.route("/email-confirmation/<data>/<hash>")
def email_confirmation(data, hash):
try:
confirmation_obj = EmailConfirmationObject(*b64_to_obj(data))
except:
flash(
_("The email confirmation link that brought you here is invalid."),
"error",
)
return redirect(url_for("account.index"))
if confirmation_obj.has_expired():
flash(
_("The email confirmation link that brought you here has expired."),
"error",
)
return redirect(url_for("account.index"))
if hash != confirmation_obj.build_hash():
flash(
_("The invitation link that brought you here was invalid."),
"error",
)
return redirect(url_for("account.index"))
user = models.User.get(confirmation_obj.identifier)
if not user:
flash(
_("The email confirmation link that brought you here is invalid."),
"error",
)
return redirect(url_for("account.index"))
if confirmation_obj.email in user.emails:
flash(
_("This address email have already been confirmed."),
"error",
)
return redirect(url_for("account.index"))
if models.User.query(emails=confirmation_obj.email):
flash(
_("This address email is already associated with another account."),
"error",
)
return redirect(url_for("account.index"))
user.emails = user.emails + [confirmation_obj.email]
user.save()
flash(_("Your email address have been confirmed."), "success")
return redirect(url_for("account.index"))
@bp.route("/profile", methods=("GET", "POST"))
@permissions_needed("manage_users")
def profile_creation(user):
form = profile_form(user.write, user.read)
form = build_profile_form(user.write, user.read)
form.process(CombinedMultiDict((request.files, request.form)) or None)
for field in form:
@ -428,15 +499,7 @@ def profile_create(current_app, form):
return user
@bp.route("/profile/<user:edited_user>", methods=("GET", "POST"))
@user_needed()
def profile_edition(user, edited_user):
if not user.can_manage_users and not (user.can_edit_self and edited_user == user):
abort(404)
menuitem = "profile" if edited_user == user else "users"
fields = user.read | user.write
def profile_edition_main_form(user, edited_user, emails_readonly):
available_fields = {
"formatted_name",
"title",
@ -458,44 +521,34 @@ def profile_edition(user, edited_user):
"preferred_language",
"organization",
}
if emails_readonly:
available_fields.remove("emails")
readable_fields = user.read & available_fields
writable_fields = user.write & available_fields
data = {
field: getattr(edited_user, field)[0]
if getattr(edited_user, field)
and isinstance(getattr(edited_user, field), list)
and not PROFILE_FORM_FIELDS[field].field_class == wtforms.FieldList
else getattr(edited_user, field) or ""
for field in fields
if hasattr(edited_user, field) and field in available_fields
for field in writable_fields | readable_fields
if hasattr(edited_user, field)
}
form = profile_form(
user.write & available_fields, user.read & available_fields, edited_user
)
form.process(CombinedMultiDict((request.files, request.form)) or None, data=data)
form.render_field_macro_file = "partial/profile_field.html"
form.render_field_extra_context = {
request_data = CombinedMultiDict((request.files, request.form))
profile_form = build_profile_form(writable_fields, readable_fields)
profile_form.process(request_data or None, data=data)
profile_form.user = edited_user
profile_form.render_field_macro_file = "partial/profile_field.html"
profile_form.render_field_extra_context = {
"user": user,
"edited_user": edited_user,
}
return profile_form
if not request.form or form.form_control():
return render_template(
"profile_edit.html",
form=form,
menuitem=menuitem,
edited_user=edited_user,
)
if not form.validate():
flash(_("Profile edition failed."), "error")
return render_template(
"profile_edit.html",
form=form,
menuitem=menuitem,
edited_user=edited_user,
)
for attribute in form:
def profile_edition_main_form_validation(user, edited_user, profile_form):
for attribute in profile_form:
if attribute.name in edited_user.attributes and attribute.name in user.write:
if isinstance(attribute.data, FileStorage):
data = attribute.data.stream.read()
@ -504,20 +557,126 @@ def profile_edition(user, edited_user):
setattr(edited_user, attribute.name, data)
if "photo" in form and form["photo_delete"].data:
if "photo" in profile_form and profile_form["photo_delete"].data:
del edited_user.photo
if "preferred_language" in request.form:
# Refresh the babel cache in case the lang is updated
refresh()
if form["preferred_language"].data == "auto":
if profile_form["preferred_language"].data == "auto":
edited_user.preferred_language = None
edited_user.save()
def profile_edition_emails_form(user, edited_user, has_smtp):
emails_form = EmailConfirmationForm(
request.form or None, data={"old_emails": edited_user.emails}
)
emails_form.add_email_button = has_smtp
return emails_form
def profile_edition_add_email(user, edited_user, emails_form):
email_confirmation = EmailConfirmationObject(
datetime.datetime.now(datetime.timezone.utc).isoformat(),
edited_user.identifier,
emails_form.new_email.data,
)
email_confirmation_url = url_for(
"account.email_confirmation",
data=email_confirmation.b64(),
hash=email_confirmation.build_hash(),
_external=True,
)
current_app.logger.debug(
f"Attempt to send a verification mail with link: {email_confirmation_url}"
)
return send_confirmation_email(emails_form.new_email.data, email_confirmation_url)
def profile_edition_remove_email(user, edited_user, email):
if email not in edited_user.emails:
return False
if len(edited_user.emails) == 1:
return False
edited_user.emails = [m for m in edited_user.emails if m != email]
edited_user.save()
return True
@bp.route("/profile/<user:edited_user>", methods=("GET", "POST"))
@user_needed()
def profile_edition(user, edited_user):
if not user.can_manage_users and not (user.can_edit_self and edited_user == user):
abort(404)
menuitem = "profile" if edited_user == user else "users"
has_smtp = "SMTP" in current_app.config
has_email_confirmation = current_app.config.get("EMAIL_CONFIRMATION") is True or (
current_app.config.get("EMAIL_CONFIRMATION") is None and has_smtp
)
emails_readonly = has_email_confirmation and not user.can_manage_users
profile_form = profile_edition_main_form(user, edited_user, emails_readonly)
emails_form = (
profile_edition_emails_form(user, edited_user, has_smtp)
if emails_readonly
else None
)
render_context = {
"menuitem": menuitem,
"edited_user": edited_user,
"profile_form": profile_form,
"emails_form": emails_form,
}
if not request.form or profile_form.form_control():
return render_template("profile_edit.html", **render_context)
if request.form.get("action") == "edit-profile":
if not profile_form.validate():
flash(_("Profile edition failed."), "error")
return render_template("profile_edit.html", **render_context)
profile_edition_main_form_validation(user, edited_user, profile_form)
flash(_("Profile updated successfully."), "success")
return redirect(url_for("account.profile_edition", edited_user=edited_user))
if request.form.get("action") == "add_email":
if not emails_form.validate():
flash(_("Email addition failed."), "error")
return render_template("profile_edit.html", **render_context)
if profile_edition_add_email(user, edited_user, emails_form):
flash(
_(
"An email has been sent to the email address. "
"Please check your inbox and click on the verification link it contains"
),
"success",
)
else:
flash(_("Could not send the verification email"), "error")
return redirect(url_for("account.profile_edition", edited_user=edited_user))
if request.form.get("email_remove"):
if not profile_edition_remove_email(
user, edited_user, request.form.get("email_remove")
):
flash(_("Email deletion failed."), "error")
return render_template("profile_edit.html", **render_context)
flash(_("The email have been successfully deleted."), "success")
return redirect(url_for("account.profile_edition", edited_user=edited_user))
abort(400, f"bad form action: {request.form.get('action')}")
@bp.route("/profile/<user:edited_user>/settings", methods=("GET", "POST"))
@user_needed()
@ -625,7 +784,7 @@ def profile_settings_edit(editor, edited_user):
if "groups" in fields:
data["groups"] = [g.id for g in edited_user.groups]
form = profile_form(
form = build_profile_form(
editor.write & available_fields, editor.read & available_fields, edited_user
)
form.process(CombinedMultiDict((request.files, request.form)) or None, data=data)
@ -751,7 +910,7 @@ def reset(user, hash):
form = PasswordResetForm(request.form)
hashes = {
profile_hash(
build_hash(
user.identifier,
email,
user.password[0] if user.has_password() else "",
@ -784,7 +943,7 @@ def photo(user, field):
if request.if_modified_since and request.if_modified_since >= user.last_modified:
return "", 304
etag = profile_hash(user.identifier, user.last_modified.isoformat())
etag = build_hash(user.identifier, user.last_modified.isoformat())
if request.if_none_match and etag in request.if_none_match:
return "", 304

View file

@ -1,7 +1,7 @@
from canaille.app import obj_to_b64
from canaille.app.flask import permissions_needed
from canaille.app.forms import Form
from canaille.core.mails import profile_hash
from canaille.core.mails import build_hash
from canaille.core.mails import send_test_mail
from flask import Blueprint
from flask import current_app
@ -79,7 +79,7 @@ def password_init_html(user):
reset_url = url_for(
"account.reset",
user=user,
hash=profile_hash(user.identifier, user.preferred_email, user.password[0]),
hash=build_hash(user.identifier, user.preferred_email, user.password[0]),
title=_("Password initialization on {website_name}").format(
website_name=current_app.config.get("NAME", "Canaille")
),
@ -105,7 +105,7 @@ def password_init_txt(user):
reset_url = url_for(
"account.reset",
user=user,
hash=profile_hash(user.identifier, user.preferred_email, user.password[0]),
hash=build_hash(user.identifier, user.preferred_email, user.password[0]),
_external=True,
)
@ -124,7 +124,7 @@ def password_reset_html(user):
reset_url = url_for(
"account.reset",
user=user,
hash=profile_hash(user.identifier, user.preferred_email, user.password[0]),
hash=build_hash(user.identifier, user.preferred_email, user.password[0]),
title=_("Password reset on {website_name}").format(
website_name=current_app.config.get("NAME", "Canaille")
),
@ -150,7 +150,7 @@ def password_reset_txt(user):
reset_url = url_for(
"account.reset",
user=user,
hash=profile_hash(user.identifier, user.preferred_email, user.password[0]),
hash=build_hash(user.identifier, user.preferred_email, user.password[0]),
_external=True,
)
@ -169,7 +169,7 @@ def invitation_html(user, identifier, email):
registration_url = url_for(
"account.registration",
data=obj_to_b64([identifier, email]),
hash=profile_hash(identifier, email),
hash=build_hash(identifier, email),
_external=True,
)
@ -192,7 +192,7 @@ def invitation_txt(user, identifier, email):
registration_url = url_for(
"account.registration",
data=obj_to_b64([identifier, email]),
hash=profile_hash(identifier, email),
hash=build_hash(identifier, email),
_external=True,
)
@ -202,3 +202,45 @@ def invitation_txt(user, identifier, email):
site_url=base_url,
registration_url=registration_url,
)
@bp.route("/mail/<identifier>/<email>/email-confirmation.html")
@permissions_needed("manage_oidc")
def email_confirmation_html(user, identifier, email):
base_url = url_for("account.index", _external=True)
email_confirmation_url = url_for(
"account.email_confirmation",
data=obj_to_b64([identifier, email]),
hash=build_hash(identifier, email),
_external=True,
)
return render_template(
"mail/email-confirmation.html",
site_name=current_app.config.get("NAME", "Canaille"),
site_url=base_url,
confirmation_url=email_confirmation_url,
logo=current_app.config.get("LOGO"),
title=_("Email confirmation on {website_name}").format(
website_name=current_app.config.get("NAME", "Canaille")
),
)
@bp.route("/mail/<identifier>/<email>/email-confirmation.txt")
@permissions_needed("manage_oidc")
def email_confirmation_txt(user, identifier, email):
base_url = url_for("account.index", _external=True)
email_confirmation_url = url_for(
"account.email_confirmation",
data=obj_to_b64([identifier, email]),
hash=build_hash(identifier, email),
_external=True,
)
return render_template(
"mail/email-confirmation.txt",
site_name=current_app.config.get("NAME", "Canaille"),
site_url=base_url,
confirmation_url=email_confirmation_url,
)

View file

@ -284,7 +284,7 @@ PROFILE_FORM_FIELDS = dict(
)
def profile_form(write_field_names, readonly_field_names, user=None):
def build_profile_form(write_field_names, readonly_field_names, user=None):
if "password" in write_field_names:
write_field_names |= {"password1", "password2"}
@ -379,3 +379,34 @@ class InvitationForm(Form):
],
render_kw={},
)
class EmailConfirmationForm(Form):
old_emails = wtforms.FieldList(
wtforms.EmailField(
_("Email addresses"),
validators=[ReadOnly()],
description=_(
"This email will be used as a recovery address to reset the password if needed"
),
render_kw={
"placeholder": _("jane@doe.com"),
"spellcheck": "false",
"autocorrect": "off",
"readonly": "true",
},
),
)
new_email = wtforms.EmailField(
_("New email address"),
validators=[
wtforms.validators.DataRequired(),
wtforms.validators.Email(),
unique_email,
],
render_kw={
"placeholder": _("jane@doe.com"),
"spellcheck": "false",
"autocorrect": "off",
},
)

View file

@ -1,4 +1,4 @@
from canaille.app import profile_hash
from canaille.app import build_hash
from canaille.app.mails import logo
from canaille.app.mails import send_email
from flask import current_app
@ -41,7 +41,7 @@ def send_password_reset_mail(user, mail):
reset_url = url_for(
"account.reset",
user=user,
hash=profile_hash(
hash=build_hash(
user.identifier,
mail,
user.password[0] if user.has_password() else "",
@ -82,7 +82,7 @@ def send_password_initialization_mail(user, email):
reset_url = url_for(
"account.reset",
user=user,
hash=profile_hash(
hash=build_hash(
user.identifier,
email,
user.password[0] if user.has_password() else "",
@ -147,3 +147,34 @@ def send_invitation_mail(email, registration_url):
html=html_body,
attachements=[(logo_cid, logo_filename, logo_raw)] if logo_filename else None,
)
def send_confirmation_email(email, confirmation_url):
base_url = url_for("account.index", _external=True)
logo_cid, logo_filename, logo_raw = logo()
subject = _("Confirm your address email on {website_name}").format(
website_name=current_app.config.get("NAME", base_url)
)
text_body = render_template(
"mail/email-confirmation.txt",
site_name=current_app.config.get("NAME", base_url),
site_url=base_url,
confirmation_url=confirmation_url,
)
html_body = render_template(
"mail/email-confirmation.html",
site_name=current_app.config.get("NAME", base_url),
site_url=base_url,
confirmation_url=confirmation_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,
)

View file

@ -39,7 +39,7 @@ del_button=false
<div class="ui
{%- if corner_indicator %} corner labeled{% endif -%}
{%- if icon or field.description %} left icon{% endif -%}
{%- if field_visible and (add_button or del_button) %} action{% endif -%}
{%- if field_visible and ((add_button or del_button) and not readonly and not disabled) or caller is defined %} action{% endif -%}
{%- if field.type not in ("BooleanField", "RadioField") %} input{% endif -%}
">
{% endif %}
@ -75,6 +75,9 @@ del_button=false
{% endif %}
{% if field_visible %}
{% if caller is defined %}
{{ caller() }}
{% else %}
{% if del_button %}
<button
class="ui teal icon button"
@ -82,7 +85,6 @@ del_button=false
type="submit"
name="fieldlist_remove"
value="{{ field.name }}"
hx-post=""
{# Workaround for https://github.com/bigskysoftware/htmx/issues/1506 #}
hx-vals='{"fieldlist_remove": "{{ field.name }}"}'
hx-target="closest .fieldlist"
@ -90,14 +92,13 @@ del_button=false
<i class="minus icon"></i>
</button>
{% endif %}
{% if add_button %}
{% if add_button and not readonly and not disabled %}
<button
class="ui teal icon button"
title="{{ _("Add another field") }}"
type="submit"
name="fieldlist_add"
value="{{ field.name }}"
hx-post=""
{# Workaround for https://github.com/bigskysoftware/htmx/issues/1506 #}
hx-vals='{"fieldlist_add": "{{ field.name }}"}'
hx-target="closest .fieldlist"
@ -105,6 +106,7 @@ del_button=false
<i class="plus icon"></i>
</button>
{% endif %}
{% endif %}
</div>
{% endif %}

View file

@ -98,6 +98,19 @@
</div>
</div>
<div class="item">
<div class="right floated content">
<div class="ui buttons">
<a class="ui button primary" href="{{ url_for("admin.email_confirmation_txt", identifier=user.identifier, email=user.preferred_email) }}">TXT</a>
<a class="ui button primary" href="{{ url_for("admin.email_confirmation_html", identifier=user.identifier, email=user.preferred_email) }}">HTML</a>
</div>
</div>
<div class="middle aligned content">
{{ _("Email verification") }}
</div>
</div>
<div class="item">
<div class="right floated content">
<div class="ui buttons">

View file

@ -0,0 +1,44 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "https://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="https://www.w3.org/1999/xhtml">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width"/>
<style type="text/css" style="font-weight: 300">@import url({{ url_for('static', filename='fonts/lato.css', _external=True) }});</style>
<title>{{ title }}</title>
</head>
<body style="color: rgba(0,0,0,.87); padding: 1em; margin: auto; width: 700px; font-family: Lato,'Helvetica Neue',Arial,Helvetica,sans-serif; font-weight: 400; font-size: 14px;">
<table cellspacing="0" cellpadding="0" border="0" style="font-weight: 400; background: #fff; font-size: 1rem; margin-top: 0; margin-bottom: 0; width: 700px;">
<tr>
<td colspan="2">
<h3 style="font-weight: 700; line-height: 1.3em; font-size: 1.3rem; padding: .8rem 1rem; margin: 0; box-shadow: none; border: 1px solid #d4d4d5; border-radius: .3rem .3rem 0 0;">
{% if logo %}
<img src="{{ logo }}" alt="{{ site_name }}" style="font-size: 1.3rem; border-style: none; width: 50px; display: inline-block; margin-top: .14em; vertical-align: middle;">
{% endif %}
<div style="font-size: 1.3rem; display: inline-block; padding-left: .75rem; vertical-align: middle;">
{% trans %}Email address confirmation{% endtrans %}
</div>
</h3>
</td>
</tr>
<tr>
<td colspan="2" style="background: #f8f8f9; padding: 1em 1.5em; line-height: 1.4em; font-size: 1em; margin: 0; border-radius: 0; text-align: justify; border-left: 1px solid #d4d4d5; border-right: 1px solid #d4d4d5;">
{% trans %}
You added an email address to your account on {{ site_name }}.
Please click on the "Validate email address" button below to confirm your email address.
{% endtrans %}
</td>
</tr>
<tr style="margin: 0; border-radius: 0; text-align:center; border-left: 1px solid #d4d4d5; border-right: 1px solid #d4d4d5; border-bottom: 1px solid #d4d4d5;">
<td style="background: #e0e1e2; width:50%; border-radius: 0 0 0 .3rem;">
<a href="{{ site_url }}" style="width: 100%; display: inline-block; vertical-align: middle; color: rgba(0,0,0,.6); padding: .8em 0; font-weight: 700; line-height: 1em; text-align: center; text-decoration: none; font-size: 1rem;margin: 0; border-left: none; border-radius: 0; box-shadow: 0 0 0 1px transparent inset,0 0 0 0 rgba(34,36,38,.15) inset;">{{ site_name }}</a>
</td>
<td style="background: #2185d0; width:50%; border-radius: 0 0 .3rem 0;">
<a href="{{ confirmation_url }}" style="width: 100%; display: inline-block; vertical-align: middle; padding: .8em 0; font-weight: 700; line-height: 1em; text-align: center; text-decoration: none; font-size: 1rem; color: #fff; margin: 0; border-radius: 0; box-shadow: 0 0 0 1px transparent inset,0 0 0 0 rgba(34,36,38,.15) inset;">{% trans %}Validate email address{% endtrans %}</a>
</td>
</tr>
</table>
</body>
</html>

View file

@ -0,0 +1,9 @@
# {% trans %}Email verification{% endtrans %}
{% trans %}
You added an email address to your account on {{ site_name }}.
Please click on the link below to confirm your email address.
{% endtrans %}
{% trans %}Verification link{% endtrans %}: {{ confirmation_url }}
{{ site_name }}: {{ site_url }}

View file

@ -49,17 +49,17 @@
</div>
</h2>
{% call fui.render_form(form) %}
{% if "photo" in form %}
{% call fui.render_form(profile_form) %}
{% if "photo" in profile_form %}
<div class="ui grid">
<div class="three wide column">
{% block photo_field scoped %}
{{ profile.render_field(form.photo, display=false, class="photo-field") }}
{{ profile.render_field(form.photo_delete, display=false, class="photo-delete-button") }}
{{ profile.render_field(profile_form.photo, display=false, class="photo-field") }}
{{ profile.render_field(profile_form.photo_delete, display=false, class="photo-delete-button") }}
{% set photo = edited_user.photo and edited_user.photo[0] %}
<label
class="ui small bordered image photo-content"
for="{{ form.photo.id }}"
for="{{ profile_form.photo.id }}"
title="{{ _("Click to upload a photo") }}"
{% if not photo %}style="display: none;"{% endif %}>
@ -70,7 +70,7 @@
</label>
<label
class="ui centered photo-placeholder"
for="{{ form.photo.id }}"
for="{{ profile_form.photo.id }}"
title="{{ _("Click to upload a photo") }}"
{% if photo %}style="display: none;"{% endif %}>
<i class="massive centered portrait icon"></i>
@ -81,83 +81,83 @@
<div class="thirteen wide column">
{% endif %}
{% if "given_name" in form or "family_name" in form %}
{% if "given_name" in profile_form or "family_name" in profile_form %}
<div class="equal width fields">
{% if "given_name" in form %}
{% block given_name_field scoped %}{{ profile.render_field(form.given_name, user, edited_user) }}{% endblock %}
{% if "given_name" in profile_form %}
{% block given_name_field scoped %}{{ profile.render_field(profile_form.given_name, user, edited_user) }}{% endblock %}
{% endif %}
{% if "family_name" in form %}
{% block sn_field scoped %}{{ profile.render_field(form.family_name, user, edited_user) }}{% endblock %}
{% if "family_name" in profile_form %}
{% block sn_field scoped %}{{ profile.render_field(profile_form.family_name, user, edited_user) }}{% endblock %}
{% endif %}
</div>
{% endif %}
{% if "display_name" in form %}
{% block display_name_field scoped %}{{ profile.render_field(form.display_name, user, edited_user) }}{% endblock %}
{% if "display_name" in profile_form %}
{% block display_name_field scoped %}{{ profile.render_field(profile_form.display_name, user, edited_user) }}{% endblock %}
{% endif %}
{% if "photo" in form %}</div></div>{% endif %}
{% if "photo" in profile_form %}</div></div>{% endif %}
{% if "emails" in form %}
{% block emails_field scoped %}{{ profile.render_field(form.emails, user, edited_user) }}{% endblock %}
{% if "emails" in profile_form and not emails_form %}
{% block emails_field scoped %}{{ profile.render_field(profile_form.emails, user, edited_user) }}{% endblock %}
{% endif %}
{% if "phone_numbers" in form %}
{% block phone_numbers_field scoped %}{{ profile.render_field(form.phone_numbers, user, edited_user) }}{% endblock %}
{% if "phone_numbers" in profile_form %}
{% block phone_numbers_field scoped %}{{ profile.render_field(profile_form.phone_numbers, user, edited_user) }}{% endblock %}
{% endif %}
{% if "formatted_address" in form %}
{% block formatted_address_field scoped %}{{ profile.render_field(form.formatted_address, user, edited_user) }}{% endblock %}
{% if "formatted_address" in profile_form %}
{% block formatted_address_field scoped %}{{ profile.render_field(profile_form.formatted_address, user, edited_user) }}{% endblock %}
{% endif %}
{% if "street" in form %}
{% block street_field scoped %}{{ profile.render_field(form.street, user, edited_user) }}{% endblock %}
{% if "street" in profile_form %}
{% block street_field scoped %}{{ profile.render_field(profile_form.street, user, edited_user) }}{% endblock %}
{% endif %}
<div class="equal width fields">
{% if "postal_code" in form %}
{% block postal_code_field scoped %}{{ profile.render_field(form.postal_code, user, edited_user) }}{% endblock %}
{% if "postal_code" in profile_form %}
{% block postal_code_field scoped %}{{ profile.render_field(profile_form.postal_code, user, edited_user) }}{% endblock %}
{% endif %}
{% if "locality" in form %}
{% block locality_field scoped %}{{ profile.render_field(form.locality, user, edited_user) }}{% endblock %}
{% if "locality" in profile_form %}
{% block locality_field scoped %}{{ profile.render_field(profile_form.locality, user, edited_user) }}{% endblock %}
{% endif %}
{% if "region" in form %}
{% block region_field scoped %}{{ profile.render_field(form.region, user, edited_user) }}{% endblock %}
{% if "region" in profile_form %}
{% block region_field scoped %}{{ profile.render_field(profile_form.region, user, edited_user) }}{% endblock %}
{% endif %}
</div>
<div class="equal width fields">
{% if "department" in form %}
{% block department_number_field scoped %}{{ profile.render_field(form.department, user, edited_user) }}{% endblock %}
{% if "department" in profile_form %}
{% block department_number_field scoped %}{{ profile.render_field(profile_form.department, user, edited_user) }}{% endblock %}
{% endif %}
{% if "employee_number" in form %}
{% block employee_number_field scoped %}{{ profile.render_field(form.employee_number, user, edited_user) }}{% endblock %}
{% if "employee_number" in profile_form %}
{% block employee_number_field scoped %}{{ profile.render_field(profile_form.employee_number, user, edited_user) }}{% endblock %}
{% endif %}
</div>
<div class="equal width fields">
{% if "title" in form %}
{% block title_field scoped %}{{ profile.render_field(form.title, user, edited_user) }}{% endblock %}
{% if "title" in profile_form %}
{% block title_field scoped %}{{ profile.render_field(profile_form.title, user, edited_user) }}{% endblock %}
{% endif %}
{% if "organization" in form %}
{% block organization_field scoped %}{{ profile.render_field(form.organization, user, edited_user) }}{% endblock %}
{% if "organization" in profile_form %}
{% block organization_field scoped %}{{ profile.render_field(profile_form.organization, user, edited_user) }}{% endblock %}
{% endif %}
</div>
{% if "profile_url" in form %}
{% block profile_url_field scoped %}{{ profile.render_field(form.profile_url, user, edited_user) }}{% endblock %}
{% if "profile_url" in profile_form %}
{% block profile_url_field scoped %}{{ profile.render_field(profile_form.profile_url, user, edited_user) }}{% endblock %}
{% endif %}
{% if "preferred_language" in form %}
{% block preferred_language_field scoped %}{{ profile.render_field(form.preferred_language, user, edited_user) }}{% endblock %}
{% if "preferred_language" in profile_form %}
{% block preferred_language_field scoped %}{{ profile.render_field(profile_form.preferred_language, user, edited_user) }}{% endblock %}
{% endif %}
<div class="ui right aligned container">
@ -168,5 +168,69 @@
</div>
</div>
{% endcall %}
{% if emails_form %}
<h2 class="ui center aligned header">
<div class="content">
{% if user.user_name == edited_user.user_name %}
{% trans %}My email addresses{% endtrans %}
{% else %}
{% trans %}Email addresses{% endtrans %}
{% endif %}
</div>
</h2>
{% call fui.render_form(emails_form) %}
<div class="field fieldlist">
{{ emails_form.old_emails.label() }}
{% for field in emails_form.old_emails %}
<div class="field">
<div class="ui corner labeled left icon{% if emails_form.old_emails|len>1 %} action{% endif %} input">
{{ field(readonly=True) }}
{% if field.description %}
<i class="question circle link icon" title="{{ field.description }}"></i>
{% endif %}
{% if emails_form.old_emails|len > 1 %}
<button
class="ui teal icon button"
title="{{ _("Remove this email address") }}"
type="submit"
name="email_remove"
value="{{ field.data }}"
{# Workaround for https://github.com/bigskysoftware/htmx/issues/1506 #}
hx-vals='{"email_remove": "{{ field.data }}"}'
hx-target="closest .fieldlist"
formnovalidate>
<i class="minus icon"></i>
</button>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% if emails_form.add_email_button %}
{% call fui.render_field(emails_form.new_email) %}
<button
class="ui primary right labeled icon button"
type="submit"
name="action"
value="add_email"
title="{% trans %}Send a verification email to validate this address.{% endtrans %}"
{# Workaround for https://github.com/bigskysoftware/htmx/issues/1506 #}
hx-vals='{"action": "add_email"}'
hx-target="closest .fieldlist"
formnovalidate
>
<i class="send icon"></i>
{% trans %}
Verify
{% endtrans %}
</button>
{% endcall %}
{% endif %}
{% endcall %}
{% endif %}
</div>
{% endblock %}

View file

@ -38,6 +38,13 @@ FAVICON = "/static/img/canaille-c.png"
# Accelerates webpages with async requests
# HTMX = true
# If EMAIL_CONFIRMATION is 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, this is true if SMTP is configured, else this is false.
# If explicitely set to true and SMTP is disabled, the email field
# will be read-only.
# EMAIL_CONFIRMATION =
# 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

View file

@ -38,6 +38,13 @@ FAVICON = "/static/img/canaille-c.png"
# Accelerates webpages with async requests
# HTMX = true
# If EMAIL_CONFIRMATION is 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, this is true if SMTP is configured, else this is false.
# If explicitely set to true and SMTP is disabled, the email field
# will be read-only.
# EMAIL_CONFIRMATION =
# 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

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.
: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,
this is true if SMTP is configured, else this is false. If explicitely set to
true and SMTP is disabled, the email field will be read-only.
:HIDE_INVALID_LOGINS:
*Optional.* Wether to tell the users if a username exists during failing login attempts.
Defaults to ``True``. This may be a security issue to disable this, as this give a way to malicious people to if an account exists on this canaille instance.

View file

@ -412,13 +412,14 @@ def test_fieldlist_add_readonly(testclient, logged_user, configuration):
configuration["ACL"]["DEFAULT"]["READ"].append("phone_numbers")
res = testclient.get("/profile/user")
assert "readonly" in res.form["phone_numbers-0"].attrs
assert "phone_numbers-1" not in res.form.fields
form = res.forms["baseform"]
assert "readonly" in form["phone_numbers-0"].attrs
assert "phone_numbers-1" not in form.fields
data = {
"csrf_token": res.form["csrf_token"].value,
"family_name": res.form["family_name"].value,
"phone_numbers-0": res.form["phone_numbers-0"].value,
"csrf_token": form["csrf_token"].value,
"family_name": form["family_name"].value,
"phone_numbers-0": form["phone_numbers-0"].value,
"fieldlist_add": "phone_numbers-0",
}
testclient.post("/profile/user", data, status=403)
@ -431,13 +432,14 @@ def test_fieldlist_remove_readonly(testclient, logged_user, configuration):
logged_user.save()
res = testclient.get("/profile/user")
assert "readonly" in res.form["phone_numbers-0"].attrs
assert "readonly" in res.form["phone_numbers-1"].attrs
form = res.forms["baseform"]
assert "readonly" in form["phone_numbers-0"].attrs
assert "readonly" in form["phone_numbers-1"].attrs
data = {
"csrf_token": res.form["csrf_token"].value,
"family_name": res.form["family_name"].value,
"phone_numbers-0": res.form["phone_numbers-0"].value,
"csrf_token": form["csrf_token"].value,
"family_name": form["family_name"].value,
"phone_numbers-0": form["phone_numbers-0"].value,
"fieldlist_remove": "phone_numbers-1",
}
testclient.post("/profile/user", data, status=403)

View file

@ -6,40 +6,44 @@ def test_preferred_language(testclient, logged_user):
logged_user.save()
res = testclient.get("/profile/user", status=200)
assert res.form["preferred_language"].value == "auto"
form = res.forms["baseform"]
assert form["preferred_language"].value == "auto"
assert res.pyquery("html")[0].attrib["lang"] == "en"
res.mustcontain("My profile")
res.mustcontain(no="Mon profil")
res.form["preferred_language"] = "fr"
res = res.form.submit(name="action", value="edit")
form["preferred_language"] = "fr"
res = form.submit(name="action", value="edit-profile")
assert res.flashes == [("success", "Le profil a été mis à jour avec succès.")]
res = res.follow()
form = res.forms["baseform"]
logged_user.reload()
assert logged_user.preferred_language == "fr"
assert res.form["preferred_language"].value == "fr"
assert form["preferred_language"].value == "fr"
assert res.pyquery("html")[0].attrib["lang"] == "fr"
res.mustcontain(no="My profile")
res.mustcontain("Mon profil")
res.form["preferred_language"] = "en"
res = res.form.submit(name="action", value="edit")
form["preferred_language"] = "en"
res = form.submit(name="action", value="edit-profile")
assert res.flashes == [("success", "Profile updated successfully.")]
res = res.follow()
form = res.forms["baseform"]
logged_user.reload()
assert logged_user.preferred_language == "en"
assert res.form["preferred_language"].value == "en"
assert form["preferred_language"].value == "en"
assert res.pyquery("html")[0].attrib["lang"] == "en"
res.mustcontain("My profile")
res.mustcontain(no="Mon profil")
res.form["preferred_language"] = "auto"
res = res.form.submit(name="action", value="edit")
form["preferred_language"] = "auto"
res = form.submit(name="action", value="edit-profile")
assert res.flashes == [("success", "Profile updated successfully.")]
res = res.follow()
form = res.forms["baseform"]
logged_user.reload()
assert logged_user.preferred_language is None
assert res.form["preferred_language"].value == "auto"
assert form["preferred_language"].value == "auto"
assert res.pyquery("html")[0].attrib["lang"] == "en"
res.mustcontain("My profile")
res.mustcontain(no="Mon profil")
@ -50,11 +54,12 @@ def test_form_translations(testclient, logged_user):
logged_user.save()
res = testclient.get("/profile/user", status=200)
res.form["emails-0"] = "invalid"
res = res.form.submit(name="action", value="edit")
form = res.forms["baseform"]
form["phone_numbers-0"] = "invalid"
res = form.submit(name="action", value="edit-profile")
res.mustcontain(no="Invalid email address.")
res.mustcontain("Adresse électronique non valide.")
res.mustcontain(no="Not a valid phone number")
res.mustcontain("Nest pas un numéro de téléphone valide")
def test_language_config(testclient, logged_user):

View file

@ -162,7 +162,13 @@ def test_mail_with_logo_in_http(testclient, logged_admin, smtpd, httpserver):
def test_mail_debug_pages(testclient, logged_admin):
for base in ["test", "password-init", "reset", "admin/admin@admin.com/invitation"]:
for base in [
"test",
"password-init",
"reset",
"admin/admin@admin.com/invitation",
"admin/admin@admin.com/email-confirmation",
]:
testclient.get(f"/admin/mail/{base}.html")
testclient.get(f"/admin/mail/{base}.txt")

View file

@ -0,0 +1,524 @@
import datetime
from unittest import mock
import freezegun
from canaille.core.account import EmailConfirmationObject
from canaille.core.account import Invitation
from flask import url_for
def test_confirmation_disabled_email_editable(testclient, backend, logged_user):
"""
If email confirmation is disabled, users should be able to pick
any email.
"""
testclient.app.config["EMAIL_CONFIRMATION"] = False
res = testclient.get("/profile/user")
assert "readonly" not in res.form["emails-0"].attrs
assert not any(field.id == "add_email" for field in res.form.fields["action"])
res = res.form.submit(name="fieldlist_add", value="emails-0")
res.form["emails-0"] = "email1@mydomain.tld"
res.form["emails-1"] = "email2@mydomain.tld"
res = res.form.submit(name="action", value="edit-profile")
assert res.flashes == [("success", "Profile updated successfully.")]
res = res.follow()
logged_user.reload()
assert logged_user.emails == ["email1@mydomain.tld", "email2@mydomain.tld"]
def test_confirmation_unset_smtp_disabled_email_editable(
testclient, backend, logged_admin, user
):
"""
If email confirmation is unset and no SMTP server has
been configured, then email confirmation cannot be enabled,
thus users must be able to pick any email.
"""
del testclient.app.config["SMTP"]
testclient.app.config["EMAIL_CONFIRMATION"] = None
res = testclient.get("/profile/user")
assert "readonly" not in res.form["emails-0"].attrs
assert not any(field.id == "add_email" for field in res.form.fields["action"])
res = res.form.submit(name="fieldlist_add", value="emails-0")
res.form["emails-0"] = "email1@mydomain.tld"
res.form["emails-1"] = "email2@mydomain.tld"
res = res.form.submit(name="action", value="edit-profile")
assert res.flashes == [("success", "Profile updated successfully.")]
res = res.follow()
user.reload()
assert user.emails == ["email1@mydomain.tld", "email2@mydomain.tld"]
def test_confirmation_enabled_smtp_disabled_readonly(testclient, backend, logged_user):
"""
If email confirmation is enabled and no SMTP server is configured,
this might be a misconfiguration, or a temporary SMTP disabling.
In doubt, users cannot edit their emails.
"""
del testclient.app.config["SMTP"]
testclient.app.config["EMAIL_CONFIRMATION"] = True
res = testclient.get("/profile/user")
assert "readonly" in res.forms["emailconfirmationform"]["old_emails-0"].attrs
assert "emails-0" not in res.forms["baseform"].fields
res.forms["emailconfirmationform"]["old_emails-0"] = "email1@mydomain.tld"
assert "action" not in res.forms["emailconfirmationform"].fields
def test_confirmation_unset_smtp_enabled_email_admin_editable(
testclient, backend, logged_admin, user
):
"""
Administrators should be able to edit user email addresses,
even when email confirmation is unset and SMTP is configured.
"""
testclient.app.config["EMAIL_CONFIRMATION"] = None
res = testclient.get("/profile/user")
assert "readonly" not in res.form["emails-0"].attrs
assert not any(field.id == "add_email" for field in res.form.fields["action"])
res = res.form.submit(name="fieldlist_add", value="emails-0")
res.form["emails-0"] = "email1@mydomain.tld"
res.form["emails-1"] = "email2@mydomain.tld"
res = res.form.submit(name="action", value="edit-profile")
assert res.flashes == [("success", "Profile updated successfully.")]
res = res.follow()
user.reload()
assert user.emails == ["email1@mydomain.tld", "email2@mydomain.tld"]
def test_confirmation_enabled_smtp_disabled_admin_editable(
testclient, backend, logged_admin, user
):
"""
Administrators should be able to edit user email addresses,
even when email confirmation is enabled and SMTP is disabled.
"""
testclient.app.config["EMAIL_CONFIRMATION"] = True
del testclient.app.config["SMTP"]
res = testclient.get("/profile/user")
assert "readonly" not in res.form["emails-0"].attrs
assert not any(field.id == "add_email" for field in res.form.fields["action"])
res = res.form.submit(name="fieldlist_add", value="emails-0")
res.form["emails-0"] = "email1@mydomain.tld"
res.form["emails-1"] = "email2@mydomain.tld"
res = res.form.submit(name="action", value="edit-profile")
assert res.flashes == [("success", "Profile updated successfully.")]
res = res.follow()
user.reload()
assert user.emails == ["email1@mydomain.tld", "email2@mydomain.tld"]
def test_confirmation_unset_smtp_enabled_email_user_validation(
smtpd, testclient, backend, user
):
"""
If email confirmation is unset and there is a SMTP server
configured, then users emails should be validated by sending
a confirmation email.
"""
testclient.app.config["EMAIL_CONFIRMATION"] = None
with freezegun.freeze_time("2020-01-01 01:00:00"):
res = testclient.get("/login")
res.form["login"] = "user"
res = res.form.submit().follow()
res.form["password"] = "correct horse battery staple"
res = res.form.submit()
with freezegun.freeze_time("2020-01-01 02:00:00"):
res = testclient.get("/profile/user")
assert "readonly" in res.forms["emailconfirmationform"]["old_emails-0"].attrs
with freezegun.freeze_time("2020-01-01 02:00:00"):
res.forms["emailconfirmationform"]["new_email"] = "new_email@mydomain.tld"
res = res.forms["emailconfirmationform"].submit(
name="action", value="add_email"
)
assert res.flashes == [
(
"success",
"An email has been sent to the email address. "
"Please check your inbox and click on the verification link it contains",
)
]
email_confirmation = EmailConfirmationObject(
"2020-01-01T02:00:00+00:00",
"user",
"new_email@mydomain.tld",
)
email_confirmation_url = url_for(
"account.email_confirmation",
data=email_confirmation.b64(),
hash=email_confirmation.build_hash(),
_external=True,
)
assert len(smtpd.messages) == 1
assert email_confirmation_url in str(smtpd.messages[0].get_payload()[0]).replace(
"=\n", ""
)
with freezegun.freeze_time("2020-01-01 03:00:00"):
res = testclient.get(email_confirmation_url)
assert ("success", "Your email address have been confirmed.") in res.flashes
user.reload()
assert "new_email@mydomain.tld" in user.emails
def test_confirmation_invalid_link(testclient, backend, user):
"""
Random confirmation links should fail.
"""
res = testclient.get("/email-confirmation/invalid/invalid")
assert (
"error",
"The email confirmation link that brought you here is invalid.",
) in res.flashes
def test_confirmation_mail_form_failed(testclient, backend, user):
"""
Tests when an error happens during the mail sending.
"""
with freezegun.freeze_time("2020-01-01 01:00:00"):
res = testclient.get("/login")
res.form["login"] = "user"
res = res.form.submit().follow()
res.form["password"] = "correct horse battery staple"
res = res.form.submit()
with freezegun.freeze_time("2020-01-01 02:00:00"):
res = testclient.get("/profile/user")
assert "readonly" in res.forms["emailconfirmationform"]["old_emails-0"].attrs
with freezegun.freeze_time("2020-01-01 02:00:00"):
res.forms["emailconfirmationform"]["new_email"] = "invalid"
res = res.forms["emailconfirmationform"].submit(
name="action", value="add_email"
)
assert res.flashes == [("error", "Email addition failed.")]
user.reload()
assert user.emails == ["john@doe.com"]
@mock.patch("smtplib.SMTP")
def test_confirmation_mail_send_failed(SMTP, smtpd, testclient, backend, user):
"""
Tests when an error happens during the mail sending.
"""
SMTP.side_effect = mock.Mock(side_effect=OSError("unit test mail error"))
with freezegun.freeze_time("2020-01-01 01:00:00"):
res = testclient.get("/login")
res.form["login"] = "user"
res = res.form.submit().follow()
res.form["password"] = "correct horse battery staple"
res = res.form.submit()
with freezegun.freeze_time("2020-01-01 02:00:00"):
res = testclient.get("/profile/user")
assert "readonly" in res.forms["emailconfirmationform"]["old_emails-0"].attrs
with freezegun.freeze_time("2020-01-01 02:00:00"):
res.forms["emailconfirmationform"]["new_email"] = "new_email@mydomain.tld"
res = res.forms["emailconfirmationform"].submit(
name="action", value="add_email", expect_errors=True
)
assert res.flashes == [("error", "Could not send the verification email")]
user.reload()
assert user.emails == ["john@doe.com"]
def test_confirmation_expired_link(testclient, backend, user):
"""
Expired valid confirmation links should fail.
"""
email_confirmation = EmailConfirmationObject(
"2020-01-01T01:00:00+00:00",
"user",
"new_email@mydomain.tld",
)
email_confirmation_url = url_for(
"account.email_confirmation",
data=email_confirmation.b64(),
hash=email_confirmation.build_hash(),
_external=True,
)
with freezegun.freeze_time("2021-01-01 01:00:00"):
res = testclient.get(email_confirmation_url)
assert (
"error",
"The email confirmation link that brought you here has expired.",
) in res.flashes
user.reload()
assert "new_email@mydomain.tld" not in user.emails
def test_confirmation_invalid_hash_link(testclient, backend, user):
"""
Confirmation link with invalid hashes should fail.
"""
email_confirmation = EmailConfirmationObject(
"2020-01-01T01:00:00+00:00",
"user",
"new_email@mydomain.tld",
)
email_confirmation_url = url_for(
"account.email_confirmation",
data=email_confirmation.b64(),
hash="invalid",
_external=True,
)
with freezegun.freeze_time("2020-01-01 01:00:00"):
res = testclient.get(email_confirmation_url)
assert (
"error",
"The invitation link that brought you here was invalid.",
) in res.flashes
user.reload()
assert "new_email@mydomain.tld" not in user.emails
def test_confirmation_invalid_user_link(testclient, backend, user):
"""
Confirmation link about an unexisting user should fail.
For instance, when the user account has been deleted between
the mail is sent and the link is clicked.
"""
email_confirmation = EmailConfirmationObject(
"2020-01-01T01:00:00+00:00",
"invalid-user",
"new_email@mydomain.tld",
)
email_confirmation_url = url_for(
"account.email_confirmation",
data=email_confirmation.b64(),
hash=email_confirmation.build_hash(),
_external=True,
)
with freezegun.freeze_time("2020-01-01 01:00:00"):
res = testclient.get(email_confirmation_url)
assert (
"error",
"The email confirmation link that brought you here is invalid.",
) in res.flashes
user.reload()
assert "new_email@mydomain.tld" not in user.emails
def test_confirmation_email_already_confirmed_link(testclient, backend, user, admin):
"""
Clicking twice on a confirmation link should fail.
"""
email_confirmation = EmailConfirmationObject(
"2020-01-01T01:00:00+00:00",
"user",
"john@doe.com",
)
email_confirmation_url = url_for(
"account.email_confirmation",
data=email_confirmation.b64(),
hash=email_confirmation.build_hash(),
_external=True,
)
with freezegun.freeze_time("2020-01-01 01:00:00"):
res = testclient.get(email_confirmation_url)
assert (
"error",
"This address email have already been confirmed.",
) in res.flashes
user.reload()
assert "new_email@mydomain.tld" not in user.emails
def test_confirmation_email_already_used_link(testclient, backend, user, admin):
"""
Confirmation link should fail if the target email is already associated
to another account. For instance, if an administrator already put
this email to someone else's profile.
"""
email_confirmation = EmailConfirmationObject(
"2020-01-01T01:00:00+00:00",
"user",
"jane@doe.com",
)
email_confirmation_url = url_for(
"account.email_confirmation",
data=email_confirmation.b64(),
hash=email_confirmation.build_hash(),
_external=True,
)
with freezegun.freeze_time("2020-01-01 01:00:00"):
res = testclient.get(email_confirmation_url)
assert (
"error",
"This address email is already associated with another account.",
) in res.flashes
user.reload()
assert "new_email@mydomain.tld" not in user.emails
def test_delete_email(testclient, logged_user):
"""
Tests that user can deletes its emails unless they have only
one left.
"""
res = testclient.get("/profile/user")
assert "email_remove" not in res.forms["emailconfirmationform"].fields
logged_user.emails = logged_user.emails + ["new@email.com"]
logged_user.save()
res = testclient.get("/profile/user")
assert "email_remove" in res.forms["emailconfirmationform"].fields
res = res.forms["emailconfirmationform"].submit(
name="email_remove", value="new@email.com"
)
assert res.flashes == [("success", "The email have been successfully deleted.")]
logged_user.reload()
assert logged_user.emails == ["john@doe.com"]
def test_delete_wrong_email(testclient, logged_user):
"""
Tests that removing an already removed email do not
produce anything.
"""
logged_user.emails = logged_user.emails + ["new@email.com"]
logged_user.save()
res = testclient.get("/profile/user")
res1 = res.forms["emailconfirmationform"].submit(
name="email_remove", value="new@email.com"
)
assert res1.flashes == [("success", "The email have been successfully deleted.")]
res2 = res.forms["emailconfirmationform"].submit(
name="email_remove", value="new@email.com"
)
assert res2.flashes == [("error", "Email deletion failed.")]
logged_user.reload()
assert logged_user.emails == ["john@doe.com"]
def test_delete_last_email(testclient, logged_user):
"""
Tests that users cannot remove their last email address.
"""
logged_user.emails = logged_user.emails + ["new@email.com"]
logged_user.save()
res = testclient.get("/profile/user")
res1 = res.forms["emailconfirmationform"].submit(
name="email_remove", value="new@email.com"
)
assert res1.flashes == [("success", "The email have been successfully deleted.")]
res2 = res.forms["emailconfirmationform"].submit(
name="email_remove", value="john@doe.com"
)
assert res2.flashes == [("error", "Email deletion failed.")]
logged_user.reload()
assert logged_user.emails == ["john@doe.com"]
def test_edition_forced_mail(testclient, logged_user):
"""
Tests that users that must perform email verification
cannot force the profile form.
"""
res = testclient.get("/profile/user", status=200)
form = res.forms["baseform"]
testclient.post(
"/profile/user",
{
"csrf_token": form["csrf_token"].value,
"emails-0": "new@email.com",
"action": "edit-profile",
},
)
logged_user.reload()
assert logged_user.emails == ["john@doe.com"]
def test_invitation_form_mail_field_readonly(testclient):
"""
Tests that the email field is readonly in the invitation
form creation if email confirmation is enabled.
"""
testclient.app.config["EMAIL_CONFIRMATION"] = True
invitation = Invitation(
datetime.datetime.now(datetime.timezone.utc).isoformat(),
"someoneelse",
False,
"someone@mydomain.tld",
[],
)
hash = invitation.build_hash()
b64 = invitation.b64()
res = testclient.get(f"/register/{b64}/{hash}")
assert "readonly" in res.form["emails-0"].attrs
def test_invitation_form_mail_field_writable(testclient):
"""
Tests that the email field is writable in the invitation
form creation if email confirmation is disabled.
"""
testclient.app.config["EMAIL_CONFIRMATION"] = False
invitation = Invitation(
datetime.datetime.now(datetime.timezone.utc).isoformat(),
"someoneelse",
False,
"someone@mydomain.tld",
[],
)
hash = invitation.build_hash()
b64 = invitation.b64()
res = testclient.get(f"/register/{b64}/{hash}")
assert "readonly" not in res.form["emails-0"].attrs

View file

@ -164,7 +164,7 @@ def test_registration(testclient, foo_group):
[foo_group.id],
)
b64 = invitation.b64()
hash = invitation.profile_hash()
hash = invitation.build_hash()
testclient.get(f"/register/{b64}/{hash}", status=200)
@ -178,13 +178,13 @@ def test_registration_formcontrol(testclient):
[],
)
b64 = invitation.b64()
hash = invitation.profile_hash()
hash = invitation.build_hash()
res = testclient.get(f"/register/{b64}/{hash}", status=200)
assert "emails-1" not in res.form.fields
res = res.form.submit(status=200, name="fieldlist_add", value="emails-0")
assert "emails-1" in res.form.fields
res = res.form.submit(status=200, name="fieldlist_add", value="phone_numbers-0")
assert "phone_numbers-1" in res.form.fields
def test_registration_invalid_hash(testclient, foo_group):
@ -205,7 +205,7 @@ def test_registration_invalid_data(testclient, foo_group):
"someone@mydomain.tld",
[foo_group.id],
)
hash = invitation.profile_hash()
hash = invitation.build_hash()
testclient.get(f"/register/invalid/{hash}", status=302)
@ -221,7 +221,7 @@ def test_registration_more_than_48_hours_after_invitation(testclient, foo_group)
"someone@mydomain.tld",
[foo_group.id],
)
hash = invitation.profile_hash()
hash = invitation.build_hash()
b64 = invitation.b64()
testclient.get(f"/register/{b64}/{hash}", status=302)
@ -235,7 +235,7 @@ def test_registration_no_password(testclient, foo_group):
"someone@mydomain.tld",
[foo_group.id],
)
hash = invitation.profile_hash()
hash = invitation.build_hash()
b64 = invitation.b64()
url = f"/register/{b64}/{hash}"
@ -260,7 +260,7 @@ def test_no_registration_if_logged_in(testclient, logged_user, foo_group):
"someone@mydomain.tld",
[foo_group.id],
)
hash = invitation.profile_hash()
hash = invitation.build_hash()
b64 = invitation.b64()
url = f"/register/{b64}/{hash}"
@ -298,7 +298,7 @@ def test_groups_are_saved_even_when_user_does_not_have_read_permission(
[foo_group.id],
)
b64 = invitation.b64()
hash = invitation.profile_hash()
hash = invitation.build_hash()
res = testclient.get(f"/register/{b64}/{hash}", status=200)

View file

@ -1,9 +1,9 @@
from canaille.core.account import profile_hash
from canaille.core.account import build_hash
def test_password_reset(testclient, user):
assert not user.check_password("foobarbaz")[0]
hash = profile_hash("user", user.preferred_email, user.password[0])
hash = build_hash("user", user.preferred_email, user.password[0])
res = testclient.get("/reset/user/" + hash, status=200)
@ -27,7 +27,7 @@ def test_password_reset_multiple_emails(testclient, user):
user.save()
assert not user.check_password("foobarbaz")[0]
hash = profile_hash("user", "foo@baz.com", user.password[0])
hash = build_hash("user", "foo@baz.com", user.password[0])
res = testclient.get("/reset/user/" + hash, status=200)
@ -55,7 +55,7 @@ def test_password_reset_bad_link(testclient, user):
def test_password_reset_bad_password(testclient, user):
hash = profile_hash("user", user.preferred_email, user.password[0])
hash = build_hash("user", user.preferred_email, user.password[0])
res = testclient.get("/reset/user/" + hash, status=200)

View file

@ -1,7 +1,21 @@
import pytest
from canaille.core.populate import fake_users
from webtest import Upload
@pytest.fixture
def configuration(configuration):
configuration["EMAIL_CONFIRMATION"] = False
return configuration
def test_invalid_form_request(testclient, logged_user):
res = testclient.get("/profile/user")
res = res.forms["baseform"].submit(
name="action", value="invalid-action", status=400
)
def test_user_list_pagination(testclient, logged_admin):
res = testclient.get("/users")
res.mustcontain("1 item")
@ -101,24 +115,25 @@ def test_edition(
jpeg_photo,
):
res = testclient.get("/profile/user", status=200)
res.form["given_name"] = "given_name"
res.form["family_name"] = "family_name"
res.form["display_name"] = "display_name"
res.form["emails-0"] = "email@mydomain.tld"
res.form["phone_numbers-0"] = "555-666-777"
res.form["formatted_address"] = "formatted_address"
res.form["street"] = "street"
res.form["postal_code"] = "postal_code"
res.form["locality"] = "locality"
res.form["region"] = "region"
res.form["employee_number"] = 666
res.form["department"] = 1337
res.form["title"] = "title"
res.form["organization"] = "organization"
res.form["preferred_language"] = "fr"
res.form["photo"] = Upload("logo.jpg", jpeg_photo)
form = res.forms["baseform"]
form["given_name"] = "given_name"
form["family_name"] = "family_name"
form["display_name"] = "display_name"
form["emails-0"] = "email@mydomain.tld"
form["phone_numbers-0"] = "555-666-777"
form["formatted_address"] = "formatted_address"
form["street"] = "street"
form["postal_code"] = "postal_code"
form["locality"] = "locality"
form["region"] = "region"
form["employee_number"] = 666
form["department"] = 1337
form["title"] = "title"
form["organization"] = "organization"
form["preferred_language"] = "fr"
form["photo"] = Upload("logo.jpg", jpeg_photo)
res = res.form.submit(name="action", value="edit")
res = form.submit(name="action", value="edit-profile")
assert res.flashes == [
("success", "Le profil a été mis à jour avec succès.")
], res.text
@ -157,10 +172,11 @@ def test_edition_remove_fields(
admin,
):
res = testclient.get("/profile/user", status=200)
res.form["display_name"] = ""
res.form["phone_numbers-0"] = ""
form = res.forms["baseform"]
form["display_name"] = ""
form["phone_numbers-0"] = ""
res = res.form.submit(name="action", value="edit")
res = form.submit(name="action", value="edit-profile")
assert res.flashes == [("success", "Profile updated successfully.")], res.text
res = res.follow()
@ -189,14 +205,15 @@ def test_field_permissions_none(testclient, logged_user):
}
res = testclient.get("/profile/user", status=200)
assert "phone_numbers-0" not in res.form.fields
form = res.forms["baseform"]
assert "phone_numbers-0" not in form.fields
testclient.post(
"/profile/user",
{
"action": "edit",
"action": "edit-profile",
"phone_numbers-0": "000-000-000",
"csrf_token": res.form["csrf_token"].value,
"csrf_token": form["csrf_token"].value,
},
)
logged_user.reload()
@ -214,14 +231,15 @@ def test_field_permissions_read(testclient, logged_user):
"PERMISSIONS": ["edit_self"],
}
res = testclient.get("/profile/user", status=200)
assert "phone_numbers-0" in res.form.fields
form = res.forms["baseform"]
assert "phone_numbers-0" in form.fields
testclient.post(
"/profile/user",
{
"action": "edit",
"action": "edit-profile",
"phone_numbers-0": "000-000-000",
"csrf_token": res.form["csrf_token"].value,
"csrf_token": form["csrf_token"].value,
},
)
logged_user.reload()
@ -239,14 +257,15 @@ def test_field_permissions_write(testclient, logged_user):
"PERMISSIONS": ["edit_self"],
}
res = testclient.get("/profile/user", status=200)
assert "phone_numbers-0" in res.form.fields
form = res.forms["baseform"]
assert "phone_numbers-0" in form.fields
testclient.post(
"/profile/user",
{
"action": "edit",
"action": "edit-profile",
"phone_numbers-0": "000-000-000",
"csrf_token": res.form["csrf_token"].value,
"csrf_token": form["csrf_token"].value,
},
)
logged_user.reload()
@ -255,15 +274,16 @@ def test_field_permissions_write(testclient, logged_user):
def test_simple_user_cannot_edit_other(testclient, admin, logged_user):
res = testclient.get("/profile/user", status=200)
form = res.forms["baseform"]
testclient.get("/profile/admin", status=404)
testclient.post(
"/profile/admin",
{"action": "edit", "csrf_token": res.form["csrf_token"].value},
{"action": "edit-profile", "csrf_token": form["csrf_token"].value},
status=404,
)
testclient.post(
"/profile/admin",
{"action": "delete", "csrf_token": res.form["csrf_token"].value},
{"action": "delete", "csrf_token": form["csrf_token"].value},
status=404,
)
testclient.get("/users", status=403)
@ -275,18 +295,20 @@ def test_admin_bad_request(testclient, logged_moderator):
def test_bad_email(testclient, logged_user):
res = testclient.get("/profile/user", status=200)
form = res.forms["baseform"]
res.form["emails-0"] = "john@doe.com"
form["emails-0"] = "john@doe.com"
res = res.form.submit(name="action", value="edit").follow()
res = form.submit(name="action", value="edit-profile").follow()
assert ["john@doe.com"] == logged_user.emails
res = testclient.get("/profile/user", status=200)
form = res.forms["baseform"]
res.form["emails-0"] = "yolo"
form["emails-0"] = "yolo"
res = res.form.submit(name="action", value="edit", status=200)
res = form.submit(name="action", value="edit-profile", status=200)
logged_user.reload()
@ -295,11 +317,12 @@ def test_bad_email(testclient, logged_user):
def test_surname_is_mandatory(testclient, logged_user):
res = testclient.get("/profile/user", status=200)
form = res.forms["baseform"]
logged_user.family_name = ["Doe"]
res.form["family_name"] = ""
form["family_name"] = ""
res = res.form.submit(name="action", value="edit", status=200)
res = form.submit(name="action", value="edit-profile", status=200)
logged_user.reload()
@ -308,18 +331,21 @@ def test_surname_is_mandatory(testclient, logged_user):
def test_formcontrol(testclient, logged_user):
res = testclient.get("/profile/user")
assert "emails-1" not in res.form.fields
form = res.forms["baseform"]
assert "emails-1" not in form.fields
res = res.form.submit(status=200, name="fieldlist_add", value="emails-0")
assert "emails-1" in res.form.fields
res = form.submit(status=200, name="fieldlist_add", value="emails-0")
form = res.forms["baseform"]
assert "emails-1" in form.fields
def test_formcontrol_htmx(testclient, logged_user):
res = testclient.get("/profile/user")
form = res.forms["baseform"]
data = {
field: res.form[field].value
for field in res.form.fields
if len(res.form.fields.get(field)) == 1
field: form[field].value
for field in form.fields
if len(form.fields.get(field)) == 1
}
data["fieldlist_add"] = "emails-0"
response = testclient.post(
@ -336,11 +362,13 @@ def test_formcontrol_htmx(testclient, logged_user):
def test_inline_validation(testclient, logged_admin, user):
res = testclient.get("/profile/admin")
form = res.forms["baseform"]
res = testclient.post(
"/profile/admin",
{
"csrf_token": res.form["csrf_token"].value,
"csrf_token": form["csrf_token"].value,
"emails-0": "john@doe.com",
"action": "edit-profile",
},
headers={
"HX-Request": "true",
@ -358,11 +386,13 @@ def test_inline_validation_keep_indicators(
configuration["ACL"]["ADMIN"]["WRITE"].append("display_name")
res = testclient.get("/profile/admin")
form = res.forms["baseform"]
res = testclient.post(
"/profile/user",
{
"csrf_token": res.form["csrf_token"].value,
"csrf_token": form["csrf_token"].value,
"display_name": "George Abitbol",
"action": "edit-profile",
},
headers={
"HX-Request": "true",

View file

@ -54,9 +54,10 @@ def test_photo_on_profile_edition(
):
# Add a photo
res = testclient.get("/profile/user", status=200)
res.form["photo"] = Upload("logo.jpg", jpeg_photo)
res.form["photo_delete"] = False
res = res.form.submit(name="action", value="edit")
form = res.forms["baseform"]
form["photo"] = Upload("logo.jpg", jpeg_photo)
form["photo_delete"] = False
res = form.submit(name="action", value="edit-profile")
assert ("success", "Profile updated successfully.") in res.flashes
res = res.follow()
@ -66,8 +67,9 @@ def test_photo_on_profile_edition(
# No change
res = testclient.get("/profile/user", status=200)
res.form["photo_delete"] = False
res = res.form.submit(name="action", value="edit")
form = res.forms["baseform"]
form["photo_delete"] = False
res = form.submit(name="action", value="edit-profile")
assert ("success", "Profile updated successfully.") in res.flashes
res = res.follow()
@ -77,8 +79,9 @@ def test_photo_on_profile_edition(
# Photo deletion
res = testclient.get("/profile/user", status=200)
res.form["photo_delete"] = True
res = res.form.submit(name="action", value="edit")
form = res.forms["baseform"]
form["photo_delete"] = True
res = form.submit(name="action", value="edit-profile")
assert ("success", "Profile updated successfully.") in res.flashes
res = res.follow()
@ -88,9 +91,10 @@ def test_photo_on_profile_edition(
# Photo deletion AND upload, this should never happen
res = testclient.get("/profile/user", status=200)
res.form["photo"] = Upload("logo.jpg", jpeg_photo)
res.form["photo_delete"] = True
res = res.form.submit(name="action", value="edit")
form = res.forms["baseform"]
form["photo"] = Upload("logo.jpg", jpeg_photo)
form["photo_delete"] = True
res = form.submit(name="action", value="edit-profile")
assert ("success", "Profile updated successfully.") in res.flashes
res = res.follow()
@ -105,11 +109,14 @@ def test_photo_on_profile_creation(testclient, jpeg_photo, logged_admin):
res.mustcontain(no="foobar")
res = testclient.get("/profile", status=200)
res.form["photo"] = Upload("logo.jpg", jpeg_photo)
res.form["user_name"] = "foobar"
res.form["family_name"] = "Abitbol"
res.form["emails-0"] = "george@abitbol.com"
res = res.form.submit(name="action", value="edit", status=302).follow(status=200)
form = res.forms["baseform"]
form["photo"] = Upload("logo.jpg", jpeg_photo)
form["user_name"] = "foobar"
form["family_name"] = "Abitbol"
form["emails-0"] = "george@abitbol.com"
res = form.submit(name="action", value="edit-profile", status=302).follow(
status=200
)
user = models.User.get_from_login("foobar")
assert user.photo == [jpeg_photo]
@ -122,12 +129,15 @@ def test_photo_deleted_on_profile_creation(testclient, jpeg_photo, logged_admin)
res.mustcontain(no="foobar")
res = testclient.get("/profile", status=200)
res.form["photo"] = Upload("logo.jpg", jpeg_photo)
res.form["photo_delete"] = True
res.form["user_name"] = "foobar"
res.form["family_name"] = "Abitbol"
res.form["emails-0"] = "george@abitbol.com"
res = res.form.submit(name="action", value="edit", status=302).follow(status=200)
form = res.forms["baseform"]
form["photo"] = Upload("logo.jpg", jpeg_photo)
form["photo_delete"] = True
form["user_name"] = "foobar"
form["family_name"] = "Abitbol"
form["emails-0"] = "george@abitbol.com"
res = form.submit(name="action", value="edit-profile", status=302).follow(
status=200
)
user = models.User.get_from_login("foobar")
assert user.photo == []