Merge branch '47-multiple-factor-authentication' into 'main'

Implement multiple factors authentication

Closes #47

See merge request yaal/canaille!193
This commit is contained in:
Félix Rohrlich 2024-12-10 10:32:11 +00:00
commit 57db533a17
52 changed files with 2604 additions and 27 deletions

1
.gitignore vendored
View file

@ -1,5 +1,6 @@
.env
*.sqlite
*.sqlite-journal
*.pyc
*.mo
*.prof

View file

@ -15,10 +15,10 @@ repos:
- id: end-of-file-fixer
exclude: "\\.svg$|\\.map$|\\.min\\.css$|\\.min\\.js$|\\.po$|\\.pot$"
- id: check-toml
- repo: https://github.com/PyCQA/docformatter
rev: v1.7.5
hooks:
- id: docformatter
# - repo: https://github.com/PyCQA/docformatter
# rev: v1.7.5
# hooks:
# - id: docformatter
- repo: https://github.com/rtts/djhtml
rev: 3.0.7
hooks:

View file

@ -3,6 +3,12 @@
Added
^^^^^
- Multi-factor authentication :issue:`47`
- :attr:`~canaille.core.configuration.CoreSettings.OTP_METHOD` and
:attr:`~canaille.core.configuration.CoreSettings.EMAIL_OTP` and
:attr:`~canaille.core.configuration.CoreSettings.SMS_OTP` and
:attr:`~canaille.core.configuration.CoreSettings.SMPP`
:issue:`47`
- Password compromission check :issue:`179`
- :attr:`~canaille.core.configuration.CoreSettings.ADMIN_EMAIL` and
:attr:`~canaille.core.configuration.CoreSettings.ENABLE_PASSWORD_COMPROMISSION_CHECK` and

View file

@ -2,6 +2,8 @@ import base64
import hashlib
import json
import re
from base64 import b64encode
from io import BytesIO
from flask import current_app
from flask import request
@ -66,3 +68,29 @@ class classproperty:
def __get__(self, obj, owner):
return self.f(owner)
def get_b64encoded_qr_image(data):
try:
import qrcode
except ImportError:
return None
qr = qrcode.QRCode(version=1, box_size=10, border=5)
qr.add_data(data)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
buffered = BytesIO()
img.save(buffered)
return b64encode(buffered.getvalue()).decode("utf-8")
def mask_email(email):
atpos = email.find("@")
if atpos > 0:
return email[0] + "#####" + email[atpos - 1 :]
return None
def mask_phone(phone):
return phone[0:3] + "#####" + phone[-2:]

View file

@ -1,3 +1,4 @@
import importlib.util
import os
import smtplib
import socket
@ -175,7 +176,7 @@ def validate(config, validate_remote=False):
validate_keypair(config.get("CANAILLE_OIDC"))
validate_theme(config["CANAILLE"])
validate_admin_email(config["CANAILLE"])
validate_otp_config(config["CANAILLE"])
if not validate_remote:
return
@ -184,6 +185,8 @@ def validate(config, validate_remote=False):
Backend.instance.validate(config)
if smtp_config := config["CANAILLE"]["SMTP"]:
validate_smtp_configuration(smtp_config)
if smpp_config := config["CANAILLE"]["SMPP"]:
validate_smpp_configuration(smpp_config)
def validate_keypair(config):
@ -231,6 +234,31 @@ def validate_smtp_configuration(config):
raise ConfigurationException(exc) from exc
def validate_smpp_configuration(config):
try:
import smpplib
except ImportError as exc:
raise ConfigurationException(
"You have configured a SMPP server but the 'sms' extra is not installed."
) from exc
host = config["HOST"]
port = config["PORT"]
try:
with smpplib.client.Client(host, port, allow_unknown_opt_params=True) as client:
client.connect()
if config["LOGIN"]:
client.bind_transmitter(
system_id=config["LOGIN"], password=config["PASSWORD"]
)
except smpplib.exceptions.ConnectionError as exc:
raise ConfigurationException(
f"Could not connect to the SMPP server '{host}' on port '{port}'"
) from exc
except smpplib.exceptions.UnknownCommandError as exc: # pragma: no cover
raise ConfigurationException(exc) from exc
def validate_theme(config):
if not os.path.exists(config["THEME"]) and not os.path.exists(
os.path.join(ROOT, "themes", config["THEME"])
@ -243,3 +271,25 @@ def validate_admin_email(config):
raise ConfigurationException(
"You must set an administration email if you want to check if users' passwords are compromised."
)
def validate_otp_config(config):
if (
config["OTP_METHOD"] or config["EMAIL_OTP"] or config["SMS_OTP"]
) and not importlib.util.find_spec("otpauth"): # pragma: no cover
raise ConfigurationException(
"You are trying to use OTP but the 'otp' extra is not installed."
)
if config["OTP_METHOD"] not in [None, "TOTP", "HOTP"]:
raise ConfigurationException("Invalid OTP method")
if config["EMAIL_OTP"] and not config["SMTP"]:
raise ConfigurationException(
"Cannot activate email one-time password authentication without SMTP"
)
if config["SMS_OTP"] and not config["SMPP"]:
raise ConfigurationException(
"Cannot activate sms one-time password authentication without SMPP"
)

View file

@ -14,6 +14,22 @@ class Features:
def has_password_recovery(self):
return self.app.config["CANAILLE"]["ENABLE_PASSWORD_RECOVERY"]
@property
def otp_method(self):
return self.app.config["CANAILLE"]["OTP_METHOD"]
@property
def has_otp(self):
return bool(self.app.config["CANAILLE"]["OTP_METHOD"])
@property
def has_email_otp(self):
return bool(self.app.config["CANAILLE"]["EMAIL_OTP"])
@property
def has_sms_otp(self):
return self.app.config["CANAILLE"]["SMS_OTP"]
@property
def has_registration(self):
return self.app.config["CANAILLE"]["ENABLE_REGISTRATION"]

51
canaille/app/sms.py Normal file
View file

@ -0,0 +1,51 @@
from flask import current_app
def send_sms(recipient, sender, text):
try:
import smpplib.client
import smpplib.consts
import smpplib.gsm
except ImportError as exc:
raise RuntimeError(
"You are trying to send a sms but the 'sms' extra is not installed."
) from exc
port = current_app.config["CANAILLE"]["SMPP"]["PORT"]
host = current_app.config["CANAILLE"]["SMPP"]["HOST"]
login = current_app.config["CANAILLE"]["SMPP"]["LOGIN"]
password = current_app.config["CANAILLE"]["SMPP"]["PASSWORD"]
try:
client = smpplib.client.Client(host, port, allow_unknown_opt_params=True)
client.connect()
try:
client.bind_transmitter(system_id=login, password=password)
pdu = client.send_message(
source_addr_ton=smpplib.consts.SMPP_TON_INTL,
source_addr=sender,
dest_addr_ton=smpplib.consts.SMPP_TON_INTL,
destination_addr=recipient,
short_message=bytes(text, "utf-8"),
)
current_app.logger.debug(pdu.generate())
finally:
if client.state in [
smpplib.consts.SMPP_CLIENT_STATE_BOUND_TX
]: # pragma: no cover
# if bound to transmitter
try:
client.unbind()
except smpplib.exceptions.UnknownCommandError:
try:
client.unbind()
except smpplib.exceptions.PDUError:
pass
except Exception as exc:
current_app.logger.warning(f"Could not send sms: {exc}")
return False
finally:
if client: # pragma: no branch
client.disconnect()
return True

View file

@ -4,9 +4,11 @@ import json
import typing
import click
from flask import current_app
from flask.cli import AppGroup
from flask.cli import with_appcontext
from canaille.app import models
from canaille.app.commands import with_backendcontext
from canaille.app.models import MODELS
from canaille.backends import Backend
@ -77,6 +79,8 @@ def register(cli):
@cli.command(cls=ModelCommand, factory=factory, name=name, help=command_help)
def factory_command(): ...
cli.add_command(reset_otp)
def serialize(instance):
"""Quick and dirty serialization method.
@ -281,3 +285,32 @@ def delete_factory(model):
raise click.ClickException(exc) from exc
return command
@click.command()
@with_appcontext
@with_backendcontext
@click.argument("identifier")
def reset_otp(identifier):
"""Reset one-time password authentication for a user and display the
edited user in JSON format in the standard output.
IDENTIFIER should be a user id or user_name
"""
user = Backend.instance.get(models.User, identifier)
if not user:
raise click.ClickException(f"No user with id '{identifier}'")
user.initialize_otp()
current_app.logger.security(
f"Reset one-time password authentication from CLI for {user.user_name}"
)
try:
Backend.instance.save(user)
except Exception as exc: # pragma: no cover
raise click.ClickException(exc) from exc
output = json.dumps(serialize(user))
click.echo(output)

View file

@ -28,7 +28,7 @@ class LDAPSettings(BaseModel):
For instance `ou=users,dc=mydomain,dc=tld`.
"""
USER_CLASS: str = "inetOrgPerson"
USER_CLASS: list[str] = ["inetOrgPerson"]
"""The object class to use for creating new users."""
USER_RDN: str = "uid"

View file

@ -1,4 +1,5 @@
import ldap.filter
from flask import current_app
import canaille.core.models
import canaille.oidc.models
@ -34,6 +35,11 @@ class User(canaille.core.models.User, LDAPObject):
"organization": "o",
"groups": "memberOf",
"lock_date": "pwdEndTime",
"secret_token": "oathSecret",
"last_otp_login": "oathLastLogin",
"hotp_counter": "oathHOTPCounter",
"one_time_password": "oathTokenPIN",
"one_time_password_emission_date": "oathSecretTime",
}
def match_filter(self, filter):
@ -44,6 +50,9 @@ class User(canaille.core.models.User, LDAPObject):
return super().match_filter(filter)
def save(self):
if current_app.features.has_otp and not self.secret_token:
self.initialize_otp()
group_attr = self.python_attribute_to_ldap("groups")
if group_attr not in self.changes:
return

View file

@ -3,6 +3,9 @@ import datetime
import uuid
from typing import Any
from flask import current_app
import canaille.backends.memory.models
from canaille.backends import Backend
@ -124,6 +127,13 @@ class MemoryBackend(Backend):
return results[0] if results else None
def save(self, instance):
if (
isinstance(instance, canaille.backends.memory.models.User)
and current_app.features.has_otp
and not instance.secret_token
):
instance.initialize_otp()
if not instance.id:
instance.id = str(uuid.uuid4())

View file

@ -110,6 +110,10 @@ class SQLBackend(Backend):
).scalar_one_or_none()
def save(self, instance):
# run the instance save callback if existing
if hasattr(instance, "save"):
instance.save()
instance.last_modified = datetime.datetime.now(datetime.timezone.utc).replace(
microsecond=0
)

View file

@ -2,6 +2,7 @@ import datetime
import typing
import uuid
from flask import current_app
from sqlalchemy import Boolean
from sqlalchemy import Column
from sqlalchemy import ForeignKey
@ -100,6 +101,19 @@ class User(canaille.core.models.User, Base, SqlAlchemyModel):
lock_date: Mapped[datetime.datetime] = mapped_column(
TZDateTime(timezone=True), nullable=True
)
last_otp_login: Mapped[datetime.datetime] = mapped_column(
TZDateTime(timezone=True), nullable=True
)
secret_token: Mapped[str] = mapped_column(String, nullable=True, unique=True)
hotp_counter: Mapped[int] = mapped_column(Integer, nullable=True)
one_time_password: Mapped[str] = mapped_column(String, nullable=True)
one_time_password_emission_date: Mapped[datetime.datetime] = mapped_column(
TZDateTime(timezone=True), nullable=True
)
def save(self):
if current_app.features.has_otp and not self.secret_token:
self.initialize_otp()
class Group(canaille.core.models.Group, Base, SqlAlchemyModel):

View file

@ -62,6 +62,23 @@ SECRET_KEY = "change me before you go in production"
# recovery link by email. This option is true by default.
# ENABLE_PASSWORD_RECOVERY = true
# If OTP_METHOD is defined, then users will need to authenticate themselves
# using a one-time password (OTP) via an authenticator app.
# Two options are supported : "TOTP" for time one-time password,
# and "HOTP" for HMAC-based one-time password.
# This option is deactivated by default.
# OTP_METHOD = "TOTP"
# If EMAIL_OTP is true, then users will need to authenticate themselves
# via a one-time password sent to their primary email address.
# This option is false by default.
# EMAIL_OTP = false
# If SMS_OTP is true, then users will need to authenticate themselves
# via a one-time password sent to their primary phone number.
# This option is false by default.
# SMS_OTP = false
# The validity duration of registration invitations, in seconds.
# Defaults to 2 days
# INVITATION_EXPIRATION = 172800
@ -115,6 +132,7 @@ SECRET_KEY = "change me before you go in production"
# USER_BASE = "ou=users,dc=mydomain,dc=tld"
# The object class to use for creating new users
# Note : If you plan on using TOTP/HOTP authentication, use : USER_CLASS = ["inetOrgPerson", "oathHOTPToken"]
# USER_CLASS = "inetOrgPerson"
# The attribute to identify an object in the User dn.
@ -273,3 +291,11 @@ WRITE = [
# LOGIN = ""
# PASSWORD = ""
# FROM_ADDR = "admin@mydomain.example"
# The SMPP server options. If not set, sms related features such as
# sms one-time passwords will be disabled.
[CANAILLE.SMPP]
# HOST = "localhost"
# PORT = 2775
# LOGIN = ""
# PASSWORD = ""

View file

@ -39,6 +39,24 @@ class SMTPSettings(BaseModel):
"""
class SMPPSettings(BaseModel):
"""The SMPP configuration. Belong in the ``CANAILLE.SMPP`` namespace. If
not set, sms related features such as sms one-time passwords will be disabled.
"""
HOST: str | None = "localhost"
"""The SMPP host."""
PORT: int | None = 2775
"""The SMPP port. Use 8775 for SMPP over TLS (recommended)."""
LOGIN: str | None = None
"""The SMPP login."""
PASSWORD: str | None = None
"""The SMPP password."""
class Permission(str, Enum):
"""The permissions that can be assigned to users.
@ -248,6 +266,20 @@ class CoreSettings(BaseModel):
"""If :py:data:`False`, then users cannot ask for a password recovery link
by email."""
OTP_METHOD: str = None
"""If OTP_METHOD is defined, then users will need to authenticate themselves
using a one-time password (OTP) via an authenticator app.
If set to :py:data:`TOTP`, the application will use time one-time passwords,
If set to :py:data:`HOTP`, the application will use HMAC-based one-time passwords."""
EMAIL_OTP: bool = False
"""If :py:data:`True`, then users will need to authenticate themselves
via a one-time password sent to their primary email address."""
SMS_OTP: bool = False
"""If :py:data:`True`, then users will need to authenticate themselves
via a one-time password sent to their primary phone number."""
INVITATION_EXPIRATION: int = 172800
"""The validity duration of registration invitations, in seconds.
@ -286,6 +318,13 @@ class CoreSettings(BaseModel):
enabled.
"""
SMPP: SMPPSettings | None = None
"""The settings related to SMPP configuration.
If unset, sms-related features like sms one-time passwords won't be
enabled.
"""
ACL: dict[str, ACLSettings] | None = {"DEFAULT": ACLSettings()}
"""Mapping of permission groups. See :class:`ACLSettings` for more details.

View file

@ -274,7 +274,8 @@ def registration(data=None, hash=None):
)
return redirect(url_for("core.account.index"))
if current_user():
user = current_user()
if user:
flash(
_("You are already logged in, you cannot create an account."),
"error",
@ -752,6 +753,23 @@ def profile_settings(user, edited_user):
return profile_settings_edit(user, edited_user)
if (
request.form.get("action") == "confirm-reset-otp"
and current_app.features.has_otp
):
return render_template("modals/reset-otp.html", edited_user=edited_user)
if request.form.get("action") == "reset-otp" and current_app.features.has_otp:
flash(_("One-time password authentication has been reset"), "success")
request_ip = request.remote_addr or "unknown IP"
current_app.logger.security(
f"Reset one-time password authentication for {edited_user.user_name} by {user.user_name} from {request_ip}"
)
edited_user.initialize_otp()
Backend.instance.save(edited_user)
return profile_settings_edit(user, edited_user)
abort(400, f"bad form action: {request.form.get('action')}")

View file

@ -330,3 +330,35 @@ def compromised_password_check_failure_txt(user):
hashed_password=hashed_password,
user_email=user_email,
)
@bp.route("/mail/email_otp.html")
@permissions_needed("manage_oidc")
def email_otp_html(user):
base_url = url_for("core.account.index", _external=True)
otp = "000000"
return render_template(
"mails/email_otp.html",
site_name=current_app.config["CANAILLE"]["NAME"],
site_url=base_url,
otp=otp,
logo=current_app.config["CANAILLE"]["LOGO"],
title=_("One-time password authentication on {website_name}").format(
website_name=current_app.config["CANAILLE"]["NAME"]
),
)
@bp.route("/mail/email_otp.txt")
@permissions_needed("manage_oidc")
def email_otp_txt(user):
base_url = url_for("core.account.index", _external=True)
otp = "000000"
return render_template(
"mails/email_otp.txt",
site_name=current_app.config["CANAILLE"]["NAME"],
site_url=base_url,
otp=otp,
)

View file

@ -1,3 +1,5 @@
import datetime
from flask import Blueprint
from flask import abort
from flask import current_app
@ -8,6 +10,9 @@ from flask import session
from flask import url_for
from canaille.app import build_hash
from canaille.app import get_b64encoded_qr_image
from canaille.app import mask_email
from canaille.app import mask_phone
from canaille.app.flask import smtp_needed
from canaille.app.i18n import gettext as _
from canaille.app.session import current_user
@ -15,6 +20,8 @@ from canaille.app.session import login_user
from canaille.app.session import logout_user
from canaille.app.themes import render_template
from canaille.backends import Backend
from canaille.core.endpoints.forms import TwoFactorForm
from canaille.core.models import SEND_NEW_OTP_DELAY
from ..mails import send_password_initialization_mail
from ..mails import send_password_reset_mail
@ -103,16 +110,33 @@ def password():
"password.html", form=form, username=session["attempt_login"]
)
current_app.logger.security(
f'Succeed login attempt for {session["attempt_login"]} from {request_ip}'
)
del session["attempt_login"]
login_user(user)
flash(
_("Connection successful. Welcome %(user)s", user=user.formatted_name),
"success",
)
return redirect(session.pop("redirect-after-login", url_for("core.account.index")))
otp_methods = []
if current_app.features.has_otp:
otp_methods.append(current_app.features.otp_method) # TOTP or HOTP
if current_app.features.has_email_otp:
otp_methods.append("EMAIL_OTP")
if current_app.features.has_sms_otp:
otp_methods.append("SMS_OTP")
if otp_methods:
session["remaining_otp_methods"] = otp_methods
session["attempt_login_with_correct_password"] = session.pop("attempt_login")
return redirect_to_verify_2fa(
user, otp_methods[0], request_ip, url_for("core.auth.password")
)
else:
current_app.logger.security(
f'Succeed login attempt for {session["attempt_login"]} from {request_ip}'
)
del session["attempt_login"]
login_user(user)
flash(
_("Connection successful. Welcome %(user)s", user=user.formatted_name),
"success",
)
return redirect(
session.pop("redirect-after-login", url_for("core.account.index"))
)
@bp.route("/logout")
@ -251,3 +275,253 @@ def reset(user, hash):
)
return render_template("reset-password.html", form=form, user=user, hash=hash)
@bp.route("/setup-2fa")
def setup_two_factor_auth():
if not current_app.features.has_otp:
abort(404)
if current_user():
return redirect(
url_for("core.account.profile_edition", edited_user=current_user())
)
if "attempt_login_with_correct_password" not in session:
flash(_("Cannot remember the login you attempted to sign in with"), "warning")
return redirect(url_for("core.auth.login"))
user = Backend.instance.get_user_from_login(
session["attempt_login_with_correct_password"]
)
uri = user.get_otp_authentication_setup_uri()
base64_qr_image = get_b64encoded_qr_image(uri)
return render_template(
"setup-2fa.html",
secret=user.secret_token,
qr_image=base64_qr_image,
username=user.user_name,
)
@bp.route("/verify-2fa", methods=["GET", "POST"])
def verify_two_factor_auth():
if current_user():
return redirect(
url_for("core.account.profile_edition", edited_user=current_user())
)
if (
"attempt_login_with_correct_password" not in session
or "remaining_otp_methods" not in session
or not session["remaining_otp_methods"]
):
flash(_("Cannot remember the login you attempted to sign in with"), "warning")
return redirect(url_for("core.auth.login"))
current_otp_method = session["remaining_otp_methods"][0]
if (
(current_otp_method in ["TOTP", "HOTP"] and not current_app.features.has_otp)
or (
current_otp_method == "EMAIL_OTP" and not current_app.features.has_email_otp
)
or (current_otp_method == "SMS_OTP" and not current_app.features.has_sms_otp)
):
abort(404)
form = TwoFactorForm(request.form or None)
form.render_field_macro_file = "partial/login_field.html"
if not request.form or form.form_control():
return render_template(
"verify-2fa.html",
form=form,
username=session["attempt_login_with_correct_password"],
method=current_otp_method,
)
user = Backend.instance.get_user_from_login(
session["attempt_login_with_correct_password"]
)
if form.validate() and user.is_otp_valid(form.otp.data, current_otp_method):
session["remaining_otp_methods"].pop(0)
request_ip = request.remote_addr or "unknown IP"
if session["remaining_otp_methods"]:
return redirect_to_verify_2fa(
user,
session["remaining_otp_methods"][0],
request_ip,
url_for("core.auth.verify_two_factor_auth"),
)
else:
user.last_otp_login = datetime.datetime.now(datetime.timezone.utc)
Backend.instance.save(user)
current_app.logger.security(
f'Succeed login attempt for {session["attempt_login_with_correct_password"]} from {request_ip}'
)
del session["attempt_login_with_correct_password"]
login_user(user)
flash(
_(
"Connection successful. Welcome %(user)s",
user=user.formatted_name,
),
"success",
)
return redirect(
session.pop("redirect-after-login", url_for("core.account.index"))
)
else:
flash(
"The one-time password you entered is invalid. Please try again",
"error",
)
request_ip = request.remote_addr or "unknown IP"
current_app.logger.security(
f'Failed login attempt (wrong OTP) for {session["attempt_login_with_correct_password"]} from {request_ip}'
)
return redirect(url_for("core.auth.verify_two_factor_auth"))
@bp.route("/send-mail-otp", methods=["POST"])
def send_mail_otp():
if not current_app.features.has_email_otp:
abort(404)
if current_user():
return redirect(
url_for("core.account.profile_edition", edited_user=current_user())
)
if "attempt_login_with_correct_password" not in session:
flash(_("Cannot remember the login you attempted to sign in with"), "warning")
return redirect(url_for("core.auth.login"))
user = Backend.instance.get_user_from_login(
session["attempt_login_with_correct_password"]
)
if user.can_send_new_otp():
if user.generate_and_send_otp_mail():
Backend.instance.save(user)
request_ip = request.remote_addr or "unknown IP"
current_app.logger.security(
f'Sent one-time password for {session["attempt_login_with_correct_password"]} to {user.emails[0]} from {request_ip}'
)
flash(
"Code successfully sent!",
"success",
)
else:
flash("Error while sending one-time password. Please try again.", "danger")
else:
flash(
f"Too many attempts. Please try again in {SEND_NEW_OTP_DELAY} seconds.",
"danger",
)
return redirect(url_for("core.auth.verify_two_factor_auth"))
@bp.route("/send-sms-otp", methods=["POST"])
def send_sms_otp():
if not current_app.features.has_sms_otp:
abort(404)
if current_user():
return redirect(
url_for("core.account.profile_edition", edited_user=current_user())
)
if "attempt_login_with_correct_password" not in session:
flash(_("Cannot remember the login you attempted to sign in with"), "warning")
return redirect(url_for("core.auth.login"))
user = Backend.instance.get_user_from_login(
session["attempt_login_with_correct_password"]
)
if user.can_send_new_otp():
if user.generate_and_send_otp_sms():
Backend.instance.save(user)
request_ip = request.remote_addr or "unknown IP"
current_app.logger.security(
f'Sent one-time password for {session["attempt_login_with_correct_password"]} to {user.phone_numbers[0]} from {request_ip}'
)
flash(
"Code successfully sent!",
"success",
)
else:
flash("Error while sending one-time password. Please try again.", "danger")
else:
flash(
f"Too many attempts. Please try again in {SEND_NEW_OTP_DELAY} seconds.",
"danger",
)
return redirect(url_for("core.auth.verify_two_factor_auth"))
def redirect_to_verify_2fa(user, otp_method, request_ip, fail_redirect_url):
if otp_method in ["HOTP", "TOTP"]:
if not user.last_otp_login:
flash(
"You have not enabled Two-Factor Authentication. Please enable it first to login.",
"info",
)
return redirect(url_for("core.auth.setup_two_factor_auth"))
flash(
"Please enter the one-time password from your authenticator app.",
"info",
)
return redirect(url_for("core.auth.verify_two_factor_auth"))
elif otp_method == "EMAIL_OTP":
if user.can_send_new_otp():
if user.generate_and_send_otp_mail():
Backend.instance.save(user)
flash(
f"A one-time password has been sent to your email address {mask_email(user.emails[0])}. Please enter it below to login.",
"info",
)
current_app.logger.security(
f'Sent one-time password for {session["attempt_login_with_correct_password"]} to {user.emails[0]} from {request_ip}'
)
return redirect(url_for("core.auth.verify_two_factor_auth"))
else:
flash(
"Error while sending one-time password. Please try again.", "danger"
)
return redirect(fail_redirect_url)
else:
flash(
f"Too many attempts. Please try again in {SEND_NEW_OTP_DELAY} seconds.",
"danger",
)
return redirect(fail_redirect_url)
else: # sms
if user.can_send_new_otp():
if user.generate_and_send_otp_sms():
Backend.instance.save(user)
flash(
f"A one-time password has been sent to your phone number {mask_phone(user.phone_numbers[0])}. Please enter it below to login.",
"info",
)
current_app.logger.security(
f'Sent one-time password for {session["attempt_login_with_correct_password"]} to {user.phone_numbers[0]} from {request_ip}'
)
return redirect(url_for("core.auth.verify_two_factor_auth"))
else:
flash(
"Error while sending one-time password. Please try again.",
"danger",
)
return redirect(fail_redirect_url)
else:
flash(
f"Too many attempts. Please try again in {SEND_NEW_OTP_DELAY} seconds.",
"danger",
)
return redirect(fail_redirect_url)

View file

@ -22,6 +22,7 @@ from canaille.app.i18n import gettext
from canaille.app.i18n import lazy_gettext as _
from canaille.app.i18n import native_language_name_from_code
from canaille.backends import Backend
from canaille.core.models import OTP_DIGITS
def unique_user_name(form, field):
@ -480,3 +481,18 @@ class EmailConfirmationForm(Form):
"autocorrect": "off",
},
)
class TwoFactorForm(Form):
otp = wtforms.StringField(
_("One-time password"),
validators=[
wtforms.validators.DataRequired(),
wtforms.validators.Length(min=OTP_DIGITS, max=OTP_DIGITS),
],
render_kw={
"placeholder": _("123456"),
"spellcheck": "false",
"autocorrect": "off",
},
)

View file

@ -249,3 +249,34 @@ def send_compromised_password_check_failure_mail(
html=html_body,
attachments=[(logo_cid, logo_filename, logo_raw)] if logo_filename else None,
)
def send_one_time_password_mail(mail, otp):
base_url = url_for("core.account.index", _external=True)
logo_cid, logo_filename, logo_raw = logo()
subject = _("One-time password authentication on {website_name}").format(
website_name=current_app.config["CANAILLE"]["NAME"]
)
text_body = render_template(
"mails/email_otp.txt",
site_name=current_app.config["CANAILLE"]["NAME"],
site_url=base_url,
otp=otp,
)
html_body = render_template(
"mails/email_otp.html",
site_name=current_app.config["CANAILLE"]["NAME"],
site_url=base_url,
otp=otp,
logo=f"cid:{logo_cid[1:-1]}" if logo_cid else None,
title=subject,
)
return send_email(
subject=subject,
recipient=mail,
text=text_body,
html=html_body,
attachments=[(logo_cid, logo_filename, logo_raw)] if logo_filename else None,
)

View file

@ -1,4 +1,5 @@
import datetime
import secrets
from typing import Annotated
from typing import ClassVar
@ -6,6 +7,13 @@ from flask import current_app
from canaille.backends.models import Model
from canaille.core.configuration import Permission
from canaille.core.mails import send_one_time_password_mail
from canaille.core.sms import send_one_time_password_sms
HOTP_LOOK_AHEAD_WINDOW = 10
OTP_DIGITS = 6
OTP_VALIDITY = 600
SEND_NEW_OTP_DELAY = 10
class User(Model):
@ -241,6 +249,24 @@ class User(Model):
lock_date: datetime.datetime | None = None
"""A DateTime indicating when the resource was locked."""
last_otp_login: datetime.datetime | None = None
"""A DateTime indicating when the user last logged in with a one-time password.
This attribute is currently used to check whether the user has activated one-time password authentication or not."""
secret_token: str | None = None
"""Unique token generated for each user, used for
two-factor authentication."""
hotp_counter: int | None = None
"""HMAC-based One Time Password counter, used for
two-factor authentication."""
one_time_password: str | None = None
"""One time password used for email or sms two-factor authentication."""
one_time_password_emission_date: datetime.datetime | None = None
"""A DateTime indicating when the user last emitted an email or sms one-time password."""
_readable_fields = None
_writable_fields = None
_permissions = None
@ -258,10 +284,14 @@ class User(Model):
def __getattribute__(self, name):
prefix = "can_"
if name.startswith(prefix) and name != "can_read":
return self.can(name[len(prefix) :])
return super().__getattribute__(name)
try:
return super().__getattribute__(name)
except AttributeError:
if name.startswith(prefix) and name != "can_read":
return self.can(name[len(prefix) :])
raise
def can(self, *permissions: Permission):
"""Whether or not the user has the
@ -318,6 +348,110 @@ class User(Model):
self._writable_fields |= set(details["WRITE"])
return self._writable_fields
def initialize_otp(self):
self.secret_token = secrets.token_hex(32)
self.last_otp_login = None
if current_app.features.otp_method == "HOTP":
self.hotp_counter = 1
def generate_otp(self, counter_delta=0):
import otpauth
method = current_app.features.otp_method
if method == "TOTP":
totp = otpauth.TOTP(bytes(self.secret_token, "utf-8"))
return totp.string_code(totp.generate())
elif method == "HOTP":
hotp = otpauth.HOTP(bytes(self.secret_token, "utf-8"))
return hotp.string_code(hotp.generate(self.hotp_counter + counter_delta))
else: # pragma: no cover
raise RuntimeError("Invalid one-time password method")
def generate_sms_or_mail_otp(self):
otp = string_code(secrets.randbelow(10**OTP_DIGITS), OTP_DIGITS)
self.one_time_password = otp
self.one_time_password_emission_date = datetime.datetime.now(
datetime.timezone.utc
)
return otp
def generate_and_send_otp_mail(self):
otp = self.generate_sms_or_mail_otp()
if send_one_time_password_mail(self.preferred_email, otp):
return otp
return False
def generate_and_send_otp_sms(self):
otp = self.generate_sms_or_mail_otp()
if send_one_time_password_sms(self.phone_numbers[0], otp):
return otp
return False
def get_otp_authentication_setup_uri(self):
import otpauth
method = current_app.features.otp_method
if method == "TOTP":
return otpauth.TOTP(bytes(self.secret_token, "utf-8")).to_uri(
label=self.user_name, issuer=current_app.config["CANAILLE"]["NAME"]
)
elif method == "HOTP":
return otpauth.HOTP(bytes(self.secret_token, "utf-8")).to_uri(
label=self.user_name,
issuer=current_app.config["CANAILLE"]["NAME"],
counter=self.hotp_counter,
)
else: # pragma: no cover
raise RuntimeError("Invalid one-time password method")
def is_otp_valid(self, user_otp, method):
if method == "TOTP":
return self.is_totp_valid(user_otp)
elif method == "HOTP":
return self.is_hotp_valid(user_otp)
elif method == "EMAIL_OTP" or method == "SMS_OTP":
return self.is_email_or_sms_otp_valid(user_otp)
else: # pragma: no cover
raise RuntimeError("Invalid one-time password method")
def is_totp_valid(self, user_otp):
import otpauth
return otpauth.TOTP(bytes(self.secret_token, "utf-8")).verify(user_otp)
def is_hotp_valid(self, user_otp):
import otpauth
counter = self.hotp_counter
is_valid = False
# if user token's counter is ahead of canaille's, try to catch up to it
while counter - self.hotp_counter <= HOTP_LOOK_AHEAD_WINDOW:
is_valid = otpauth.HOTP(bytes(self.secret_token, "utf-8")).verify(
user_otp, counter
)
counter += 1
if is_valid:
self.hotp_counter = counter
return True
return False
def is_email_or_sms_otp_valid(self, user_otp):
return user_otp == self.one_time_password and self.is_otp_still_valid()
def is_otp_still_valid(self):
return datetime.datetime.now(
datetime.timezone.utc
) - self.one_time_password_emission_date < datetime.timedelta(
seconds=OTP_VALIDITY
)
def can_send_new_otp(self):
return self.one_time_password_emission_date is None or (
datetime.datetime.now(datetime.timezone.utc)
- self.one_time_password_emission_date
>= datetime.timedelta(seconds=SEND_NEW_OTP_DELAY)
)
class Group(Model):
"""User model, based on the `SCIM Group schema
@ -347,3 +481,15 @@ class Group(Model):
"""
description: str | None = None
def string_code(code: int, digit: int) -> str:
"""Add leading 0 if the code length does not match the defined length.
For instance, parameter ``digit=6``, but ``code=123``, this method would
return ``000123``::
>>> otp.string_code(123)
'000123'
"""
return f"{code:0{digit}}"

15
canaille/core/sms.py Normal file
View file

@ -0,0 +1,15 @@
from flask import current_app
from canaille.app.sms import send_sms
from canaille.app.themes import render_template
def send_one_time_password_sms(phone_number, otp):
website_name = current_app.config["CANAILLE"]["NAME"]
text_body = render_template(
"sms/sms_otp.txt",
website_name=website_name,
otp=otp,
)
return send_sms(recipient=phone_number, sender=website_name, text=text_body)

View file

@ -160,6 +160,18 @@
</div>
<div class="item">
<div class="right floated content">
<div class="ui buttons">
<a class="ui button primary" href="{{ url_for("core.admin.email_otp_txt") }}">TXT</a>
<a class="ui button primary" href="{{ url_for("core.admin.email_otp_html") }}">HTML</a>
</div>
</div>
<div class="middle aligned content">
{{ _("Email one-time password") }}
</div>
</div>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,43 @@
<!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 %}One-time password authentication{% 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 %}
Someone, probably you, asked for a one-time password at {{ site_name }}. If you did not ask for this email, please ignore it. If you did, please enter the one-time password below at {{ site_name }} to complete your authentication.
{% endtrans %}
<p style="text-align: center; line-height: 2em;">{% trans %}Your one-time password{% endtrans %}:
<br><b style="font-size: 2em">{{ otp }}</b>
</p>
</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>
</tr>
</table>
</body>
</html>

View file

@ -0,0 +1,6 @@
# {% trans %}One-time password authentication{% endtrans %}
{% trans %}Please enter the one-time password below at {{ site_name }} to complete your authentication.{% endtrans %}
{% trans %}One-time password{% endtrans %}: {{ otp }}
{{ site_name }}: {{ site_url }}

View file

@ -0,0 +1,32 @@
{% extends theme('base.html') %}
{% block content %}
<div id="modal-reset-otp" class="ui warning message">
<form method="post" action="{{ request.url }}">
<input type="hidden" name="csrf_token" value="{{ request.form.get("csrf_token") }}">
<div class="ui icon header">
<i class="key icon"></i>
{% trans %}One-time password authentication reset{% endtrans %}
</div>
<div class="content">
<p>
{% if user != edited_user %}
{% trans user_name=(edited_user.formatted_name or edited_user.identifier) %}
Are you sure you want to reset one-time password (OTP) authentication for {{ user_name }} ? The user will have to perform OTP setup again at next login.
{% endtrans %}
{% else %}
{% trans %}
Are you sure you want to reset one-time password (OTP) authentication? You will have to perform OTP setup again at next login.
{% endtrans %}
{% endif %}
</p>
</div>
<div class="ui center aligned container">
<div class="ui stackable buttons">
<a class="ui cancel button" href="{{ request.url }}">{% trans %}Cancel{% endtrans %}</a>
<button type="submit" name="action" value="reset-otp" class="ui red approve button">{% trans %}Reset one-time password authentication{% endtrans %}</button>
</div>
</div>
</form>
</div>
{% endblock %}

View file

@ -6,4 +6,7 @@
{% if field.name == "password" %}
{{ fui.render_field(field, icon="key", noindicator=true, **kwargs) }}
{% endif %}
{% if field.name == "otp" %}
{{ fui.render_field(field, icon="key", noindicator=true, **kwargs) }}
{% endif %}
{% endmacro %}

View file

@ -140,6 +140,12 @@
<div class="ui right aligned container">
<div class="ui stackable buttons">
{% if features.has_otp and user.can_manage_users %}
<button type="submit" class="ui right floated basic negative button confirm" name="action" value="confirm-reset-otp" id="reset-otp" formnovalidate>
{% trans %}Reset one-time password authentication{% endtrans %}
</button>
{% endif %}
{% if features.has_account_lockability and "lock_date" in user.writable_fields and not edited_user.locked %}
<button type="submit" class="ui right floated basic negative button confirm" name="action" value="confirm-lock" id="lock" formnovalidate>
{% trans %}Lock the account{% endtrans %}

View file

@ -0,0 +1,74 @@
{% extends theme('base.html') %}
{% import 'macro/flask.html' as flask %}
{% import 'macro/form.html' as fui %}
{% import 'partial/login_field.html' as login_field %}
{% block container %}
<div class="ui container">
<div class="content">
<div class="ui clearing segment">
{% block header %}
{% if logo_url %}
<a href="{{ url_for('core.account.index') }}">
<img class="ui tiny centered image" src="{{ logo_url }}" alt="{{ website_name }}">
</a>
{% else %}
<i class="massive sign in icon image ui"></i>
{% endif %}
<h2 class="ui center aligned header">
<div class="content">
{{ _("Sign in as %(username)s", username=username) }}
</div>
<div class="sub header">{% trans %}Set up Two-Factor Authentication.{% endtrans %}</div>
</h2>
{% endblock %}
{% block messages %}
{{ flask.messages() }}
{% endblock %}
<div class="ui segment">
<h3 class="ui header">Instructions:</h3>
<ol class="ui list">
<li class="item">Install a One-Time Password (OTP) generator application on your mobile.</li>
<li class="item">Set up a new authenticator.</li>
<li class="item">Scan the QR code below and click "Continue".</li>
</ol>
<p><strong>Note:</strong> If you can't scan the QR code, try entering the secret token in your authenticator app directly.</p>
</div>
<div>
<img src="data:image/png;base64, {{ qr_image }}" alt="Secret Token" class="ui centered medium image"/>
</div>
<form class="ui segment grid action input">
<label class="ui large label token-label" for="secret">Secret token:</label>
<input type="text" id="secret" name="secret" value="{{ secret }}" readonly>
<button type="button" class="ui secondary right labeled icon button" onclick="copySecret(this)">
Copy Secret
</button>
</form>
{% block buttons %}
<div class="ui right aligned container">
<div class="ui stackable buttons">
<a type="button" class="ui right floated primary button" href="{{ url_for('core.auth.verify_two_factor_auth') }}">{{ _("Continue") }}</a>
</div>
</div>
{% endblock %}
</div>
</div>
</div>
{% endblock %}
{% block script %}
<script>
function copySecret(clickedButton) {
var copyText = document.getElementById("secret");
copyText.select();
copyText.setSelectionRange(0, 99999); /*For mobile devices*/
document.execCommand("copy");
clickedButton.textContent = "Successfully copied!";
setTimeout(() => {
clickedButton.textContent = "Copy Secret";
}, 2000);
}
</script>
{% endblock %}

View file

@ -0,0 +1 @@
{% trans %}Here is the one-time password you requested for {{ website_name }}: {{ otp }}{% endtrans %}

View file

@ -0,0 +1,79 @@
{% extends theme('base.html') %}
{% import 'macro/flask.html' as flask %}
{% import 'macro/form.html' as fui %}
{% import 'partial/login_field.html' as login_field %}
{% block container %}
<div class="ui container">
<div class="content">
<div class="ui clearing segment">
{% block header %}
{% if logo_url %}
<a href="{{ url_for('core.account.index') }}">
<img class="ui tiny centered image" src="{{ logo_url }}" alt="{{ website_name }}">
</a>
{% else %}
<i class="massive sign in icon image ui"></i>
{% endif %}
<h2 class="ui center aligned header">
<div class="content">
{{ _("Sign in as %(username)s", username=username) }}
</div>
<div class="sub header">{% trans %}One-time password authentication.{% endtrans %}</div>
</h2>
{% endblock %}
{% block messages %}
{{ flask.messages() }}
{% endblock %}
{% call fui.render_form(form, hx_boost="false") %}
{% block fields %}
{{ login_field.render_field(form.otp, class="autofocus") }}
{% endblock %}
{% block buttons %}
<div class="ui right aligned container">
<div class="ui stackable buttons">
<a type="button" class="ui right floated button" href="{{ url_for('core.auth.login') }}">{{ _("I am not %(username)s", username=username) }}</a>
<button type="submit" class="ui right floated primary button">{{ _("Verify") }}</button>
</div>
</div>
{% endblock %}
{% endcall %}
{% if method == "EMAIL_OTP" or method == "SMS_OTP" %}
{% if method == "EMAIL_OTP" %}
<form action="{{ url_for('core.auth.send_mail_otp') }}" method="post">
{% endif %}
{% if method == "SMS_OTP" %}
<form action="{{ url_for('core.auth.send_sms_otp') }}" method="post">
{% endif %}
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="send-code-button" type="submit">Didn't receive the code? Click here to send another one. <span id="countdown"></span></button>
</form>
{% endif %}
</div>
</div>
</div>
{% endblock %}
{% block script %}
<script>
countDownDuration = 10;
countdown = document.getElementById("countdown");
countdown.innerHTML = countDownDuration;
button = countdown.parentNode;
button.setAttribute("disabled", "");
x = setInterval(() => {
countDownDuration--;
if (countDownDuration <= 0) {
clearInterval(x);
countdown.innerHTML = "";
button.removeAttribute("disabled");
} else {
countdown.innerHTML = countDownDuration;
}
}, 1000);
</script>
{% endblock %}

View file

@ -78,6 +78,29 @@ footer a {
margin: 1em 0 .28571429rem 0;
}
.ui.label.token-label {
padding-left: 0;
}
.send-code-button {
background: none!important;
border: none;
padding: 0!important;
text-decoration: underline;
cursor: pointer;
color: rgba(0, 0, 0, 0.87) !important;
}
.send-code-button:disabled {
text-decoration: none;
cursor: auto;
color: rgba(0, 0, 0, 0.524) !important;
}
#countdown {
font-size: 1.5em;
}
/* Fix button appearance for semantic-ui on webkit */
[type=button] {
-webkit-appearance: none;
@ -261,4 +284,12 @@ select.ui.multiple.dropdown option[selected] {
.ui.progress {
background: #222222;
}
.send-code-button {
color: rgba(255,255,255,.87) !important;
}
.send-code-button:disabled {
text-decoration: none;
cursor: auto;
color: rgba(255, 255, 255, 0.524) !important;
}
}

View file

@ -27,6 +27,7 @@ BIND_PW = "admin"
TIMEOUT = 10
USER_BASE = "ou=users,dc=mydomain,dc=tld"
GROUP_BASE = "ou=groups,dc=mydomain,dc=tld"
USER_CLASS = ["inetOrgPerson", "oathHOTPToken"]
[CANAILLE.ACL.DEFAULT]
PERMISSIONS = ["edit_self", "use_oidc"]

View file

@ -17,6 +17,7 @@ schemas = [
"ldif/memberof-config.ldif",
"ldif/refint-config.ldif",
"ldif/ppolicy-config.ldif",
"ldif/otp-config.ldif",
"../canaille/backends/ldap/schemas/oauth2-openldap.ldif",
]

View file

@ -0,0 +1,8 @@
dn: cn=module,cn=config
cn: module
objectClass: olcModuleList
olcModuleLoad: otp
dn: olcOverlay=otp,olcDatabase={1}mdb,cn=config
objectClass: olcOverlayConfig
olcOverlay: otp

View file

@ -174,6 +174,17 @@ Password compromission check
If :attr:`password compromission check feature <canaille.core.configuration.CoreSettings.ENABLE_PASSWORD_COMPROMISSION_CHECK>` is enabled, Canaille will check for password compromise on HIBP (https://haveibeenpwned.com/) every time a new password is register. You will need to set an :attr:`admin email <canaille.core.configuration.CoreSettings.ADMIN_EMAIL>`.
.. _feature_multi_factor_authentication:
Multi-factor authentication
===========================
If the :attr:`one-time password feature <canaille.core.configuration.CoreSettings.OTP_METHOD>` is set, then users will need to authenticate themselves using a one-time password via an authenticator app.
Two options are supported : "TOTP" for time one-time password, and "HOTP" for HMAC-based one-time password.
In case of lost token, TOTP/HOTP authentication can be reset by users with :attr:`user management permission <canaille.core.configuration.Permission.MANAGE_USERS>`.
If a :class:`mail server <canaille.core.configuration.SMTPSettings>` is configured and the :attr:`email one-time password feature <canaille.core.configuration.CoreSettings.EMAIL_OTP>` is enabled, then users will need to authenticate themselves via a one-time password sent to their primary email address.
If a :class:`smpp server <canaille.core.configuration.SMPPSettings>` is configured and the :attr:`sms one-time password feature <canaille.core.configuration.CoreSettings.SMS_OTP>` is enabled, then users will need to authenticate themselves via a one-time password sent to their primary phone number.
Web interface
*************
@ -273,12 +284,14 @@ Logging
Canaille writes :attr:`logs <canaille.core.configuration.CoreSettings.LOGGING>` for every important event happening, to help administrators understand what is going on and debug funky situations.
The following security events are logged with the tag [SECURITY] for easy retrieval:
The following security events are logged with the log level "security" for easy retrieval :
- Authentication attempt
- Password update
- Email update
- Forgotten password mail sent to user
- One-time password mail sent to user
- Multi-factor authentication reset
- Token emission
- Token refresh
- Token revokation

View file

@ -58,3 +58,9 @@ For the sake of readability, it is omitted in the following examples.
.. click:: doc.commands:delete
:prog: canaille delete
:nested: full
.. _cli_reset_otp:
.. click:: doc.commands:reset-otp
:prog: canaille reset-otp
:nested: full

View file

@ -74,6 +74,7 @@ Parameters
.. autopydantic_settings:: canaille.core.configuration.CoreSettings
.. autopydantic_settings:: canaille.core.configuration.SMTPSettings
.. autopydantic_settings:: canaille.core.configuration.SMPPSettings
.. autopydantic_settings:: canaille.core.configuration.ACLSettings
.. autoclass:: canaille.core.configuration.Permission
:members:

View file

@ -49,10 +49,15 @@ It is used when the ``CANAILLE_LDAP`` configuration parameter is defined. For in
GROUP_BASE = "ou=groups,dc=mydomain,dc=tld"
If you want to use TOTP/HOTP authentication, you will need to add the ``oathHOTPToken`` class to the user :
.. code-block:: toml
USER_CLASS = ["inetOrgPerson", "oathHOTPToken"]
You can find more details on the LDAP configuration in the :class:`dedicated section <canaille.backends.ldap.configuration.LDAPSettings>`.
.. note ::
Currently, only the ``inetOrgPerson`` and ``groupOfNames`` schemas have been tested.
Currently, only the ``inetOrgPerson``, ``oathHOTPToken`` and ``groupOfNames`` schemas have been tested.
If you want to use different schemas or LDAP servers, adaptations may be needed.
Patches are welcome.
@ -108,3 +113,30 @@ You can adapt and load those configuration files with:
# Adapt those commands according to your setup
sudo ldapadd -Q -H ldapi:/// -Y EXTERNAL -f ppolicy-config.ldif
sudo ldapadd -Q -H ldapi:/// -Y EXTERNAL -f ppolicy.ldif
otp
~~~~~~~
If the `otp <https://www.openldap.org/software/man.cgi?query=slapo-otp>`_ overlay is configured, you will be able to add one-time password authentication in canaille.
Here is a configuration example compatible with canaille:
.. literalinclude :: ../../demo/ldif/otp-config.ldif
:language: ldif
:caption: otp-config.ldif
You can adapt and load this configuration file with:
.. code-block:: bash
# Adapt this command according to your setup
sudo ldapadd -Q -H ldapi:/// -Y EXTERNAL -f otp-config.ldif
You will also need to add the ``oathHOTPToken`` class to the user:
.. code-block:: toml
:caption: config.toml
[CANAILLE_LDAP]
...
USER_CLASS = ["inetOrgPerson", "oathHOTPToken"]

View file

@ -32,6 +32,8 @@ Canaille provides different package options:
- `postgresql` provides the dependencies to enable the PostgreSQL backend;
- `mysql` provides the dependencies to enable the MySQL backend;
- `sentry` provides sentry integration to watch Canaille exceptions;
- `otp` provides the dependencies to enable one-time password authentication;
- `sms` provides the dependencies to enable sms sending;
- `all` provides all the extras above.
They can be installed with:

View file

@ -1,5 +1,5 @@
[build-system]
requires = ["hatchling", "babel", "setuptools >= 50.0.0; python_version>='3.12'"]
requires = ["hatchling", "babel", "setuptools >= 50.0.0; python_version<'3.13'"]
build-backend = "hatchling.build"
[project]
@ -81,6 +81,16 @@ mysql = [
"sqlalchemy-utils >= 0.41.1",
]
otp = [
"otpauth>=2.1.1",
"pillow>=11.0.0",
"qrcode>=8.0",
]
sms = [
"smpplib>=2.2.3",
]
[project.urls]
homepage = "https://canaille.yaal.coop"
documentation = "https://canaille.readthedocs.io/en/latest/"

View file

@ -1,7 +1,7 @@
from canaille.commands import cli
def test_check_command(testclient):
def test_check_command(testclient, mock_smpp):
runner = testclient.app.test_cli_runner()
res = runner.invoke(cli, ["check"])
assert res.exit_code == 0, res.stdout
@ -14,7 +14,7 @@ def test_check_command_fail(testclient):
assert res.exit_code == 1, res.stdout
def test_check_command_no_smtp(testclient):
def test_check_command_no_smtp(testclient, mock_smpp):
testclient.app.config["CANAILLE"]["SMTP"] = None
runner = testclient.app.test_cli_runner()
res = runner.invoke(cli, ["check"])

View file

@ -0,0 +1,76 @@
import json
import logging
from unittest import mock
import pytest
from canaille.commands import cli
@pytest.mark.parametrize("otp_method", ["TOTP", "HOTP"])
def test_reset_otp_by_id(testclient, backend, caplog, user_otp, otp_method):
"""Reset one-time password authentication for a user by its id."""
testclient.app.config["CANAILLE"]["OTP_METHOD"] = otp_method
old_token = user_otp.secret_token
assert old_token is not None
assert user_otp.last_otp_login is not None
runner = testclient.app.test_cli_runner()
res = runner.invoke(
cli,
[
"reset-otp",
user_otp.id,
],
)
assert res.exit_code == 0, res.stdout
assert json.loads(res.stdout) == {
"created": mock.ANY,
"display_name": "Johnny",
"emails": [
"john@doe.test",
],
"family_name": "Doe",
"formatted_address": "1235, somewhere",
"formatted_name": "John (johnny) Doe",
"given_name": "John",
"id": user_otp.id,
"last_modified": mock.ANY,
"password": "***",
"phone_numbers": [
"555-000-000",
],
"preferred_language": "en",
"profile_url": "https://john.test",
"user_name": "user",
"hotp_counter": 1,
"secret_token": mock.ANY,
}
assert (
"canaille",
logging.SECURITY,
"Reset one-time password authentication from CLI for user",
) in caplog.record_tuples
backend.reload(user_otp)
assert user_otp.secret_token is not None
assert user_otp.secret_token != old_token
assert user_otp.last_otp_login is None
if otp_method == "HOTP":
assert user_otp.hotp_counter == 1
def test_reset_otp_unknown_id(testclient):
"""Error case for trying to reset one-time password authentication for an invalid user."""
runner = testclient.app.test_cli_runner()
res = runner.invoke(
cli,
[
"reset-otp",
"invalid",
],
)
assert res.exit_code == 1, res.stdout
assert res.stdout == "Error: No user with id 'invalid'\n"

View file

@ -142,7 +142,9 @@ def test_smtp_connection_remote_smtp_wrong_credentials(
validate(config_dict, validate_remote=True)
def test_smtp_connection_remote_smtp_no_credentials(testclient, backend, configuration):
def test_smtp_connection_remote_smtp_no_credentials(
testclient, backend, configuration, mock_smpp
):
del configuration["CANAILLE"]["SMTP"]["LOGIN"]
del configuration["CANAILLE"]["SMTP"]["PASSWORD"]
config_obj = settings_factory(configuration)
@ -229,3 +231,87 @@ def test_enable_password_compromission_check_with_and_without_admin_email(
config_obj = settings_factory(configuration)
config_dict = config_obj.model_dump()
validate(config_dict, validate_remote=False)
def test_invalid_otp_option(configuration, backend):
config_obj = settings_factory(configuration)
config_dict = config_obj.model_dump()
validate(config_dict, validate_remote=False)
with pytest.raises(
ConfigurationException,
match=r"Invalid OTP method",
):
configuration["CANAILLE"]["OTP_METHOD"] = "invalid"
config_obj = settings_factory(configuration)
config_dict = config_obj.model_dump()
validate(config_dict, validate_remote=False)
def test_email_otp_without_smtp(configuration, backend):
config_obj = settings_factory(configuration)
config_dict = config_obj.model_dump()
validate(config_dict, validate_remote=False)
with pytest.raises(
ConfigurationException,
match=r"Cannot activate email one-time password authentication without SMTP",
):
configuration["CANAILLE"]["SMTP"] = None
configuration["CANAILLE"]["EMAIL_OTP"] = True
config_obj = settings_factory(configuration)
config_dict = config_obj.model_dump()
validate(config_dict, validate_remote=False)
def test_sms_otp_without_smpp(configuration, backend):
config_obj = settings_factory(configuration)
config_dict = config_obj.model_dump()
validate(config_dict, validate_remote=False)
with pytest.raises(
ConfigurationException,
match=r"Cannot activate sms one-time password authentication without SMPP",
):
configuration["CANAILLE"]["SMPP"] = None
configuration["CANAILLE"]["SMS_OTP"] = True
config_obj = settings_factory(configuration)
config_dict = config_obj.model_dump()
validate(config_dict, validate_remote=False)
def test_smpp_connection_remote_smpp_unreachable(testclient, backend, configuration):
configuration["CANAILLE"]["SMPP"] = {
"HOST": "invalid-smpp.com",
"PORT": 2775,
"LOGIN": "user",
"PASSWORD": "user",
}
config_obj = settings_factory(configuration)
config_dict = config_obj.model_dump()
with pytest.raises(
ConfigurationException,
match=r"Could not connect to the SMPP server 'invalid-smpp.com' on port '2775'",
):
validate(config_dict, validate_remote=True)
def test_validate_without_smpp(configuration, backend, mock_smpp):
configuration["CANAILLE"]["SMPP"] = None
config_obj = settings_factory(configuration)
config_dict = config_obj.model_dump()
validate(config_dict, validate_remote=True)
def test_smpp_connection_remote_smpp_no_credentials(
testclient, backend, configuration, mock_smpp
):
del configuration["CANAILLE"]["SMPP"]["LOGIN"]
del configuration["CANAILLE"]["SMPP"]["PASSWORD"]
config_obj = settings_factory(configuration)
config_dict = config_obj.model_dump()
validate(config_dict, validate_remote=True)

View file

@ -179,6 +179,7 @@ def test_mail_debug_pages(testclient, logged_admin):
"admin/admin@admin.com/email-confirmation",
"admin@admin.com/registration",
"compromised_password_check_failure",
"email_otp",
]:
testclient.get(f"/admin/mail/{base}.html")
testclient.get(f"/admin/mail/{base}.txt")

View file

@ -16,6 +16,7 @@ def slapd_server():
"demo/ldif/memberof-config.ldif",
"demo/ldif/ppolicy-config.ldif",
"demo/ldif/ppolicy.ldif",
"demo/ldif/otp-config.ldif",
"canaille/backends/ldap/schemas/oauth2-openldap.ldif",
"demo/ldif/bootstrap-users-tree.ldif",
"demo/ldif/bootstrap-oidc-tree.ldif",
@ -38,6 +39,7 @@ def ldap_configuration(configuration, slapd_server):
"USER_FILTER": "(uid={{ login }})",
"GROUP_BASE": "ou=groups",
"TIMEOUT": 0.1,
"USER_CLASS": ["inetOrgPerson", "oathHOTPToken"],
}
yield configuration
del configuration["CANAILLE_LDAP"]

View file

@ -186,7 +186,7 @@ def test_ldap_connection_no_remote(testclient, configuration):
validate(config_dict)
def test_ldap_connection_remote(testclient, configuration, backend):
def test_ldap_connection_remote(testclient, configuration, backend, mock_smpp):
config_obj = settings_factory(configuration)
config_dict = config_obj.model_dump()
validate(config_dict, validate_remote=True)

View file

@ -1,6 +1,9 @@
import datetime
import os
from unittest.mock import patch
import pytest
import smpplib
from babel.messages.frontend import compile_catalog
from flask_webtest import TestApp
from jinja2 import FileSystemBytecodeCache
@ -132,6 +135,12 @@ def configuration(smtpd):
"PASSWORD": smtpd.config.login_password,
"FROM_ADDR": "admin@mydomain.test",
},
"SMPP": {
"HOST": "localhost",
"PORT": 2775,
"LOGIN": "user",
"PASSWORD": "user",
},
"LOGGING": {
"version": 1,
"formatters": {
@ -199,6 +208,17 @@ def user(app, backend):
backend.delete(u)
@pytest.fixture
def user_otp(app, user, backend):
user.secret_token = (
"fefe9b106b8a033d3fcb4de16ac06b2cae71c7d95a41b158c30380d1bc35b2ba"
)
user.hotp_counter = 1
user.last_otp_login = datetime.datetime(2020, 1, 1)
backend.save(user)
yield user
@pytest.fixture
def admin(app, backend):
u = models.User(
@ -234,6 +254,13 @@ def logged_user(user, testclient):
return user
@pytest.fixture
def logged_user_otp(user_otp, testclient):
with testclient.session_transaction() as sess:
sess["user_id"] = [user_otp.id]
return user_otp
@pytest.fixture
def logged_admin(admin, testclient):
with testclient.session_transaction() as sess:
@ -275,3 +302,23 @@ def bar_group(app, admin, backend):
@pytest.fixture
def jpeg_photo():
return b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x01\x01,\x01,\x00\x00\xff\xfe\x00\x13Created with GIMP\xff\xe2\x02\xb0ICC_PROFILE\x00\x01\x01\x00\x00\x02\xa0lcms\x040\x00\x00mntrRGB XYZ \x07\xe5\x00\x0c\x00\x08\x00\x0f\x00\x16\x00(acspAPPL\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xf6\xd6\x00\x01\x00\x00\x00\x00\xd3-lcms\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\rdesc\x00\x00\x01 \x00\x00\x00@cprt\x00\x00\x01`\x00\x00\x006wtpt\x00\x00\x01\x98\x00\x00\x00\x14chad\x00\x00\x01\xac\x00\x00\x00,rXYZ\x00\x00\x01\xd8\x00\x00\x00\x14bXYZ\x00\x00\x01\xec\x00\x00\x00\x14gXYZ\x00\x00\x02\x00\x00\x00\x00\x14rTRC\x00\x00\x02\x14\x00\x00\x00 gTRC\x00\x00\x02\x14\x00\x00\x00 bTRC\x00\x00\x02\x14\x00\x00\x00 chrm\x00\x00\x024\x00\x00\x00$dmnd\x00\x00\x02X\x00\x00\x00$dmdd\x00\x00\x02|\x00\x00\x00$mluc\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x0cenUS\x00\x00\x00$\x00\x00\x00\x1c\x00G\x00I\x00M\x00P\x00 \x00b\x00u\x00i\x00l\x00t\x00-\x00i\x00n\x00 \x00s\x00R\x00G\x00Bmluc\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x0cenUS\x00\x00\x00\x1a\x00\x00\x00\x1c\x00P\x00u\x00b\x00l\x00i\x00c\x00 \x00D\x00o\x00m\x00a\x00i\x00n\x00\x00XYZ \x00\x00\x00\x00\x00\x00\xf6\xd6\x00\x01\x00\x00\x00\x00\xd3-sf32\x00\x00\x00\x00\x00\x01\x0cB\x00\x00\x05\xde\xff\xff\xf3%\x00\x00\x07\x93\x00\x00\xfd\x90\xff\xff\xfb\xa1\xff\xff\xfd\xa2\x00\x00\x03\xdc\x00\x00\xc0nXYZ \x00\x00\x00\x00\x00\x00o\xa0\x00\x008\xf5\x00\x00\x03\x90XYZ \x00\x00\x00\x00\x00\x00$\x9f\x00\x00\x0f\x84\x00\x00\xb6\xc4XYZ \x00\x00\x00\x00\x00\x00b\x97\x00\x00\xb7\x87\x00\x00\x18\xd9para\x00\x00\x00\x00\x00\x03\x00\x00\x00\x02ff\x00\x00\xf2\xa7\x00\x00\rY\x00\x00\x13\xd0\x00\x00\n[chrm\x00\x00\x00\x00\x00\x03\x00\x00\x00\x00\xa3\xd7\x00\x00T|\x00\x00L\xcd\x00\x00\x99\x9a\x00\x00&g\x00\x00\x0f\\mluc\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x0cenUS\x00\x00\x00\x08\x00\x00\x00\x1c\x00G\x00I\x00M\x00Pmluc\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x0cenUS\x00\x00\x00\x08\x00\x00\x00\x1c\x00s\x00R\x00G\x00B\xff\xdb\x00C\x00\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\xff\xdb\x00C\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\xff\xc2\x00\x11\x08\x00\x01\x00\x01\x03\x01\x11\x00\x02\x11\x01\x03\x11\x01\xff\xc4\x00\x14\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\t\xff\xc4\x00\x14\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xda\x00\x0c\x03\x01\x00\x02\x10\x03\x10\x00\x00\x01\x7f\x0f\xff\xc4\x00\x14\x10\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xda\x00\x08\x01\x01\x00\x01\x05\x02\x7f\xff\xc4\x00\x14\x11\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xda\x00\x08\x01\x03\x01\x01?\x01\x7f\xff\xc4\x00\x14\x11\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xda\x00\x08\x01\x02\x01\x01?\x01\x7f\xff\xc4\x00\x14\x10\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xda\x00\x08\x01\x01\x00\x06?\x02\x7f\xff\xc4\x00\x14\x10\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xda\x00\x08\x01\x01\x00\x01?!\x7f\xff\xda\x00\x0c\x03\x01\x00\x02\x00\x03\x00\x00\x00\x10\x1f\xff\xc4\x00\x14\x11\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xda\x00\x08\x01\x03\x01\x01?\x10\x7f\xff\xc4\x00\x14\x11\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xda\x00\x08\x01\x02\x01\x01?\x10\x7f\xff\xc4\x00\x14\x10\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xda\x00\x08\x01\x01\x00\x01?\x10\x7f\xff\xd9"
pdu = None
@pytest.fixture()
def mock_smpp(testclient):
class MockSmppClient(smpplib.client.Client):
def connect(self):
pass
def bind_transmitter(self, system_id, password):
pass
def send_pdu(self, p):
global pdu
pdu = p
with patch("smpplib.client.Client", MockSmppClient):
yield

View file

@ -0,0 +1,659 @@
import datetime
import logging
import pytest
import time_machine
import tests.conftest
from canaille.app import mask_email
from canaille.app import mask_phone
from canaille.core.models import OTP_VALIDITY
from canaille.core.models import SEND_NEW_OTP_DELAY
def test_email_otp_disabled(testclient):
testclient.app.config["CANAILLE"]["EMAIL_OTP"] = None
with testclient.session_transaction() as session:
session["remaining_otp_methods"] = ["EMAIL_OTP"]
session["attempt_login_with_correct_password"] = "id"
testclient.get("/verify-2fa", status=404)
testclient.app.config["WTF_CSRF_ENABLED"] = False
testclient.post("/send-mail-otp", status=404)
def test_sms_otp_disabled(testclient):
testclient.app.config["CANAILLE"]["SMS_OTP"] = None
with testclient.session_transaction() as session:
session["remaining_otp_methods"] = ["SMS_OTP"]
session["attempt_login_with_correct_password"] = "id"
testclient.get("/verify-2fa", status=404)
testclient.app.config["WTF_CSRF_ENABLED"] = False
testclient.post("/send-sms-otp", status=404)
def test_signin_and_out_with_email_otp(smtpd, testclient, backend, user, caplog):
testclient.app.config["CANAILLE"]["EMAIL_OTP"] = True
with testclient.session_transaction() as session:
assert not session.get("user_id")
res = testclient.get("/login", status=200)
res.form["login"] = "user"
res = res.form.submit(status=302)
res = res.follow(status=200)
with testclient.session_transaction() as session:
assert "user" == session.get("attempt_login")
res.form["password"] = "correct horse battery staple"
res = res.form.submit(status=302)
assert (
"info",
"A one-time password has been sent to your email address j#####n@doe.test. Please enter it below to login.",
) in res.flashes
assert (
"canaille",
logging.SECURITY,
"Sent one-time password for user to john@doe.test from unknown IP",
) in caplog.record_tuples
res = res.follow(status=200)
with testclient.session_transaction() as session:
assert "attempt_login" not in session
assert "user" == session.get("attempt_login_with_correct_password")
res = testclient.get("/verify-2fa")
backend.reload(user)
otp = user.one_time_password
assert len(smtpd.messages) == 1
email_content = str(smtpd.messages[0].get_payload()[0]).replace("=\n", "")
assert otp in email_content
main_form = res.forms[0]
main_form["otp"] = otp
res = main_form.submit(status=302)
assert (
"success",
"Connection successful. Welcome John (johnny) Doe",
) in res.flashes
assert (
"canaille",
logging.SECURITY,
"Succeed login attempt for user from unknown IP",
) in caplog.record_tuples
res = res.follow(status=302)
res = res.follow(status=200)
with testclient.session_transaction() as session:
assert [user.id] == session.get("user_id")
assert "attempt_login" not in session
assert "attempt_login_with_correct_password" not in session
res = testclient.get("/login", status=302)
res = testclient.get("/logout")
assert (
"success",
"You have been disconnected. See you next time John (johnny) Doe",
) in res.flashes
assert (
"canaille",
logging.SECURITY,
"Logout user from unknown IP",
) in caplog.record_tuples
res = res.follow(status=302)
res = res.follow(status=200)
def test_signin_with_email_otp_failed_to_send_code(testclient, user):
testclient.app.config["CANAILLE"]["EMAIL_OTP"] = True
testclient.app.config["CANAILLE"]["SMTP"]["HOST"] = "invalid host"
with testclient.session_transaction() as session:
assert not session.get("user_id")
res = testclient.get("/login", status=200)
res.form["login"] = "user"
res = res.form.submit(status=302)
res = res.follow(status=200)
with testclient.session_transaction() as session:
assert "user" == session.get("attempt_login")
res.form["password"] = "correct horse battery staple"
res = res.form.submit(status=302)
assert (
"danger",
"Error while sending one-time password. Please try again.",
) in res.flashes
assert res.location == "/password"
def test_signin_wrong_email_otp(testclient, user, caplog):
testclient.app.config["CANAILLE"]["EMAIL_OTP"] = True
with testclient.session_transaction() as session:
assert not session.get("user_id")
res = testclient.get("/login", status=200)
res.form["login"] = "user"
res = res.form.submit(status=302)
res = res.follow(status=200)
res.form["password"] = "correct horse battery staple"
res = res.form.submit(status=302)
res = res.follow(status=200)
main_form = res.forms[0]
main_form["otp"] = 111111
res = main_form.submit()
assert (
"error",
"The one-time password you entered is invalid. Please try again",
) in res.flashes
assert (
"canaille",
logging.SECURITY,
"Failed login attempt (wrong OTP) for user from unknown IP",
) in caplog.record_tuples
def test_signin_expired_email_otp(testclient, user, caplog):
testclient.app.config["CANAILLE"]["EMAIL_OTP"] = True
with time_machine.travel("2020-01-01 01:00:00+00:00", tick=False) as traveller:
with testclient.session_transaction() as session:
assert not session.get("user_id")
res = testclient.get("/login", status=200)
res.form["login"] = "user"
res = res.form.submit(status=302)
res = res.follow(status=200)
res.form["password"] = "correct horse battery staple"
res = res.form.submit(status=302)
res = res.follow(status=200)
main_form = res.forms[0]
main_form["otp"] = user.generate_and_send_otp_mail()
traveller.shift(datetime.timedelta(seconds=OTP_VALIDITY))
res = main_form.submit()
assert (
"error",
"The one-time password you entered is invalid. Please try again",
) in res.flashes
assert (
"canaille",
logging.SECURITY,
"Failed login attempt (wrong OTP) for user from unknown IP",
) in caplog.record_tuples
def test_signin_expired_sms_otp(testclient, user, caplog, mock_smpp):
testclient.app.config["CANAILLE"]["SMS_OTP"] = True
with time_machine.travel("2020-01-01 01:00:00+00:00", tick=False) as traveller:
with testclient.session_transaction() as session:
assert not session.get("user_id")
res = testclient.get("/login", status=200)
res.form["login"] = "user"
res = res.form.submit(status=302)
res = res.follow(status=200)
res.form["password"] = "correct horse battery staple"
res = res.form.submit(status=302)
res = res.follow(status=200)
main_form = res.forms[0]
otp = user.generate_and_send_otp_sms()
main_form["otp"] = otp
assert otp in str(tests.conftest.pdu.generate())
traveller.shift(datetime.timedelta(seconds=OTP_VALIDITY))
res = main_form.submit()
assert (
"error",
"The one-time password you entered is invalid. Please try again",
) in res.flashes
assert (
"canaille",
logging.SECURITY,
"Failed login attempt (wrong OTP) for user from unknown IP",
) in caplog.record_tuples
def test_signin_and_out_with_sms_otp(testclient, backend, user, caplog, mock_smpp):
testclient.app.config["CANAILLE"]["SMS_OTP"] = True
with testclient.session_transaction() as session:
assert not session.get("user_id")
res = testclient.get("/login", status=200)
res.form["login"] = "user"
res = res.form.submit(status=302)
res = res.follow(status=200)
with testclient.session_transaction() as session:
assert "user" == session.get("attempt_login")
res.form["password"] = "correct horse battery staple"
res = res.form.submit(status=302)
assert (
"info",
"A one-time password has been sent to your phone number 555#####00. Please enter it below to login.",
) in res.flashes
assert (
"canaille",
logging.SECURITY,
"Sent one-time password for user to 555-000-000 from unknown IP",
) in caplog.record_tuples
res = res.follow(status=200)
with testclient.session_transaction() as session:
assert "attempt_login" not in session
assert "user" == session.get("attempt_login_with_correct_password")
res = testclient.get("/verify-2fa")
backend.reload(user)
otp = user.one_time_password
main_form = res.forms[0]
main_form["otp"] = otp
assert otp in str(tests.conftest.pdu.generate())
res = main_form.submit(status=302)
assert (
"success",
"Connection successful. Welcome John (johnny) Doe",
) in res.flashes
assert (
"canaille",
logging.SECURITY,
"Succeed login attempt for user from unknown IP",
) in caplog.record_tuples
res = res.follow(status=302)
res = res.follow(status=200)
with testclient.session_transaction() as session:
assert [user.id] == session.get("user_id")
assert "attempt_login" not in session
assert "attempt_login_with_correct_password" not in session
res = testclient.get("/login", status=302)
res = testclient.get("/logout")
assert (
"success",
"You have been disconnected. See you next time John (johnny) Doe",
) in res.flashes
assert (
"canaille",
logging.SECURITY,
"Logout user from unknown IP",
) in caplog.record_tuples
res = res.follow(status=302)
res = res.follow(status=200)
def test_signin_with_sms_otp_failed_to_send_code(testclient, user):
testclient.app.config["CANAILLE"]["SMS_OTP"] = True
testclient.app.config["CANAILLE"]["SMPP"]["HOST"] = "invalid host"
with testclient.session_transaction() as session:
assert not session.get("user_id")
res = testclient.get("/login", status=200)
res.form["login"] = "user"
res = res.form.submit(status=302)
res = res.follow(status=200)
with testclient.session_transaction() as session:
assert "user" == session.get("attempt_login")
res.form["password"] = "correct horse battery staple"
res = res.form.submit(status=302)
assert (
"danger",
"Error while sending one-time password. Please try again.",
) in res.flashes
assert res.location == "/password"
def test_signin_wrong_sms_otp(testclient, user, caplog, mock_smpp):
testclient.app.config["CANAILLE"]["SMS_OTP"] = True
with testclient.session_transaction() as session:
assert not session.get("user_id")
res = testclient.get("/login", status=200)
res.form["login"] = "user"
res = res.form.submit(status=302)
res = res.follow(status=200)
res.form["password"] = "correct horse battery staple"
res = res.form.submit(status=302)
res = res.follow(status=200)
main_form = res.forms[0]
main_form["otp"] = 111111
res = main_form.submit()
assert (
"error",
"The one-time password you entered is invalid. Please try again",
) in res.flashes
assert (
"canaille",
logging.SECURITY,
"Failed login attempt (wrong OTP) for user from unknown IP",
) in caplog.record_tuples
@pytest.mark.parametrize("otp_method", ["EMAIL_OTP", "SMS_OTP"])
def test_verify_mail_or_sms_otp_page_without_signin_in_redirects_to_login_page(
testclient, user, otp_method
):
testclient.app.config["CANAILLE"][otp_method] = True
res = testclient.get("/verify-2fa", status=302)
assert res.location == "/login"
assert res.flashes == [
("warning", "Cannot remember the login you attempted to sign in with")
]
@pytest.mark.parametrize("otp_method", ["EMAIL_OTP", "SMS_OTP"])
def test_verify_mail_or_sms_otp_page_already_logged_in(
testclient, logged_user, otp_method
):
testclient.app.config["CANAILLE"][otp_method] = True
res = testclient.get("/verify-2fa", status=302)
assert res.location == "/profile/user"
def test_mask_email():
email = "foo@bar.com"
assert mask_email(email) == "f#####o@bar.com"
email = "hello"
assert mask_email(email) is None
def test_mask_phone_number():
phone = "+33123456789"
assert mask_phone(phone) == "+33#####89"
@pytest.mark.parametrize(
"otp_method,new_otp_path",
[("EMAIL_OTP", "/send-mail-otp"), ("SMS_OTP", "/send-sms-otp")],
)
def test_send_new_mail_or_sms_otp_without_signin_in_redirects_to_login_page(
testclient, user, otp_method, new_otp_path
):
testclient.app.config["CANAILLE"][otp_method] = True
testclient.app.config["WTF_CSRF_ENABLED"] = False
res = testclient.post(new_otp_path, status=302)
assert res.location == "/login"
assert res.flashes == [
("warning", "Cannot remember the login you attempted to sign in with")
]
@pytest.mark.parametrize(
"otp_method,new_otp_path",
[("EMAIL_OTP", "/send-mail-otp"), ("SMS_OTP", "/send-sms-otp")],
)
def test_send_new_mail_or_sms_otp_already_logged_in(
testclient, logged_user, otp_method, new_otp_path
):
testclient.app.config["CANAILLE"][otp_method] = True
testclient.app.config["WTF_CSRF_ENABLED"] = False
res = testclient.post(new_otp_path, status=302)
assert res.location == "/profile/user"
def test_send_new_mail_otp(smtpd, testclient, backend, user, caplog):
testclient.app.config["CANAILLE"]["EMAIL_OTP"] = True
testclient.app.config["WTF_CSRF_ENABLED"] = False
with testclient.session_transaction() as session:
session["attempt_login_with_correct_password"] = user.user_name
res = testclient.post("/send-mail-otp", status=302)
backend.reload(user)
otp = user.one_time_password
assert len(smtpd.messages) == 1
email_content = str(smtpd.messages[0].get_payload()[0]).replace("=\n", "")
assert otp in email_content
assert (
"success",
"Code successfully sent!",
) in res.flashes
assert (
"canaille",
logging.SECURITY,
"Sent one-time password for user to john@doe.test from unknown IP",
) in caplog.record_tuples
assert res.location == "/verify-2fa"
def test_send_mail_otp_multiple_attempts(testclient, backend, user, caplog):
testclient.app.config["CANAILLE"]["EMAIL_OTP"] = True
testclient.app.config["WTF_CSRF_ENABLED"] = False
with time_machine.travel("2020-01-01 01:00:00+00:00", tick=False) as traveller:
with testclient.session_transaction() as session:
session["attempt_login_with_correct_password"] = user.user_name
res = testclient.post("/send-mail-otp", status=302)
assert res.location == "/verify-2fa"
traveller.shift(datetime.timedelta(seconds=SEND_NEW_OTP_DELAY - 1))
res = testclient.post("/send-mail-otp", status=302)
assert (
"danger",
f"Too many attempts. Please try again in {SEND_NEW_OTP_DELAY} seconds.",
) in res.flashes
assert res.location == "/verify-2fa"
traveller.shift(datetime.timedelta(seconds=1))
res = testclient.post("/send-mail-otp", status=302)
assert (
"success",
"Code successfully sent!",
) in res.flashes
def test_send_new_mail_otp_failed(testclient, user):
testclient.app.config["CANAILLE"]["EMAIL_OTP"] = True
testclient.app.config["CANAILLE"]["SMTP"]["HOST"] = "invalid host"
testclient.app.config["WTF_CSRF_ENABLED"] = False
with testclient.session_transaction() as session:
session["attempt_login_with_correct_password"] = user.user_name
res = testclient.post("/send-mail-otp", status=302)
assert (
"danger",
"Error while sending one-time password. Please try again.",
) in res.flashes
assert res.location == "/verify-2fa"
def test_send_new_sms_otp(testclient, backend, user, caplog, mock_smpp):
testclient.app.config["CANAILLE"]["SMS_OTP"] = True
testclient.app.config["WTF_CSRF_ENABLED"] = False
with testclient.session_transaction() as session:
session["attempt_login_with_correct_password"] = user.user_name
res = testclient.post("/send-sms-otp", status=302)
backend.reload(user)
otp = user.one_time_password
assert otp in str(tests.conftest.pdu.generate())
assert (
"success",
"Code successfully sent!",
) in res.flashes
assert (
"canaille",
logging.SECURITY,
"Sent one-time password for user to 555-000-000 from unknown IP",
) in caplog.record_tuples
assert res.location == "/verify-2fa"
def test_send_sms_otp_multiple_attempts(testclient, backend, user, caplog, mock_smpp):
testclient.app.config["CANAILLE"]["SMS_OTP"] = True
testclient.app.config["WTF_CSRF_ENABLED"] = False
with time_machine.travel("2020-01-01 01:00:00+00:00", tick=False) as traveller:
with testclient.session_transaction() as session:
session["attempt_login_with_correct_password"] = user.user_name
res = testclient.post("/send-sms-otp", status=302)
assert res.location == "/verify-2fa"
traveller.shift(datetime.timedelta(seconds=SEND_NEW_OTP_DELAY - 1))
res = testclient.post("/send-sms-otp", status=302)
assert (
"danger",
f"Too many attempts. Please try again in {SEND_NEW_OTP_DELAY} seconds.",
) in res.flashes
assert res.location == "/verify-2fa"
traveller.shift(datetime.timedelta(seconds=1))
res = testclient.post("/send-sms-otp", status=302)
assert (
"success",
"Code successfully sent!",
) in res.flashes
def test_send_new_sms_otp_failed(testclient, user):
testclient.app.config["CANAILLE"]["SMS_OTP"] = True
testclient.app.config["CANAILLE"]["SMPP"]["HOST"] = "invalid host"
testclient.app.config["WTF_CSRF_ENABLED"] = False
with testclient.session_transaction() as session:
session["attempt_login_with_correct_password"] = user.user_name
res = testclient.post("/send-sms-otp", status=302)
assert (
"danger",
"Error while sending one-time password. Please try again.",
) in res.flashes
assert res.location == "/verify-2fa"
def test_signin_with_multiple_otp_methods(
smtpd, testclient, backend, user_otp, caplog, mock_smpp
):
testclient.app.config["CANAILLE"]["OTP_METHOD"] = "HOTP"
testclient.app.config["CANAILLE"]["EMAIL_OTP"] = True
testclient.app.config["CANAILLE"]["SMS_OTP"] = True
with testclient.session_transaction() as session:
assert not session.get("user_id")
res = testclient.get("/login", status=200)
res.form["login"] = "user"
res = res.form.submit(status=302)
res = res.follow(status=200)
with testclient.session_transaction() as session:
assert "user" == session.get("attempt_login")
res.form["password"] = "correct horse battery staple"
res = res.form.submit(status=302).follow(status=200)
with testclient.session_transaction() as session:
assert "attempt_login" not in session
assert "user" == session.get("attempt_login_with_correct_password")
# TOTP/HOTP
res = testclient.get("/verify-2fa")
res.form["otp"] = user_otp.generate_otp()
res = res.form.submit(status=302).follow(status=200)
# EMAIL_OTP
res = testclient.get("/verify-2fa")
backend.reload(user_otp)
otp = user_otp.one_time_password
main_form = res.forms[0]
main_form["otp"] = otp
res = main_form.submit(status=302).follow(status=200)
# SMS_OTP
res = testclient.get("/verify-2fa")
backend.reload(user_otp)
otp = user_otp.one_time_password
main_form = res.forms[0]
main_form["otp"] = otp
res = main_form.submit(status=302).follow(status=302).follow(status=200)
with testclient.session_transaction() as session:
assert [user_otp.id] == session.get("user_id")
assert "attempt_login" not in session
assert "attempt_login_with_correct_password" not in session
res = testclient.get("/login", status=302)
def test_send_password_form_multiple_times(smtpd, testclient, backend, user, caplog):
testclient.app.config["CANAILLE"]["EMAIL_OTP"] = True
res = testclient.get("/login", status=200)
res.form["login"] = "user"
res = res.form.submit(status=302)
res = res.follow(status=200)
res.form["password"] = "correct horse battery staple"
res = res.form.submit(status=302)
with testclient.session_transaction() as session:
session["attempt_login"] = user.user_name
del session["attempt_login_with_correct_password"]
res = testclient.get("/password", status=200)
res.form["password"] = "correct horse battery staple"
res = res.form.submit(status=302)
assert (
"danger",
f"Too many attempts. Please try again in {SEND_NEW_OTP_DELAY} seconds.",
) in res.flashes
assert res.location == "/password"

View file

@ -2,6 +2,7 @@ import datetime
import logging
from unittest import mock
import pytest
from flask import current_app
from flask import g
@ -733,3 +734,32 @@ def test_edition_invalid_group(testclient, logged_admin, user, foo_group):
res = res.form.submit(name="action", value="edit-settings")
assert res.flashes == [("error", "Profile edition failed.")]
res.mustcontain("Invalid choice(s): one or more data inputs could not be coerced.")
@pytest.mark.parametrize("otp_method", ["TOTP", "HOTP"])
def test_account_reset_otp(
testclient, backend, caplog, logged_admin, user_otp, otp_method
):
testclient.app.config["CANAILLE"]["OTP_METHOD"] = otp_method
old_token = user_otp.secret_token
assert old_token is not None
assert user_otp.last_otp_login is not None
res = testclient.get("/profile/user/settings")
res.mustcontain("Reset one-time password authentication")
res = res.form.submit(name="action", value="confirm-reset-otp")
res = res.form.submit(name="action", value="reset-otp")
user = backend.get(models.User, id=user_otp.id)
assert user.secret_token is not None
assert user.secret_token != old_token
assert user.last_otp_login is None
if otp_method == "HOTP":
assert user.hotp_counter == 1
res.mustcontain("One-time password authentication has been reset")
assert (
"canaille",
logging.SECURITY,
"Reset one-time password authentication for user by admin from unknown IP",
) in caplog.record_tuples

View file

@ -0,0 +1,384 @@
import datetime
import logging
import pytest
import time_machine
from canaille.app import models
from canaille.core.models import HOTP_LOOK_AHEAD_WINDOW
def test_otp_disabled(testclient):
testclient.app.config["CANAILLE"]["OTP_METHOD"] = None
with testclient.session_transaction() as session:
session["remaining_otp_methods"] = ["TOTP"]
session["attempt_login_with_correct_password"] = "id"
testclient.get("/setup-2fa", status=404)
testclient.get("/verify-2fa", status=404)
@pytest.mark.parametrize("otp_method", ["TOTP", "HOTP"])
def test_signin_and_out_with_otp(testclient, user_otp, caplog, otp_method):
testclient.app.config["CANAILLE"]["OTP_METHOD"] = otp_method
with testclient.session_transaction() as session:
assert not session.get("user_id")
res = testclient.get("/login", status=200)
res.form["login"] = "user"
res = res.form.submit(status=302)
res = res.follow(status=200)
with testclient.session_transaction() as session:
assert "user" == session.get("attempt_login")
res.form["password"] = "correct horse battery staple"
res = res.form.submit(status=302)
assert (
"info",
"Please enter the one-time password from your authenticator app.",
) in res.flashes
res = res.follow(status=200)
with testclient.session_transaction() as session:
assert "attempt_login" not in session
assert "user" == session.get("attempt_login_with_correct_password")
res = testclient.get("/verify-2fa")
res.form["otp"] = user_otp.generate_otp()
res = res.form.submit(status=302)
assert (
"success",
"Connection successful. Welcome John (johnny) Doe",
) in res.flashes
assert (
"canaille",
logging.SECURITY,
"Succeed login attempt for user from unknown IP",
) in caplog.record_tuples
res = res.follow(status=302)
res = res.follow(status=200)
with testclient.session_transaction() as session:
assert [user_otp.id] == session.get("user_id")
assert "attempt_login" not in session
assert "attempt_login_with_correct_password" not in session
res = testclient.get("/login", status=302)
res = testclient.get("/logout")
assert (
"success",
"You have been disconnected. See you next time John (johnny) Doe",
) in res.flashes
assert (
"canaille",
logging.SECURITY,
"Logout user from unknown IP",
) in caplog.record_tuples
res = res.follow(status=302)
res = res.follow(status=200)
@pytest.mark.parametrize("otp_method", ["TOTP", "HOTP"])
def test_signin_wrong_otp(testclient, user_otp, caplog, otp_method):
testclient.app.config["CANAILLE"]["OTP_METHOD"] = otp_method
with testclient.session_transaction() as session:
assert not session.get("user_id")
res = testclient.get("/login", status=200)
res.form["login"] = "user"
res = res.form.submit(status=302)
res = res.follow(status=200)
res.form["password"] = "correct horse battery staple"
res = res.form.submit(status=302)
res = res.follow(status=200)
res.form["otp"] = 111111
res = res.form.submit()
assert (
"error",
"The one-time password you entered is invalid. Please try again",
) in res.flashes
assert (
"canaille",
logging.SECURITY,
"Failed login attempt (wrong OTP) for user from unknown IP",
) in caplog.record_tuples
def test_signin_expired_totp(testclient, user_otp, caplog):
testclient.app.config["CANAILLE"]["OTP_METHOD"] = "TOTP"
with time_machine.travel("2020-01-01 01:00:00+00:00", tick=False) as traveller:
with testclient.session_transaction() as session:
assert not session.get("user_id")
res = testclient.get("/login", status=200)
res.form["login"] = "user"
res = res.form.submit(status=302)
res = res.follow(status=200)
res.form["password"] = "correct horse battery staple"
res = res.form.submit(status=302)
res = res.follow(status=200)
res.form["otp"] = user_otp.generate_otp()
traveller.shift(datetime.timedelta(seconds=30))
res = res.form.submit()
assert (
"error",
"The one-time password you entered is invalid. Please try again",
) in res.flashes
assert (
"canaille",
logging.SECURITY,
"Failed login attempt (wrong OTP) for user from unknown IP",
) in caplog.record_tuples
@pytest.mark.parametrize("otp_method", ["TOTP", "HOTP"])
def test_new_user_setup_otp(testclient, backend, caplog, otp_method):
testclient.app.config["CANAILLE"]["OTP_METHOD"] = otp_method
u = models.User(
formatted_name="Otp User",
family_name="Otp",
user_name="otp",
emails=["john@doe.test"],
password="correct horse battery staple",
)
backend.save(u)
assert u.secret_token is not None
if otp_method == "HOTP":
assert u.hotp_counter == 1
with testclient.session_transaction() as session:
assert not session.get("user_id")
res = testclient.get("/login", status=200)
res.form["login"] = "otp"
res = res.form.submit(status=302)
res = res.follow(status=200)
res.form["password"] = "correct horse battery staple"
res = res.form.submit(status=302)
assert res.location == "/setup-2fa"
assert (
"info",
"You have not enabled Two-Factor Authentication. Please enable it first to login.",
) in res.flashes
res = testclient.get("/setup-2fa", status=200)
assert u.secret_token == res.form["secret"].value
res = testclient.get("/verify-2fa", status=200)
res.form["otp"] = u.generate_otp()
res = res.form.submit(status=302)
assert (
"success",
"Connection successful. Welcome Otp User",
) in res.flashes
assert (
"canaille",
logging.SECURITY,
"Succeed login attempt for otp from unknown IP",
) in caplog.record_tuples
res = res.follow(status=302)
res = res.follow(status=200)
with testclient.session_transaction() as session:
assert [u.id] == session.get("user_id")
assert "attempt_login" not in session
assert "attempt_login_with_correct_password" not in session
res = testclient.get("/login", status=302)
backend.delete(u)
@pytest.mark.parametrize("otp_method", ["TOTP", "HOTP"])
def test_verify_otp_page_without_signin_in_redirects_to_login_page(
testclient, user_otp, otp_method
):
testclient.app.config["CANAILLE"]["OTP_METHOD"] = otp_method
res = testclient.get("/verify-2fa", status=302)
assert res.location == "/login"
assert res.flashes == [
("warning", "Cannot remember the login you attempted to sign in with")
]
@pytest.mark.parametrize("otp_method", ["TOTP", "HOTP"])
def test_setup_otp_page_without_signin_in_redirects_to_login_page(
testclient, user_otp, otp_method
):
testclient.app.config["CANAILLE"]["OTP_METHOD"] = otp_method
res = testclient.get("/setup-2fa", status=302)
assert res.location == "/login"
assert res.flashes == [
("warning", "Cannot remember the login you attempted to sign in with")
]
@pytest.mark.parametrize("otp_method", ["TOTP", "HOTP"])
def test_verify_otp_page_already_logged_in(testclient, logged_user_otp, otp_method):
testclient.app.config["CANAILLE"]["OTP_METHOD"] = otp_method
res = testclient.get("/verify-2fa", status=302)
assert res.location == "/profile/user"
def test_signin_multiple_attempts_doesnt_desynchronize_hotp(
testclient, user_otp, caplog
):
testclient.app.config["CANAILLE"]["OTP_METHOD"] = "HOTP"
with testclient.session_transaction() as session:
assert not session.get("user_id")
res = testclient.get("/login", status=200)
res.form["login"] = "user"
res = res.form.submit(status=302)
res = res.follow(status=200)
with testclient.session_transaction() as session:
assert "user" == session.get("attempt_login")
res.form["password"] = "correct horse battery staple"
res = res.form.submit(status=302)
res = res.follow(status=200)
with testclient.session_transaction() as session:
assert "attempt_login" not in session
assert "user" == session.get("attempt_login_with_correct_password")
res = testclient.get("/verify-2fa")
for _x in range(3):
res.form["otp"] = "111111"
res = res.form.submit(status=302).follow()
res.form["otp"] = user_otp.generate_otp()
res = res.form.submit(status=302)
assert (
"success",
"Connection successful. Welcome John (johnny) Doe",
) in res.flashes
assert (
"canaille",
logging.SECURITY,
"Succeed login attempt for user from unknown IP",
) in caplog.record_tuples
res = res.follow(status=302)
res = res.follow(status=200)
@pytest.mark.parametrize("otp_method", ["TOTP", "HOTP"])
def test_setup_otp_page_already_logged_in(testclient, logged_user_otp, otp_method):
testclient.app.config["CANAILLE"]["OTP_METHOD"] = otp_method
res = testclient.get("/setup-2fa", status=302)
assert res.location == "/profile/user"
def test_signin_inside_hotp_look_ahead_window(testclient, backend, user_otp, caplog):
testclient.app.config["CANAILLE"]["OTP_METHOD"] = "HOTP"
with testclient.session_transaction() as session:
assert not session.get("user_id")
assert user_otp.hotp_counter == 1
res = testclient.get("/login", status=200)
res.form["login"] = "user"
res = res.form.submit(status=302)
res = res.follow(status=200)
with testclient.session_transaction() as session:
assert "user" == session.get("attempt_login")
res.form["password"] = "correct horse battery staple"
res = res.form.submit(status=302)
res = res.follow(status=200)
with testclient.session_transaction() as session:
assert "attempt_login" not in session
assert "user" == session.get("attempt_login_with_correct_password")
res = testclient.get("/verify-2fa")
res.form["otp"] = user_otp.generate_otp(HOTP_LOOK_AHEAD_WINDOW)
res = res.form.submit(status=302)
assert (
"success",
"Connection successful. Welcome John (johnny) Doe",
) in res.flashes
assert (
"canaille",
logging.SECURITY,
"Succeed login attempt for user from unknown IP",
) in caplog.record_tuples
res = res.follow(status=302)
res = res.follow(status=200)
user = backend.get(models.User, id=user_otp.id)
assert user.hotp_counter == HOTP_LOOK_AHEAD_WINDOW + 2
def test_signin_outside_hotp_look_ahead_window(testclient, backend, user_otp, caplog):
testclient.app.config["CANAILLE"]["OTP_METHOD"] = "HOTP"
with testclient.session_transaction() as session:
assert not session.get("user_id")
assert user_otp.hotp_counter == 1
res = testclient.get("/login", status=200)
res.form["login"] = "user"
res = res.form.submit(status=302)
res = res.follow(status=200)
with testclient.session_transaction() as session:
assert "user" == session.get("attempt_login")
res.form["password"] = "correct horse battery staple"
res = res.form.submit(status=302)
res = res.follow(status=200)
with testclient.session_transaction() as session:
assert "attempt_login" not in session
assert "user" == session.get("attempt_login_with_correct_password")
res = testclient.get("/verify-2fa")
res.form["otp"] = user_otp.generate_otp(HOTP_LOOK_AHEAD_WINDOW + 1)
res = res.form.submit(status=302)
assert (
"error",
"The one-time password you entered is invalid. Please try again",
) in res.flashes
assert (
"canaille",
logging.SECURITY,
"Failed login attempt (wrong OTP) for user from unknown IP",
) in caplog.record_tuples
user = backend.get(models.User, id=user_otp.id)
assert user.hotp_counter == 1

112
uv.lock
View file

@ -153,6 +153,11 @@ mysql = [
oidc = [
{ name = "authlib" },
]
otp = [
{ name = "otpauth" },
{ name = "pillow" },
{ name = "qrcode" },
]
postgresql = [
{ name = "passlib" },
{ name = "sqlalchemy", extra = ["postgresql-psycopg2binary"] },
@ -162,6 +167,9 @@ postgresql = [
sentry = [
{ name = "sentry-sdk" },
]
sms = [
{ name = "smpplib" },
]
sqlite = [
{ name = "passlib" },
{ name = "sqlalchemy" },
@ -215,15 +223,19 @@ requires-dist = [
{ name = "flask-babel", marker = "extra == 'front'", specifier = ">=4.0.0" },
{ name = "flask-themer", marker = "extra == 'front'", specifier = ">=2.0.0" },
{ name = "flask-wtf", specifier = ">=1.2.1" },
{ name = "otpauth", marker = "extra == 'otp'", specifier = ">=2.1.1" },
{ name = "passlib", marker = "extra == 'mysql'", specifier = ">=1.7.4" },
{ name = "passlib", marker = "extra == 'postgresql'", specifier = ">=1.7.4" },
{ name = "passlib", marker = "extra == 'sqlite'", specifier = ">=1.7.4" },
{ name = "pillow", marker = "extra == 'otp'", specifier = ">=11.0.0" },
{ name = "pycountry", marker = "extra == 'front'", specifier = ">=23.12.7" },
{ name = "pydantic-settings", specifier = ">=2.0.3" },
{ name = "python-ldap", marker = "extra == 'ldap'", specifier = ">=3.4.0" },
{ name = "pytz", marker = "extra == 'front'", specifier = ">=2022.7" },
{ name = "qrcode", marker = "extra == 'otp'", specifier = ">=8.0" },
{ name = "requests", specifier = ">=2.32.3" },
{ name = "sentry-sdk", marker = "extra == 'sentry'", specifier = ">=2.0.0" },
{ name = "smpplib", marker = "extra == 'sms'", specifier = ">=2.2.3" },
{ name = "sqlalchemy", marker = "extra == 'sqlite'", specifier = ">=2.0.23" },
{ name = "sqlalchemy", extras = ["mysql-connector"], marker = "extra == 'mysql'", specifier = ">=2.0.23" },
{ name = "sqlalchemy", extras = ["postgresql-psycopg2binary"], marker = "extra == 'postgresql'", specifier = ">=2.0.23" },
@ -1020,6 +1032,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314 },
]
[[package]]
name = "otpauth"
version = "2.1.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7c/28/bc2428f072cc3ceb9eecead6e41e2914fab4f0d00c1d5ad2e5f018eb080a/otpauth-2.1.1.tar.gz", hash = "sha256:e09ed63a01b35acd78b7bb0f981fdcdbd1d9d9c01be1a3e0d7024c65d0e74e2a", size = 6138 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/26/7b/37eb712ecf1c9472baff30eb16d1c87ea92e396312de3ac1962da22f3a3b/otpauth-2.1.1-py3-none-any.whl", hash = "sha256:b08d2bf30c0118e4d6f5bd41c5d537761d119c001da4b69cba0e0ae7b93d7793", size = 6551 },
]
[[package]]
name = "packaging"
version = "24.2"
@ -1038,6 +1059,73 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/a4/ab6b7589382ca3df236e03faa71deac88cae040af60c071a78d254a62172/passlib-1.7.4-py2.py3-none-any.whl", hash = "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1", size = 525554 },
]
[[package]]
name = "pillow"
version = "11.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a5/26/0d95c04c868f6bdb0c447e3ee2de5564411845e36a858cfd63766bc7b563/pillow-11.0.0.tar.gz", hash = "sha256:72bacbaf24ac003fea9bff9837d1eedb6088758d41e100c1552930151f677739", size = 46737780 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/98/fb/a6ce6836bd7fd93fbf9144bf54789e02babc27403b50a9e1583ee877d6da/pillow-11.0.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:6619654954dc4936fcff82db8eb6401d3159ec6be81e33c6000dfd76ae189947", size = 3154708 },
{ url = "https://files.pythonhosted.org/packages/6a/1d/1f51e6e912d8ff316bb3935a8cda617c801783e0b998bf7a894e91d3bd4c/pillow-11.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b3c5ac4bed7519088103d9450a1107f76308ecf91d6dabc8a33a2fcfb18d0fba", size = 2979223 },
{ url = "https://files.pythonhosted.org/packages/90/83/e2077b0192ca8a9ef794dbb74700c7e48384706467067976c2a95a0f40a1/pillow-11.0.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a65149d8ada1055029fcb665452b2814fe7d7082fcb0c5bed6db851cb69b2086", size = 4183167 },
{ url = "https://files.pythonhosted.org/packages/0e/74/467af0146970a98349cdf39e9b79a6cc8a2e7558f2c01c28a7b6b85c5bda/pillow-11.0.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88a58d8ac0cc0e7f3a014509f0455248a76629ca9b604eca7dc5927cc593c5e9", size = 4283912 },
{ url = "https://files.pythonhosted.org/packages/85/b1/d95d4f7ca3a6c1ae120959605875a31a3c209c4e50f0029dc1a87566cf46/pillow-11.0.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:c26845094b1af3c91852745ae78e3ea47abf3dbcd1cf962f16b9a5fbe3ee8488", size = 4195815 },
{ url = "https://files.pythonhosted.org/packages/41/c3/94f33af0762ed76b5a237c5797e088aa57f2b7fa8ee7932d399087be66a8/pillow-11.0.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:1a61b54f87ab5786b8479f81c4b11f4d61702830354520837f8cc791ebba0f5f", size = 4366117 },
{ url = "https://files.pythonhosted.org/packages/ba/3c/443e7ef01f597497268899e1cca95c0de947c9bbf77a8f18b3c126681e5d/pillow-11.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:674629ff60030d144b7bca2b8330225a9b11c482ed408813924619c6f302fdbb", size = 4278607 },
{ url = "https://files.pythonhosted.org/packages/26/95/1495304448b0081e60c0c5d63f928ef48bb290acee7385804426fa395a21/pillow-11.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:598b4e238f13276e0008299bd2482003f48158e2b11826862b1eb2ad7c768b97", size = 4410685 },
{ url = "https://files.pythonhosted.org/packages/45/da/861e1df971ef0de9870720cb309ca4d553b26a9483ec9be3a7bf1de4a095/pillow-11.0.0-cp310-cp310-win32.whl", hash = "sha256:9a0f748eaa434a41fccf8e1ee7a3eed68af1b690e75328fd7a60af123c193b50", size = 2249185 },
{ url = "https://files.pythonhosted.org/packages/d5/4e/78f7c5202ea2a772a5ab05069c1b82503e6353cd79c7e474d4945f4b82c3/pillow-11.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:a5629742881bcbc1f42e840af185fd4d83a5edeb96475a575f4da50d6ede337c", size = 2566726 },
{ url = "https://files.pythonhosted.org/packages/77/e4/6e84eada35cbcc646fc1870f72ccfd4afacb0fae0c37ffbffe7f5dc24bf1/pillow-11.0.0-cp310-cp310-win_arm64.whl", hash = "sha256:ee217c198f2e41f184f3869f3e485557296d505b5195c513b2bfe0062dc537f1", size = 2254585 },
{ url = "https://files.pythonhosted.org/packages/f0/eb/f7e21b113dd48a9c97d364e0915b3988c6a0b6207652f5a92372871b7aa4/pillow-11.0.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:1c1d72714f429a521d8d2d018badc42414c3077eb187a59579f28e4270b4b0fc", size = 3154705 },
{ url = "https://files.pythonhosted.org/packages/25/b3/2b54a1d541accebe6bd8b1358b34ceb2c509f51cb7dcda8687362490da5b/pillow-11.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:499c3a1b0d6fc8213519e193796eb1a86a1be4b1877d678b30f83fd979811d1a", size = 2979222 },
{ url = "https://files.pythonhosted.org/packages/20/12/1a41eddad8265c5c19dda8fb6c269ce15ee25e0b9f8f26286e6202df6693/pillow-11.0.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c8b2351c85d855293a299038e1f89db92a2f35e8d2f783489c6f0b2b5f3fe8a3", size = 4190220 },
{ url = "https://files.pythonhosted.org/packages/a9/9b/8a8c4d07d77447b7457164b861d18f5a31ae6418ef5c07f6f878fa09039a/pillow-11.0.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f4dba50cfa56f910241eb7f883c20f1e7b1d8f7d91c750cd0b318bad443f4d5", size = 4291399 },
{ url = "https://files.pythonhosted.org/packages/fc/e4/130c5fab4a54d3991129800dd2801feeb4b118d7630148cd67f0e6269d4c/pillow-11.0.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:5ddbfd761ee00c12ee1be86c9c0683ecf5bb14c9772ddbd782085779a63dd55b", size = 4202709 },
{ url = "https://files.pythonhosted.org/packages/39/63/b3fc299528d7df1f678b0666002b37affe6b8751225c3d9c12cf530e73ed/pillow-11.0.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:45c566eb10b8967d71bf1ab8e4a525e5a93519e29ea071459ce517f6b903d7fa", size = 4372556 },
{ url = "https://files.pythonhosted.org/packages/c6/a6/694122c55b855b586c26c694937d36bb8d3b09c735ff41b2f315c6e66a10/pillow-11.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b4fd7bd29610a83a8c9b564d457cf5bd92b4e11e79a4ee4716a63c959699b306", size = 4287187 },
{ url = "https://files.pythonhosted.org/packages/ba/a9/f9d763e2671a8acd53d29b1e284ca298bc10a595527f6be30233cdb9659d/pillow-11.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:cb929ca942d0ec4fac404cbf520ee6cac37bf35be479b970c4ffadf2b6a1cad9", size = 4418468 },
{ url = "https://files.pythonhosted.org/packages/6e/0e/b5cbad2621377f11313a94aeb44ca55a9639adabcaaa073597a1925f8c26/pillow-11.0.0-cp311-cp311-win32.whl", hash = "sha256:006bcdd307cc47ba43e924099a038cbf9591062e6c50e570819743f5607404f5", size = 2249249 },
{ url = "https://files.pythonhosted.org/packages/dc/83/1470c220a4ff06cd75fc609068f6605e567ea51df70557555c2ab6516b2c/pillow-11.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:52a2d8323a465f84faaba5236567d212c3668f2ab53e1c74c15583cf507a0291", size = 2566769 },
{ url = "https://files.pythonhosted.org/packages/52/98/def78c3a23acee2bcdb2e52005fb2810ed54305602ec1bfcfab2bda6f49f/pillow-11.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:16095692a253047fe3ec028e951fa4221a1f3ed3d80c397e83541a3037ff67c9", size = 2254611 },
{ url = "https://files.pythonhosted.org/packages/1c/a3/26e606ff0b2daaf120543e537311fa3ae2eb6bf061490e4fea51771540be/pillow-11.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d2c0a187a92a1cb5ef2c8ed5412dd8d4334272617f532d4ad4de31e0495bd923", size = 3147642 },
{ url = "https://files.pythonhosted.org/packages/4f/d5/1caabedd8863526a6cfa44ee7a833bd97f945dc1d56824d6d76e11731939/pillow-11.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:084a07ef0821cfe4858fe86652fffac8e187b6ae677e9906e192aafcc1b69903", size = 2978999 },
{ url = "https://files.pythonhosted.org/packages/d9/ff/5a45000826a1aa1ac6874b3ec5a856474821a1b59d838c4f6ce2ee518fe9/pillow-11.0.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8069c5179902dcdce0be9bfc8235347fdbac249d23bd90514b7a47a72d9fecf4", size = 4196794 },
{ url = "https://files.pythonhosted.org/packages/9d/21/84c9f287d17180f26263b5f5c8fb201de0f88b1afddf8a2597a5c9fe787f/pillow-11.0.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f02541ef64077f22bf4924f225c0fd1248c168f86e4b7abdedd87d6ebaceab0f", size = 4300762 },
{ url = "https://files.pythonhosted.org/packages/84/39/63fb87cd07cc541438b448b1fed467c4d687ad18aa786a7f8e67b255d1aa/pillow-11.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:fcb4621042ac4b7865c179bb972ed0da0218a076dc1820ffc48b1d74c1e37fe9", size = 4210468 },
{ url = "https://files.pythonhosted.org/packages/7f/42/6e0f2c2d5c60f499aa29be14f860dd4539de322cd8fb84ee01553493fb4d/pillow-11.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:00177a63030d612148e659b55ba99527803288cea7c75fb05766ab7981a8c1b7", size = 4381824 },
{ url = "https://files.pythonhosted.org/packages/31/69/1ef0fb9d2f8d2d114db982b78ca4eeb9db9a29f7477821e160b8c1253f67/pillow-11.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8853a3bf12afddfdf15f57c4b02d7ded92c7a75a5d7331d19f4f9572a89c17e6", size = 4296436 },
{ url = "https://files.pythonhosted.org/packages/44/ea/dad2818c675c44f6012289a7c4f46068c548768bc6c7f4e8c4ae5bbbc811/pillow-11.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3107c66e43bda25359d5ef446f59c497de2b5ed4c7fdba0894f8d6cf3822dafc", size = 4429714 },
{ url = "https://files.pythonhosted.org/packages/af/3a/da80224a6eb15bba7a0dcb2346e2b686bb9bf98378c0b4353cd88e62b171/pillow-11.0.0-cp312-cp312-win32.whl", hash = "sha256:86510e3f5eca0ab87429dd77fafc04693195eec7fd6a137c389c3eeb4cfb77c6", size = 2249631 },
{ url = "https://files.pythonhosted.org/packages/57/97/73f756c338c1d86bb802ee88c3cab015ad7ce4b838f8a24f16b676b1ac7c/pillow-11.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:8ec4a89295cd6cd4d1058a5e6aec6bf51e0eaaf9714774e1bfac7cfc9051db47", size = 2567533 },
{ url = "https://files.pythonhosted.org/packages/0b/30/2b61876e2722374558b871dfbfcbe4e406626d63f4f6ed92e9c8e24cac37/pillow-11.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:27a7860107500d813fcd203b4ea19b04babe79448268403172782754870dac25", size = 2254890 },
{ url = "https://files.pythonhosted.org/packages/63/24/e2e15e392d00fcf4215907465d8ec2a2f23bcec1481a8ebe4ae760459995/pillow-11.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:bcd1fb5bb7b07f64c15618c89efcc2cfa3e95f0e3bcdbaf4642509de1942a699", size = 3147300 },
{ url = "https://files.pythonhosted.org/packages/43/72/92ad4afaa2afc233dc44184adff289c2e77e8cd916b3ddb72ac69495bda3/pillow-11.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0e038b0745997c7dcaae350d35859c9715c71e92ffb7e0f4a8e8a16732150f38", size = 2978742 },
{ url = "https://files.pythonhosted.org/packages/9e/da/c8d69c5bc85d72a8523fe862f05ababdc52c0a755cfe3d362656bb86552b/pillow-11.0.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ae08bd8ffc41aebf578c2af2f9d8749d91f448b3bfd41d7d9ff573d74f2a6b2", size = 4194349 },
{ url = "https://files.pythonhosted.org/packages/cd/e8/686d0caeed6b998351d57796496a70185376ed9c8ec7d99e1d19ad591fc6/pillow-11.0.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d69bfd8ec3219ae71bcde1f942b728903cad25fafe3100ba2258b973bd2bc1b2", size = 4298714 },
{ url = "https://files.pythonhosted.org/packages/ec/da/430015cec620d622f06854be67fd2f6721f52fc17fca8ac34b32e2d60739/pillow-11.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:61b887f9ddba63ddf62fd02a3ba7add935d053b6dd7d58998c630e6dbade8527", size = 4208514 },
{ url = "https://files.pythonhosted.org/packages/44/ae/7e4f6662a9b1cb5f92b9cc9cab8321c381ffbee309210940e57432a4063a/pillow-11.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:c6a660307ca9d4867caa8d9ca2c2658ab685de83792d1876274991adec7b93fa", size = 4380055 },
{ url = "https://files.pythonhosted.org/packages/74/d5/1a807779ac8a0eeed57f2b92a3c32ea1b696e6140c15bd42eaf908a261cd/pillow-11.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:73e3a0200cdda995c7e43dd47436c1548f87a30bb27fb871f352a22ab8dcf45f", size = 4296751 },
{ url = "https://files.pythonhosted.org/packages/38/8c/5fa3385163ee7080bc13026d59656267daaaaf3c728c233d530e2c2757c8/pillow-11.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fba162b8872d30fea8c52b258a542c5dfd7b235fb5cb352240c8d63b414013eb", size = 4430378 },
{ url = "https://files.pythonhosted.org/packages/ca/1d/ad9c14811133977ff87035bf426875b93097fb50af747793f013979facdb/pillow-11.0.0-cp313-cp313-win32.whl", hash = "sha256:f1b82c27e89fffc6da125d5eb0ca6e68017faf5efc078128cfaa42cf5cb38798", size = 2249588 },
{ url = "https://files.pythonhosted.org/packages/fb/01/3755ba287dac715e6afdb333cb1f6d69740a7475220b4637b5ce3d78cec2/pillow-11.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:8ba470552b48e5835f1d23ecb936bb7f71d206f9dfeee64245f30c3270b994de", size = 2567509 },
{ url = "https://files.pythonhosted.org/packages/c0/98/2c7d727079b6be1aba82d195767d35fcc2d32204c7a5820f822df5330152/pillow-11.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:846e193e103b41e984ac921b335df59195356ce3f71dcfd155aa79c603873b84", size = 2254791 },
{ url = "https://files.pythonhosted.org/packages/eb/38/998b04cc6f474e78b563716b20eecf42a2fa16a84589d23c8898e64b0ffd/pillow-11.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4ad70c4214f67d7466bea6a08061eba35c01b1b89eaa098040a35272a8efb22b", size = 3150854 },
{ url = "https://files.pythonhosted.org/packages/13/8e/be23a96292113c6cb26b2aa3c8b3681ec62b44ed5c2bd0b258bd59503d3c/pillow-11.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:6ec0d5af64f2e3d64a165f490d96368bb5dea8b8f9ad04487f9ab60dc4bb6003", size = 2982369 },
{ url = "https://files.pythonhosted.org/packages/97/8a/3db4eaabb7a2ae8203cd3a332a005e4aba00067fc514aaaf3e9721be31f1/pillow-11.0.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c809a70e43c7977c4a42aefd62f0131823ebf7dd73556fa5d5950f5b354087e2", size = 4333703 },
{ url = "https://files.pythonhosted.org/packages/28/ac/629ffc84ff67b9228fe87a97272ab125bbd4dc462745f35f192d37b822f1/pillow-11.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:4b60c9520f7207aaf2e1d94de026682fc227806c6e1f55bba7606d1c94dd623a", size = 4412550 },
{ url = "https://files.pythonhosted.org/packages/d6/07/a505921d36bb2df6868806eaf56ef58699c16c388e378b0dcdb6e5b2fb36/pillow-11.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:1e2688958a840c822279fda0086fec1fdab2f95bf2b717b66871c4ad9859d7e8", size = 4461038 },
{ url = "https://files.pythonhosted.org/packages/d6/b9/fb620dd47fc7cc9678af8f8bd8c772034ca4977237049287e99dda360b66/pillow-11.0.0-cp313-cp313t-win32.whl", hash = "sha256:607bbe123c74e272e381a8d1957083a9463401f7bd01287f50521ecb05a313f8", size = 2253197 },
{ url = "https://files.pythonhosted.org/packages/df/86/25dde85c06c89d7fc5db17940f07aae0a56ac69aa9ccb5eb0f09798862a8/pillow-11.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5c39ed17edea3bc69c743a8dd3e9853b7509625c2462532e62baa0732163a904", size = 2572169 },
{ url = "https://files.pythonhosted.org/packages/51/85/9c33f2517add612e17f3381aee7c4072779130c634921a756c97bc29fb49/pillow-11.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:75acbbeb05b86bc53cbe7b7e6fe00fbcf82ad7c684b3ad82e3d711da9ba287d3", size = 2256828 },
{ url = "https://files.pythonhosted.org/packages/36/57/42a4dd825eab762ba9e690d696d894ba366e06791936056e26e099398cda/pillow-11.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1187739620f2b365de756ce086fdb3604573337cc28a0d3ac4a01ab6b2d2a6d2", size = 3119239 },
{ url = "https://files.pythonhosted.org/packages/98/f7/25f9f9e368226a1d6cf3507081a1a7944eddd3ca7821023377043f5a83c8/pillow-11.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:fbbcb7b57dc9c794843e3d1258c0fbf0f48656d46ffe9e09b63bbd6e8cd5d0a2", size = 2950803 },
{ url = "https://files.pythonhosted.org/packages/59/01/98ead48a6c2e31e6185d4c16c978a67fe3ccb5da5c2ff2ba8475379bb693/pillow-11.0.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d203af30149ae339ad1b4f710d9844ed8796e97fda23ffbc4cc472968a47d0b", size = 3281098 },
{ url = "https://files.pythonhosted.org/packages/51/c0/570255b2866a0e4d500a14f950803a2ec273bac7badc43320120b9262450/pillow-11.0.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21a0d3b115009ebb8ac3d2ebec5c2982cc693da935f4ab7bb5c8ebe2f47d36f2", size = 3323665 },
{ url = "https://files.pythonhosted.org/packages/0e/75/689b4ec0483c42bfc7d1aacd32ade7a226db4f4fac57c6fdcdf90c0731e3/pillow-11.0.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:73853108f56df97baf2bb8b522f3578221e56f646ba345a372c78326710d3830", size = 3310533 },
{ url = "https://files.pythonhosted.org/packages/3d/30/38bd6149cf53da1db4bad304c543ade775d225961c4310f30425995cb9ec/pillow-11.0.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:e58876c91f97b0952eb766123bfef372792ab3f4e3e1f1a2267834c2ab131734", size = 3414886 },
{ url = "https://files.pythonhosted.org/packages/ec/3d/c32a51d848401bd94cabb8767a39621496491ee7cd5199856b77da9b18ad/pillow-11.0.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:224aaa38177597bb179f3ec87eeefcce8e4f85e608025e9cfac60de237ba6316", size = 2567508 },
]
[[package]]
name = "platformdirs"
version = "4.3.6"
@ -1506,6 +1594,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446 },
]
[[package]]
name = "qrcode"
version = "8.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d7/db/6fc9631cac1327f609d2c8ae3680ecd987a2e97472437f2de7ead1235156/qrcode-8.0.tar.gz", hash = "sha256:025ce2b150f7fe4296d116ee9bad455a6643ab4f6e7dce541613a4758cbce347", size = 42743 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/74/ab/df8d889fd01139db68ae9e5cb5c8f0ea016823559a6ecb427582d52b07dc/qrcode-8.0-py3-none-any.whl", hash = "sha256:9fc05f03305ad27a709eb742cf3097fa19e6f6f93bb9e2f039c0979190f6f1b1", size = 45710 },
]
[[package]]
name = "requests"
version = "2.32.3"
@ -1573,6 +1673,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/45/8d/f0451d9d733de0ebe6ec4b461ea051374c4a53f161798deea3353222d151/slapd-0.1.5-py3-none-any.whl", hash = "sha256:b6aa9e75c6fb280e76877864a101564e3088e3ee211a74d11bedcfb6aec18923", size = 22124 },
]
[[package]]
name = "smpplib"
version = "2.2.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/dd/c0/9427c820f5514a5b43f98f99b746334fcae71f8385466766c3510b07d706/smpplib-2.2.3.tar.gz", hash = "sha256:5215a95b0538d26f189600e0982b31da8281f7453cd6e2862c5b21e3e1002331", size = 24819 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/88/cd/9af2de1d421807ed8c683346435d61dc6edcccc2ef30d22751ede3b96bd5/smpplib-2.2.3-py3-none-any.whl", hash = "sha256:62abc429602bd3f4e351ee786eed514f9d12fffc05d1b8d2dc715d96a733820a", size = 28875 },
]
[[package]]
name = "smtpdfix"
version = "0.5.2"