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 .env
*.sqlite *.sqlite
*.sqlite-journal
*.pyc *.pyc
*.mo *.mo
*.prof *.prof

View file

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

View file

@ -3,6 +3,12 @@
Added 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` - Password compromission check :issue:`179`
- :attr:`~canaille.core.configuration.CoreSettings.ADMIN_EMAIL` and - :attr:`~canaille.core.configuration.CoreSettings.ADMIN_EMAIL` and
:attr:`~canaille.core.configuration.CoreSettings.ENABLE_PASSWORD_COMPROMISSION_CHECK` and :attr:`~canaille.core.configuration.CoreSettings.ENABLE_PASSWORD_COMPROMISSION_CHECK` and

View file

@ -2,6 +2,8 @@ import base64
import hashlib import hashlib
import json import json
import re import re
from base64 import b64encode
from io import BytesIO
from flask import current_app from flask import current_app
from flask import request from flask import request
@ -66,3 +68,29 @@ class classproperty:
def __get__(self, obj, owner): def __get__(self, obj, owner):
return self.f(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 os
import smtplib import smtplib
import socket import socket
@ -175,7 +176,7 @@ def validate(config, validate_remote=False):
validate_keypair(config.get("CANAILLE_OIDC")) validate_keypair(config.get("CANAILLE_OIDC"))
validate_theme(config["CANAILLE"]) validate_theme(config["CANAILLE"])
validate_admin_email(config["CANAILLE"]) validate_admin_email(config["CANAILLE"])
validate_otp_config(config["CANAILLE"])
if not validate_remote: if not validate_remote:
return return
@ -184,6 +185,8 @@ def validate(config, validate_remote=False):
Backend.instance.validate(config) Backend.instance.validate(config)
if smtp_config := config["CANAILLE"]["SMTP"]: if smtp_config := config["CANAILLE"]["SMTP"]:
validate_smtp_configuration(smtp_config) validate_smtp_configuration(smtp_config)
if smpp_config := config["CANAILLE"]["SMPP"]:
validate_smpp_configuration(smpp_config)
def validate_keypair(config): def validate_keypair(config):
@ -231,6 +234,31 @@ def validate_smtp_configuration(config):
raise ConfigurationException(exc) from exc 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): def validate_theme(config):
if not os.path.exists(config["THEME"]) and not os.path.exists( if not os.path.exists(config["THEME"]) and not os.path.exists(
os.path.join(ROOT, "themes", config["THEME"]) os.path.join(ROOT, "themes", config["THEME"])
@ -243,3 +271,25 @@ def validate_admin_email(config):
raise ConfigurationException( raise ConfigurationException(
"You must set an administration email if you want to check if users' passwords are compromised." "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): def has_password_recovery(self):
return self.app.config["CANAILLE"]["ENABLE_PASSWORD_RECOVERY"] 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 @property
def has_registration(self): def has_registration(self):
return self.app.config["CANAILLE"]["ENABLE_REGISTRATION"] 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 typing
import click import click
from flask import current_app
from flask.cli import AppGroup from flask.cli import AppGroup
from flask.cli import with_appcontext from flask.cli import with_appcontext
from canaille.app import models
from canaille.app.commands import with_backendcontext from canaille.app.commands import with_backendcontext
from canaille.app.models import MODELS from canaille.app.models import MODELS
from canaille.backends import Backend from canaille.backends import Backend
@ -77,6 +79,8 @@ def register(cli):
@cli.command(cls=ModelCommand, factory=factory, name=name, help=command_help) @cli.command(cls=ModelCommand, factory=factory, name=name, help=command_help)
def factory_command(): ... def factory_command(): ...
cli.add_command(reset_otp)
def serialize(instance): def serialize(instance):
"""Quick and dirty serialization method. """Quick and dirty serialization method.
@ -281,3 +285,32 @@ def delete_factory(model):
raise click.ClickException(exc) from exc raise click.ClickException(exc) from exc
return command 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`. 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.""" """The object class to use for creating new users."""
USER_RDN: str = "uid" USER_RDN: str = "uid"

View file

@ -1,4 +1,5 @@
import ldap.filter import ldap.filter
from flask import current_app
import canaille.core.models import canaille.core.models
import canaille.oidc.models import canaille.oidc.models
@ -34,6 +35,11 @@ class User(canaille.core.models.User, LDAPObject):
"organization": "o", "organization": "o",
"groups": "memberOf", "groups": "memberOf",
"lock_date": "pwdEndTime", "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): def match_filter(self, filter):
@ -44,6 +50,9 @@ class User(canaille.core.models.User, LDAPObject):
return super().match_filter(filter) return super().match_filter(filter)
def save(self): 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") group_attr = self.python_attribute_to_ldap("groups")
if group_attr not in self.changes: if group_attr not in self.changes:
return return

View file

@ -3,6 +3,9 @@ import datetime
import uuid import uuid
from typing import Any from typing import Any
from flask import current_app
import canaille.backends.memory.models
from canaille.backends import Backend from canaille.backends import Backend
@ -124,6 +127,13 @@ class MemoryBackend(Backend):
return results[0] if results else None return results[0] if results else None
def save(self, instance): 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: if not instance.id:
instance.id = str(uuid.uuid4()) instance.id = str(uuid.uuid4())

View file

@ -110,6 +110,10 @@ class SQLBackend(Backend):
).scalar_one_or_none() ).scalar_one_or_none()
def save(self, instance): 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( instance.last_modified = datetime.datetime.now(datetime.timezone.utc).replace(
microsecond=0 microsecond=0
) )

View file

@ -2,6 +2,7 @@ import datetime
import typing import typing
import uuid import uuid
from flask import current_app
from sqlalchemy import Boolean from sqlalchemy import Boolean
from sqlalchemy import Column from sqlalchemy import Column
from sqlalchemy import ForeignKey from sqlalchemy import ForeignKey
@ -100,6 +101,19 @@ class User(canaille.core.models.User, Base, SqlAlchemyModel):
lock_date: Mapped[datetime.datetime] = mapped_column( lock_date: Mapped[datetime.datetime] = mapped_column(
TZDateTime(timezone=True), nullable=True 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): 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. # recovery link by email. This option is true by default.
# ENABLE_PASSWORD_RECOVERY = true # 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. # The validity duration of registration invitations, in seconds.
# Defaults to 2 days # Defaults to 2 days
# INVITATION_EXPIRATION = 172800 # INVITATION_EXPIRATION = 172800
@ -115,6 +132,7 @@ SECRET_KEY = "change me before you go in production"
# USER_BASE = "ou=users,dc=mydomain,dc=tld" # USER_BASE = "ou=users,dc=mydomain,dc=tld"
# The object class to use for creating new users # 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" # USER_CLASS = "inetOrgPerson"
# The attribute to identify an object in the User dn. # The attribute to identify an object in the User dn.
@ -273,3 +291,11 @@ WRITE = [
# LOGIN = "" # LOGIN = ""
# PASSWORD = "" # PASSWORD = ""
# FROM_ADDR = "admin@mydomain.example" # 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): class Permission(str, Enum):
"""The permissions that can be assigned to users. """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 """If :py:data:`False`, then users cannot ask for a password recovery link
by email.""" 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 INVITATION_EXPIRATION: int = 172800
"""The validity duration of registration invitations, in seconds. """The validity duration of registration invitations, in seconds.
@ -286,6 +318,13 @@ class CoreSettings(BaseModel):
enabled. 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()} ACL: dict[str, ACLSettings] | None = {"DEFAULT": ACLSettings()}
"""Mapping of permission groups. See :class:`ACLSettings` for more details. """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")) return redirect(url_for("core.account.index"))
if current_user(): user = current_user()
if user:
flash( flash(
_("You are already logged in, you cannot create an account."), _("You are already logged in, you cannot create an account."),
"error", "error",
@ -752,6 +753,23 @@ def profile_settings(user, edited_user):
return profile_settings_edit(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')}") 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, hashed_password=hashed_password,
user_email=user_email, 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 Blueprint
from flask import abort from flask import abort
from flask import current_app from flask import current_app
@ -8,6 +10,9 @@ from flask import session
from flask import url_for from flask import url_for
from canaille.app import build_hash 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.flask import smtp_needed
from canaille.app.i18n import gettext as _ from canaille.app.i18n import gettext as _
from canaille.app.session import current_user 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.session import logout_user
from canaille.app.themes import render_template from canaille.app.themes import render_template
from canaille.backends import Backend 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_initialization_mail
from ..mails import send_password_reset_mail from ..mails import send_password_reset_mail
@ -103,16 +110,33 @@ def password():
"password.html", form=form, username=session["attempt_login"] "password.html", form=form, username=session["attempt_login"]
) )
current_app.logger.security( otp_methods = []
f'Succeed login attempt for {session["attempt_login"]} from {request_ip}' if current_app.features.has_otp:
) otp_methods.append(current_app.features.otp_method) # TOTP or HOTP
del session["attempt_login"] if current_app.features.has_email_otp:
login_user(user) otp_methods.append("EMAIL_OTP")
flash( if current_app.features.has_sms_otp:
_("Connection successful. Welcome %(user)s", user=user.formatted_name), otp_methods.append("SMS_OTP")
"success",
) if otp_methods:
return redirect(session.pop("redirect-after-login", url_for("core.account.index"))) 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") @bp.route("/logout")
@ -251,3 +275,253 @@ def reset(user, hash):
) )
return render_template("reset-password.html", form=form, user=user, hash=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 lazy_gettext as _
from canaille.app.i18n import native_language_name_from_code from canaille.app.i18n import native_language_name_from_code
from canaille.backends import Backend from canaille.backends import Backend
from canaille.core.models import OTP_DIGITS
def unique_user_name(form, field): def unique_user_name(form, field):
@ -480,3 +481,18 @@ class EmailConfirmationForm(Form):
"autocorrect": "off", "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, html=html_body,
attachments=[(logo_cid, logo_filename, logo_raw)] if logo_filename else None, 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 datetime
import secrets
from typing import Annotated from typing import Annotated
from typing import ClassVar from typing import ClassVar
@ -6,6 +7,13 @@ from flask import current_app
from canaille.backends.models import Model from canaille.backends.models import Model
from canaille.core.configuration import Permission 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): class User(Model):
@ -241,6 +249,24 @@ class User(Model):
lock_date: datetime.datetime | None = None lock_date: datetime.datetime | None = None
"""A DateTime indicating when the resource was locked.""" """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 _readable_fields = None
_writable_fields = None _writable_fields = None
_permissions = None _permissions = None
@ -258,10 +284,14 @@ class User(Model):
def __getattribute__(self, name): def __getattribute__(self, name):
prefix = "can_" 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): def can(self, *permissions: Permission):
"""Whether or not the user has the """Whether or not the user has the
@ -318,6 +348,110 @@ class User(Model):
self._writable_fields |= set(details["WRITE"]) self._writable_fields |= set(details["WRITE"])
return self._writable_fields 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): class Group(Model):
"""User model, based on the `SCIM Group schema """User model, based on the `SCIM Group schema
@ -347,3 +481,15 @@ class Group(Model):
""" """
description: str | None = None 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>
<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>
</div> </div>
{% endblock %} {% 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" %} {% if field.name == "password" %}
{{ fui.render_field(field, icon="key", noindicator=true, **kwargs) }} {{ fui.render_field(field, icon="key", noindicator=true, **kwargs) }}
{% endif %} {% endif %}
{% if field.name == "otp" %}
{{ fui.render_field(field, icon="key", noindicator=true, **kwargs) }}
{% endif %}
{% endmacro %} {% endmacro %}

View file

@ -140,6 +140,12 @@
<div class="ui right aligned container"> <div class="ui right aligned container">
<div class="ui stackable buttons"> <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 %} {% 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> <button type="submit" class="ui right floated basic negative button confirm" name="action" value="confirm-lock" id="lock" formnovalidate>
{% trans %}Lock the account{% endtrans %} {% 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; 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 */ /* Fix button appearance for semantic-ui on webkit */
[type=button] { [type=button] {
-webkit-appearance: none; -webkit-appearance: none;
@ -261,4 +284,12 @@ select.ui.multiple.dropdown option[selected] {
.ui.progress { .ui.progress {
background: #222222; 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 TIMEOUT = 10
USER_BASE = "ou=users,dc=mydomain,dc=tld" USER_BASE = "ou=users,dc=mydomain,dc=tld"
GROUP_BASE = "ou=groups,dc=mydomain,dc=tld" GROUP_BASE = "ou=groups,dc=mydomain,dc=tld"
USER_CLASS = ["inetOrgPerson", "oathHOTPToken"]
[CANAILLE.ACL.DEFAULT] [CANAILLE.ACL.DEFAULT]
PERMISSIONS = ["edit_self", "use_oidc"] PERMISSIONS = ["edit_self", "use_oidc"]

View file

@ -17,6 +17,7 @@ schemas = [
"ldif/memberof-config.ldif", "ldif/memberof-config.ldif",
"ldif/refint-config.ldif", "ldif/refint-config.ldif",
"ldif/ppolicy-config.ldif", "ldif/ppolicy-config.ldif",
"ldif/otp-config.ldif",
"../canaille/backends/ldap/schemas/oauth2-openldap.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>`. 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 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. 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 - Authentication attempt
- Password update - Password update
- Email update - Email update
- Forgotten password mail sent to user - Forgotten password mail sent to user
- One-time password mail sent to user
- Multi-factor authentication reset
- Token emission - Token emission
- Token refresh - Token refresh
- Token revokation - Token revokation

View file

@ -58,3 +58,9 @@ For the sake of readability, it is omitted in the following examples.
.. click:: doc.commands:delete .. click:: doc.commands:delete
:prog: canaille delete :prog: canaille delete
:nested: full :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.CoreSettings
.. autopydantic_settings:: canaille.core.configuration.SMTPSettings .. autopydantic_settings:: canaille.core.configuration.SMTPSettings
.. autopydantic_settings:: canaille.core.configuration.SMPPSettings
.. autopydantic_settings:: canaille.core.configuration.ACLSettings .. autopydantic_settings:: canaille.core.configuration.ACLSettings
.. autoclass:: canaille.core.configuration.Permission .. autoclass:: canaille.core.configuration.Permission
:members: :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" 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>`. You can find more details on the LDAP configuration in the :class:`dedicated section <canaille.backends.ldap.configuration.LDAPSettings>`.
.. note :: .. 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. If you want to use different schemas or LDAP servers, adaptations may be needed.
Patches are welcome. Patches are welcome.
@ -108,3 +113,30 @@ You can adapt and load those configuration files with:
# Adapt those commands according to your setup # 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-config.ldif
sudo ldapadd -Q -H ldapi:/// -Y EXTERNAL -f ppolicy.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; - `postgresql` provides the dependencies to enable the PostgreSQL backend;
- `mysql` provides the dependencies to enable the MySQL backend; - `mysql` provides the dependencies to enable the MySQL backend;
- `sentry` provides sentry integration to watch Canaille exceptions; - `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. - `all` provides all the extras above.
They can be installed with: They can be installed with:

View file

@ -1,5 +1,5 @@
[build-system] [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" build-backend = "hatchling.build"
[project] [project]
@ -81,6 +81,16 @@ mysql = [
"sqlalchemy-utils >= 0.41.1", "sqlalchemy-utils >= 0.41.1",
] ]
otp = [
"otpauth>=2.1.1",
"pillow>=11.0.0",
"qrcode>=8.0",
]
sms = [
"smpplib>=2.2.3",
]
[project.urls] [project.urls]
homepage = "https://canaille.yaal.coop" homepage = "https://canaille.yaal.coop"
documentation = "https://canaille.readthedocs.io/en/latest/" documentation = "https://canaille.readthedocs.io/en/latest/"

View file

@ -1,7 +1,7 @@
from canaille.commands import cli from canaille.commands import cli
def test_check_command(testclient): def test_check_command(testclient, mock_smpp):
runner = testclient.app.test_cli_runner() runner = testclient.app.test_cli_runner()
res = runner.invoke(cli, ["check"]) res = runner.invoke(cli, ["check"])
assert res.exit_code == 0, res.stdout assert res.exit_code == 0, res.stdout
@ -14,7 +14,7 @@ def test_check_command_fail(testclient):
assert res.exit_code == 1, res.stdout 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 testclient.app.config["CANAILLE"]["SMTP"] = None
runner = testclient.app.test_cli_runner() runner = testclient.app.test_cli_runner()
res = runner.invoke(cli, ["check"]) 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) 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"]["LOGIN"]
del configuration["CANAILLE"]["SMTP"]["PASSWORD"] del configuration["CANAILLE"]["SMTP"]["PASSWORD"]
config_obj = settings_factory(configuration) 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_obj = settings_factory(configuration)
config_dict = config_obj.model_dump() config_dict = config_obj.model_dump()
validate(config_dict, validate_remote=False) 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@admin.com/email-confirmation",
"admin@admin.com/registration", "admin@admin.com/registration",
"compromised_password_check_failure", "compromised_password_check_failure",
"email_otp",
]: ]:
testclient.get(f"/admin/mail/{base}.html") testclient.get(f"/admin/mail/{base}.html")
testclient.get(f"/admin/mail/{base}.txt") testclient.get(f"/admin/mail/{base}.txt")

View file

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

View file

@ -186,7 +186,7 @@ def test_ldap_connection_no_remote(testclient, configuration):
validate(config_dict) 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_obj = settings_factory(configuration)
config_dict = config_obj.model_dump() config_dict = config_obj.model_dump()
validate(config_dict, validate_remote=True) validate(config_dict, validate_remote=True)

View file

@ -1,6 +1,9 @@
import datetime
import os import os
from unittest.mock import patch
import pytest import pytest
import smpplib
from babel.messages.frontend import compile_catalog from babel.messages.frontend import compile_catalog
from flask_webtest import TestApp from flask_webtest import TestApp
from jinja2 import FileSystemBytecodeCache from jinja2 import FileSystemBytecodeCache
@ -132,6 +135,12 @@ def configuration(smtpd):
"PASSWORD": smtpd.config.login_password, "PASSWORD": smtpd.config.login_password,
"FROM_ADDR": "admin@mydomain.test", "FROM_ADDR": "admin@mydomain.test",
}, },
"SMPP": {
"HOST": "localhost",
"PORT": 2775,
"LOGIN": "user",
"PASSWORD": "user",
},
"LOGGING": { "LOGGING": {
"version": 1, "version": 1,
"formatters": { "formatters": {
@ -199,6 +208,17 @@ def user(app, backend):
backend.delete(u) 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 @pytest.fixture
def admin(app, backend): def admin(app, backend):
u = models.User( u = models.User(
@ -234,6 +254,13 @@ def logged_user(user, testclient):
return user 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 @pytest.fixture
def logged_admin(admin, testclient): def logged_admin(admin, testclient):
with testclient.session_transaction() as sess: with testclient.session_transaction() as sess:
@ -275,3 +302,23 @@ def bar_group(app, admin, backend):
@pytest.fixture @pytest.fixture
def jpeg_photo(): 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" 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 import logging
from unittest import mock from unittest import mock
import pytest
from flask import current_app from flask import current_app
from flask import g 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") res = res.form.submit(name="action", value="edit-settings")
assert res.flashes == [("error", "Profile edition failed.")] assert res.flashes == [("error", "Profile edition failed.")]
res.mustcontain("Invalid choice(s): one or more data inputs could not be coerced.") 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 = [ oidc = [
{ name = "authlib" }, { name = "authlib" },
] ]
otp = [
{ name = "otpauth" },
{ name = "pillow" },
{ name = "qrcode" },
]
postgresql = [ postgresql = [
{ name = "passlib" }, { name = "passlib" },
{ name = "sqlalchemy", extra = ["postgresql-psycopg2binary"] }, { name = "sqlalchemy", extra = ["postgresql-psycopg2binary"] },
@ -162,6 +167,9 @@ postgresql = [
sentry = [ sentry = [
{ name = "sentry-sdk" }, { name = "sentry-sdk" },
] ]
sms = [
{ name = "smpplib" },
]
sqlite = [ sqlite = [
{ name = "passlib" }, { name = "passlib" },
{ name = "sqlalchemy" }, { name = "sqlalchemy" },
@ -215,15 +223,19 @@ requires-dist = [
{ name = "flask-babel", marker = "extra == 'front'", specifier = ">=4.0.0" }, { name = "flask-babel", marker = "extra == 'front'", specifier = ">=4.0.0" },
{ name = "flask-themer", marker = "extra == 'front'", specifier = ">=2.0.0" }, { name = "flask-themer", marker = "extra == 'front'", specifier = ">=2.0.0" },
{ name = "flask-wtf", specifier = ">=1.2.1" }, { 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 == 'mysql'", specifier = ">=1.7.4" },
{ name = "passlib", marker = "extra == 'postgresql'", specifier = ">=1.7.4" }, { name = "passlib", marker = "extra == 'postgresql'", specifier = ">=1.7.4" },
{ name = "passlib", marker = "extra == 'sqlite'", 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 = "pycountry", marker = "extra == 'front'", specifier = ">=23.12.7" },
{ name = "pydantic-settings", specifier = ">=2.0.3" }, { name = "pydantic-settings", specifier = ">=2.0.3" },
{ name = "python-ldap", marker = "extra == 'ldap'", specifier = ">=3.4.0" }, { name = "python-ldap", marker = "extra == 'ldap'", specifier = ">=3.4.0" },
{ name = "pytz", marker = "extra == 'front'", specifier = ">=2022.7" }, { name = "pytz", marker = "extra == 'front'", specifier = ">=2022.7" },
{ name = "qrcode", marker = "extra == 'otp'", specifier = ">=8.0" },
{ name = "requests", specifier = ">=2.32.3" }, { name = "requests", specifier = ">=2.32.3" },
{ name = "sentry-sdk", marker = "extra == 'sentry'", specifier = ">=2.0.0" }, { 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", marker = "extra == 'sqlite'", specifier = ">=2.0.23" },
{ name = "sqlalchemy", extras = ["mysql-connector"], marker = "extra == 'mysql'", 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" }, { 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 }, { 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]] [[package]]
name = "packaging" name = "packaging"
version = "24.2" 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 }, { 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]] [[package]]
name = "platformdirs" name = "platformdirs"
version = "4.3.6" 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 }, { 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]] [[package]]
name = "requests" name = "requests"
version = "2.32.3" 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 }, { 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]] [[package]]
name = "smtpdfix" name = "smtpdfix"
version = "0.5.2" version = "0.5.2"