diff --git a/.gitignore b/.gitignore index 5b989a27..943acb5e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .env *.sqlite +*.sqlite-journal *.pyc *.mo *.prof diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index beea5554..51b1922e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -15,10 +15,10 @@ repos: - id: end-of-file-fixer exclude: "\\.svg$|\\.map$|\\.min\\.css$|\\.min\\.js$|\\.po$|\\.pot$" - id: check-toml - - repo: https://github.com/PyCQA/docformatter - rev: v1.7.5 - hooks: - - id: docformatter + # - repo: https://github.com/PyCQA/docformatter + # rev: v1.7.5 + # hooks: + # - id: docformatter - repo: https://github.com/rtts/djhtml rev: 3.0.7 hooks: diff --git a/CHANGES.rst b/CHANGES.rst index 21b944c9..9dc13e12 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -3,6 +3,12 @@ Added ^^^^^ +- Multi-factor authentication :issue:`47` +- :attr:`~canaille.core.configuration.CoreSettings.OTP_METHOD` and + :attr:`~canaille.core.configuration.CoreSettings.EMAIL_OTP` and + :attr:`~canaille.core.configuration.CoreSettings.SMS_OTP` and + :attr:`~canaille.core.configuration.CoreSettings.SMPP` + :issue:`47` - Password compromission check :issue:`179` - :attr:`~canaille.core.configuration.CoreSettings.ADMIN_EMAIL` and :attr:`~canaille.core.configuration.CoreSettings.ENABLE_PASSWORD_COMPROMISSION_CHECK` and diff --git a/canaille/app/__init__.py b/canaille/app/__init__.py index dc1a3c32..f94ecf9d 100644 --- a/canaille/app/__init__.py +++ b/canaille/app/__init__.py @@ -2,6 +2,8 @@ import base64 import hashlib import json import re +from base64 import b64encode +from io import BytesIO from flask import current_app from flask import request @@ -66,3 +68,29 @@ class classproperty: def __get__(self, obj, owner): return self.f(owner) + + +def get_b64encoded_qr_image(data): + try: + import qrcode + except ImportError: + return None + + qr = qrcode.QRCode(version=1, box_size=10, border=5) + qr.add_data(data) + qr.make(fit=True) + img = qr.make_image(fill_color="black", back_color="white") + buffered = BytesIO() + img.save(buffered) + return b64encode(buffered.getvalue()).decode("utf-8") + + +def mask_email(email): + atpos = email.find("@") + if atpos > 0: + return email[0] + "#####" + email[atpos - 1 :] + return None + + +def mask_phone(phone): + return phone[0:3] + "#####" + phone[-2:] diff --git a/canaille/app/configuration.py b/canaille/app/configuration.py index 961239ed..48ccfada 100644 --- a/canaille/app/configuration.py +++ b/canaille/app/configuration.py @@ -1,3 +1,4 @@ +import importlib.util import os import smtplib import socket @@ -175,7 +176,7 @@ def validate(config, validate_remote=False): validate_keypair(config.get("CANAILLE_OIDC")) validate_theme(config["CANAILLE"]) validate_admin_email(config["CANAILLE"]) - + validate_otp_config(config["CANAILLE"]) if not validate_remote: return @@ -184,6 +185,8 @@ def validate(config, validate_remote=False): Backend.instance.validate(config) if smtp_config := config["CANAILLE"]["SMTP"]: validate_smtp_configuration(smtp_config) + if smpp_config := config["CANAILLE"]["SMPP"]: + validate_smpp_configuration(smpp_config) def validate_keypair(config): @@ -231,6 +234,31 @@ def validate_smtp_configuration(config): raise ConfigurationException(exc) from exc +def validate_smpp_configuration(config): + try: + import smpplib + except ImportError as exc: + raise ConfigurationException( + "You have configured a SMPP server but the 'sms' extra is not installed." + ) from exc + + host = config["HOST"] + port = config["PORT"] + try: + with smpplib.client.Client(host, port, allow_unknown_opt_params=True) as client: + client.connect() + if config["LOGIN"]: + client.bind_transmitter( + system_id=config["LOGIN"], password=config["PASSWORD"] + ) + except smpplib.exceptions.ConnectionError as exc: + raise ConfigurationException( + f"Could not connect to the SMPP server '{host}' on port '{port}'" + ) from exc + except smpplib.exceptions.UnknownCommandError as exc: # pragma: no cover + raise ConfigurationException(exc) from exc + + def validate_theme(config): if not os.path.exists(config["THEME"]) and not os.path.exists( os.path.join(ROOT, "themes", config["THEME"]) @@ -243,3 +271,25 @@ def validate_admin_email(config): raise ConfigurationException( "You must set an administration email if you want to check if users' passwords are compromised." ) + + +def validate_otp_config(config): + if ( + config["OTP_METHOD"] or config["EMAIL_OTP"] or config["SMS_OTP"] + ) and not importlib.util.find_spec("otpauth"): # pragma: no cover + raise ConfigurationException( + "You are trying to use OTP but the 'otp' extra is not installed." + ) + + if config["OTP_METHOD"] not in [None, "TOTP", "HOTP"]: + raise ConfigurationException("Invalid OTP method") + + if config["EMAIL_OTP"] and not config["SMTP"]: + raise ConfigurationException( + "Cannot activate email one-time password authentication without SMTP" + ) + + if config["SMS_OTP"] and not config["SMPP"]: + raise ConfigurationException( + "Cannot activate sms one-time password authentication without SMPP" + ) diff --git a/canaille/app/features.py b/canaille/app/features.py index 858ceeb9..f31e69e0 100644 --- a/canaille/app/features.py +++ b/canaille/app/features.py @@ -14,6 +14,22 @@ class Features: def has_password_recovery(self): return self.app.config["CANAILLE"]["ENABLE_PASSWORD_RECOVERY"] + @property + def otp_method(self): + return self.app.config["CANAILLE"]["OTP_METHOD"] + + @property + def has_otp(self): + return bool(self.app.config["CANAILLE"]["OTP_METHOD"]) + + @property + def has_email_otp(self): + return bool(self.app.config["CANAILLE"]["EMAIL_OTP"]) + + @property + def has_sms_otp(self): + return self.app.config["CANAILLE"]["SMS_OTP"] + @property def has_registration(self): return self.app.config["CANAILLE"]["ENABLE_REGISTRATION"] diff --git a/canaille/app/sms.py b/canaille/app/sms.py new file mode 100644 index 00000000..be94c7f6 --- /dev/null +++ b/canaille/app/sms.py @@ -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 diff --git a/canaille/backends/commands.py b/canaille/backends/commands.py index 84fad9e1..aea6b3f1 100644 --- a/canaille/backends/commands.py +++ b/canaille/backends/commands.py @@ -4,9 +4,11 @@ import json import typing import click +from flask import current_app from flask.cli import AppGroup from flask.cli import with_appcontext +from canaille.app import models from canaille.app.commands import with_backendcontext from canaille.app.models import MODELS from canaille.backends import Backend @@ -77,6 +79,8 @@ def register(cli): @cli.command(cls=ModelCommand, factory=factory, name=name, help=command_help) def factory_command(): ... + cli.add_command(reset_otp) + def serialize(instance): """Quick and dirty serialization method. @@ -281,3 +285,32 @@ def delete_factory(model): raise click.ClickException(exc) from exc return command + + +@click.command() +@with_appcontext +@with_backendcontext +@click.argument("identifier") +def reset_otp(identifier): + """Reset one-time password authentication for a user and display the + edited user in JSON format in the standard output. + + IDENTIFIER should be a user id or user_name + """ + + user = Backend.instance.get(models.User, identifier) + if not user: + raise click.ClickException(f"No user with id '{identifier}'") + + user.initialize_otp() + current_app.logger.security( + f"Reset one-time password authentication from CLI for {user.user_name}" + ) + + try: + Backend.instance.save(user) + except Exception as exc: # pragma: no cover + raise click.ClickException(exc) from exc + + output = json.dumps(serialize(user)) + click.echo(output) diff --git a/canaille/backends/ldap/configuration.py b/canaille/backends/ldap/configuration.py index 9dfaadf5..4dc36148 100644 --- a/canaille/backends/ldap/configuration.py +++ b/canaille/backends/ldap/configuration.py @@ -28,7 +28,7 @@ class LDAPSettings(BaseModel): For instance `ou=users,dc=mydomain,dc=tld`. """ - USER_CLASS: str = "inetOrgPerson" + USER_CLASS: list[str] = ["inetOrgPerson"] """The object class to use for creating new users.""" USER_RDN: str = "uid" diff --git a/canaille/backends/ldap/models.py b/canaille/backends/ldap/models.py index 0f0a3348..c9d4b729 100644 --- a/canaille/backends/ldap/models.py +++ b/canaille/backends/ldap/models.py @@ -1,4 +1,5 @@ import ldap.filter +from flask import current_app import canaille.core.models import canaille.oidc.models @@ -34,6 +35,11 @@ class User(canaille.core.models.User, LDAPObject): "organization": "o", "groups": "memberOf", "lock_date": "pwdEndTime", + "secret_token": "oathSecret", + "last_otp_login": "oathLastLogin", + "hotp_counter": "oathHOTPCounter", + "one_time_password": "oathTokenPIN", + "one_time_password_emission_date": "oathSecretTime", } def match_filter(self, filter): @@ -44,6 +50,9 @@ class User(canaille.core.models.User, LDAPObject): return super().match_filter(filter) def save(self): + if current_app.features.has_otp and not self.secret_token: + self.initialize_otp() + group_attr = self.python_attribute_to_ldap("groups") if group_attr not in self.changes: return diff --git a/canaille/backends/memory/backend.py b/canaille/backends/memory/backend.py index 36bf6bc4..ca0df63c 100644 --- a/canaille/backends/memory/backend.py +++ b/canaille/backends/memory/backend.py @@ -3,6 +3,9 @@ import datetime import uuid from typing import Any +from flask import current_app + +import canaille.backends.memory.models from canaille.backends import Backend @@ -124,6 +127,13 @@ class MemoryBackend(Backend): return results[0] if results else None def save(self, instance): + if ( + isinstance(instance, canaille.backends.memory.models.User) + and current_app.features.has_otp + and not instance.secret_token + ): + instance.initialize_otp() + if not instance.id: instance.id = str(uuid.uuid4()) diff --git a/canaille/backends/sql/backend.py b/canaille/backends/sql/backend.py index 69369bc1..04172b2c 100644 --- a/canaille/backends/sql/backend.py +++ b/canaille/backends/sql/backend.py @@ -110,6 +110,10 @@ class SQLBackend(Backend): ).scalar_one_or_none() def save(self, instance): + # run the instance save callback if existing + if hasattr(instance, "save"): + instance.save() + instance.last_modified = datetime.datetime.now(datetime.timezone.utc).replace( microsecond=0 ) diff --git a/canaille/backends/sql/models.py b/canaille/backends/sql/models.py index fc09a021..1c7e624e 100644 --- a/canaille/backends/sql/models.py +++ b/canaille/backends/sql/models.py @@ -2,6 +2,7 @@ import datetime import typing import uuid +from flask import current_app from sqlalchemy import Boolean from sqlalchemy import Column from sqlalchemy import ForeignKey @@ -100,6 +101,19 @@ class User(canaille.core.models.User, Base, SqlAlchemyModel): lock_date: Mapped[datetime.datetime] = mapped_column( TZDateTime(timezone=True), nullable=True ) + last_otp_login: Mapped[datetime.datetime] = mapped_column( + TZDateTime(timezone=True), nullable=True + ) + secret_token: Mapped[str] = mapped_column(String, nullable=True, unique=True) + hotp_counter: Mapped[int] = mapped_column(Integer, nullable=True) + one_time_password: Mapped[str] = mapped_column(String, nullable=True) + one_time_password_emission_date: Mapped[datetime.datetime] = mapped_column( + TZDateTime(timezone=True), nullable=True + ) + + def save(self): + if current_app.features.has_otp and not self.secret_token: + self.initialize_otp() class Group(canaille.core.models.Group, Base, SqlAlchemyModel): diff --git a/canaille/config.sample.toml b/canaille/config.sample.toml index bca3d6a5..51c6f27f 100644 --- a/canaille/config.sample.toml +++ b/canaille/config.sample.toml @@ -62,6 +62,23 @@ SECRET_KEY = "change me before you go in production" # recovery link by email. This option is true by default. # ENABLE_PASSWORD_RECOVERY = true +# If OTP_METHOD is defined, then users will need to authenticate themselves +# using a one-time password (OTP) via an authenticator app. +# Two options are supported : "TOTP" for time one-time password, +# and "HOTP" for HMAC-based one-time password. +# This option is deactivated by default. +# OTP_METHOD = "TOTP" + +# If EMAIL_OTP is true, then users will need to authenticate themselves +# via a one-time password sent to their primary email address. +# This option is false by default. +# EMAIL_OTP = false + +# If SMS_OTP is true, then users will need to authenticate themselves +# via a one-time password sent to their primary phone number. +# This option is false by default. +# SMS_OTP = false + # The validity duration of registration invitations, in seconds. # Defaults to 2 days # INVITATION_EXPIRATION = 172800 @@ -115,6 +132,7 @@ SECRET_KEY = "change me before you go in production" # USER_BASE = "ou=users,dc=mydomain,dc=tld" # The object class to use for creating new users +# Note : If you plan on using TOTP/HOTP authentication, use : USER_CLASS = ["inetOrgPerson", "oathHOTPToken"] # USER_CLASS = "inetOrgPerson" # The attribute to identify an object in the User dn. @@ -273,3 +291,11 @@ WRITE = [ # LOGIN = "" # PASSWORD = "" # FROM_ADDR = "admin@mydomain.example" + +# The SMPP server options. If not set, sms related features such as +# sms one-time passwords will be disabled. +[CANAILLE.SMPP] +# HOST = "localhost" +# PORT = 2775 +# LOGIN = "" +# PASSWORD = "" diff --git a/canaille/core/configuration.py b/canaille/core/configuration.py index ed7da812..64e21137 100644 --- a/canaille/core/configuration.py +++ b/canaille/core/configuration.py @@ -39,6 +39,24 @@ class SMTPSettings(BaseModel): """ +class SMPPSettings(BaseModel): + """The SMPP configuration. Belong in the ``CANAILLE.SMPP`` namespace. If + not set, sms related features such as sms one-time passwords will be disabled. + """ + + HOST: str | None = "localhost" + """The SMPP host.""" + + PORT: int | None = 2775 + """The SMPP port. Use 8775 for SMPP over TLS (recommended).""" + + LOGIN: str | None = None + """The SMPP login.""" + + PASSWORD: str | None = None + """The SMPP password.""" + + class Permission(str, Enum): """The permissions that can be assigned to users. @@ -248,6 +266,20 @@ class CoreSettings(BaseModel): """If :py:data:`False`, then users cannot ask for a password recovery link by email.""" + OTP_METHOD: str = None + """If OTP_METHOD is defined, then users will need to authenticate themselves + using a one-time password (OTP) via an authenticator app. + If set to :py:data:`TOTP`, the application will use time one-time passwords, + If set to :py:data:`HOTP`, the application will use HMAC-based one-time passwords.""" + + EMAIL_OTP: bool = False + """If :py:data:`True`, then users will need to authenticate themselves + via a one-time password sent to their primary email address.""" + + SMS_OTP: bool = False + """If :py:data:`True`, then users will need to authenticate themselves + via a one-time password sent to their primary phone number.""" + INVITATION_EXPIRATION: int = 172800 """The validity duration of registration invitations, in seconds. @@ -286,6 +318,13 @@ class CoreSettings(BaseModel): enabled. """ + SMPP: SMPPSettings | None = None + """The settings related to SMPP configuration. + + If unset, sms-related features like sms one-time passwords won't be + enabled. + """ + ACL: dict[str, ACLSettings] | None = {"DEFAULT": ACLSettings()} """Mapping of permission groups. See :class:`ACLSettings` for more details. diff --git a/canaille/core/endpoints/account.py b/canaille/core/endpoints/account.py index f2f0e75f..c0dc9fa0 100644 --- a/canaille/core/endpoints/account.py +++ b/canaille/core/endpoints/account.py @@ -274,7 +274,8 @@ def registration(data=None, hash=None): ) return redirect(url_for("core.account.index")) - if current_user(): + user = current_user() + if user: flash( _("You are already logged in, you cannot create an account."), "error", @@ -752,6 +753,23 @@ def profile_settings(user, edited_user): return profile_settings_edit(user, edited_user) + if ( + request.form.get("action") == "confirm-reset-otp" + and current_app.features.has_otp + ): + return render_template("modals/reset-otp.html", edited_user=edited_user) + + if request.form.get("action") == "reset-otp" and current_app.features.has_otp: + flash(_("One-time password authentication has been reset"), "success") + request_ip = request.remote_addr or "unknown IP" + current_app.logger.security( + f"Reset one-time password authentication for {edited_user.user_name} by {user.user_name} from {request_ip}" + ) + edited_user.initialize_otp() + Backend.instance.save(edited_user) + + return profile_settings_edit(user, edited_user) + abort(400, f"bad form action: {request.form.get('action')}") diff --git a/canaille/core/endpoints/admin.py b/canaille/core/endpoints/admin.py index 4d06fa16..c3f65d15 100644 --- a/canaille/core/endpoints/admin.py +++ b/canaille/core/endpoints/admin.py @@ -330,3 +330,35 @@ def compromised_password_check_failure_txt(user): hashed_password=hashed_password, user_email=user_email, ) + + +@bp.route("/mail/email_otp.html") +@permissions_needed("manage_oidc") +def email_otp_html(user): + base_url = url_for("core.account.index", _external=True) + otp = "000000" + + return render_template( + "mails/email_otp.html", + site_name=current_app.config["CANAILLE"]["NAME"], + site_url=base_url, + otp=otp, + logo=current_app.config["CANAILLE"]["LOGO"], + title=_("One-time password authentication on {website_name}").format( + website_name=current_app.config["CANAILLE"]["NAME"] + ), + ) + + +@bp.route("/mail/email_otp.txt") +@permissions_needed("manage_oidc") +def email_otp_txt(user): + base_url = url_for("core.account.index", _external=True) + otp = "000000" + + return render_template( + "mails/email_otp.txt", + site_name=current_app.config["CANAILLE"]["NAME"], + site_url=base_url, + otp=otp, + ) diff --git a/canaille/core/endpoints/auth.py b/canaille/core/endpoints/auth.py index d649605a..f4128328 100644 --- a/canaille/core/endpoints/auth.py +++ b/canaille/core/endpoints/auth.py @@ -1,3 +1,5 @@ +import datetime + from flask import Blueprint from flask import abort from flask import current_app @@ -8,6 +10,9 @@ from flask import session from flask import url_for from canaille.app import build_hash +from canaille.app import get_b64encoded_qr_image +from canaille.app import mask_email +from canaille.app import mask_phone from canaille.app.flask import smtp_needed from canaille.app.i18n import gettext as _ from canaille.app.session import current_user @@ -15,6 +20,8 @@ from canaille.app.session import login_user from canaille.app.session import logout_user from canaille.app.themes import render_template from canaille.backends import Backend +from canaille.core.endpoints.forms import TwoFactorForm +from canaille.core.models import SEND_NEW_OTP_DELAY from ..mails import send_password_initialization_mail from ..mails import send_password_reset_mail @@ -103,16 +110,33 @@ def password(): "password.html", form=form, username=session["attempt_login"] ) - current_app.logger.security( - f'Succeed login attempt for {session["attempt_login"]} from {request_ip}' - ) - del session["attempt_login"] - login_user(user) - flash( - _("Connection successful. Welcome %(user)s", user=user.formatted_name), - "success", - ) - return redirect(session.pop("redirect-after-login", url_for("core.account.index"))) + otp_methods = [] + if current_app.features.has_otp: + otp_methods.append(current_app.features.otp_method) # TOTP or HOTP + if current_app.features.has_email_otp: + otp_methods.append("EMAIL_OTP") + if current_app.features.has_sms_otp: + otp_methods.append("SMS_OTP") + + if otp_methods: + session["remaining_otp_methods"] = otp_methods + session["attempt_login_with_correct_password"] = session.pop("attempt_login") + return redirect_to_verify_2fa( + user, otp_methods[0], request_ip, url_for("core.auth.password") + ) + else: + current_app.logger.security( + f'Succeed login attempt for {session["attempt_login"]} from {request_ip}' + ) + del session["attempt_login"] + login_user(user) + flash( + _("Connection successful. Welcome %(user)s", user=user.formatted_name), + "success", + ) + return redirect( + session.pop("redirect-after-login", url_for("core.account.index")) + ) @bp.route("/logout") @@ -251,3 +275,253 @@ def reset(user, hash): ) return render_template("reset-password.html", form=form, user=user, hash=hash) + + +@bp.route("/setup-2fa") +def setup_two_factor_auth(): + if not current_app.features.has_otp: + abort(404) + + if current_user(): + return redirect( + url_for("core.account.profile_edition", edited_user=current_user()) + ) + + if "attempt_login_with_correct_password" not in session: + flash(_("Cannot remember the login you attempted to sign in with"), "warning") + return redirect(url_for("core.auth.login")) + + user = Backend.instance.get_user_from_login( + session["attempt_login_with_correct_password"] + ) + + uri = user.get_otp_authentication_setup_uri() + base64_qr_image = get_b64encoded_qr_image(uri) + return render_template( + "setup-2fa.html", + secret=user.secret_token, + qr_image=base64_qr_image, + username=user.user_name, + ) + + +@bp.route("/verify-2fa", methods=["GET", "POST"]) +def verify_two_factor_auth(): + if current_user(): + return redirect( + url_for("core.account.profile_edition", edited_user=current_user()) + ) + + if ( + "attempt_login_with_correct_password" not in session + or "remaining_otp_methods" not in session + or not session["remaining_otp_methods"] + ): + flash(_("Cannot remember the login you attempted to sign in with"), "warning") + return redirect(url_for("core.auth.login")) + + current_otp_method = session["remaining_otp_methods"][0] + if ( + (current_otp_method in ["TOTP", "HOTP"] and not current_app.features.has_otp) + or ( + current_otp_method == "EMAIL_OTP" and not current_app.features.has_email_otp + ) + or (current_otp_method == "SMS_OTP" and not current_app.features.has_sms_otp) + ): + abort(404) + + form = TwoFactorForm(request.form or None) + form.render_field_macro_file = "partial/login_field.html" + + if not request.form or form.form_control(): + return render_template( + "verify-2fa.html", + form=form, + username=session["attempt_login_with_correct_password"], + method=current_otp_method, + ) + + user = Backend.instance.get_user_from_login( + session["attempt_login_with_correct_password"] + ) + + if form.validate() and user.is_otp_valid(form.otp.data, current_otp_method): + session["remaining_otp_methods"].pop(0) + request_ip = request.remote_addr or "unknown IP" + if session["remaining_otp_methods"]: + return redirect_to_verify_2fa( + user, + session["remaining_otp_methods"][0], + request_ip, + url_for("core.auth.verify_two_factor_auth"), + ) + else: + user.last_otp_login = datetime.datetime.now(datetime.timezone.utc) + Backend.instance.save(user) + current_app.logger.security( + f'Succeed login attempt for {session["attempt_login_with_correct_password"]} from {request_ip}' + ) + del session["attempt_login_with_correct_password"] + login_user(user) + flash( + _( + "Connection successful. Welcome %(user)s", + user=user.formatted_name, + ), + "success", + ) + return redirect( + session.pop("redirect-after-login", url_for("core.account.index")) + ) + else: + flash( + "The one-time password you entered is invalid. Please try again", + "error", + ) + request_ip = request.remote_addr or "unknown IP" + current_app.logger.security( + f'Failed login attempt (wrong OTP) for {session["attempt_login_with_correct_password"]} from {request_ip}' + ) + return redirect(url_for("core.auth.verify_two_factor_auth")) + + +@bp.route("/send-mail-otp", methods=["POST"]) +def send_mail_otp(): + if not current_app.features.has_email_otp: + abort(404) + + if current_user(): + return redirect( + url_for("core.account.profile_edition", edited_user=current_user()) + ) + + if "attempt_login_with_correct_password" not in session: + flash(_("Cannot remember the login you attempted to sign in with"), "warning") + return redirect(url_for("core.auth.login")) + + user = Backend.instance.get_user_from_login( + session["attempt_login_with_correct_password"] + ) + + if user.can_send_new_otp(): + if user.generate_and_send_otp_mail(): + Backend.instance.save(user) + request_ip = request.remote_addr or "unknown IP" + current_app.logger.security( + f'Sent one-time password for {session["attempt_login_with_correct_password"]} to {user.emails[0]} from {request_ip}' + ) + flash( + "Code successfully sent!", + "success", + ) + else: + flash("Error while sending one-time password. Please try again.", "danger") + else: + flash( + f"Too many attempts. Please try again in {SEND_NEW_OTP_DELAY} seconds.", + "danger", + ) + + return redirect(url_for("core.auth.verify_two_factor_auth")) + + +@bp.route("/send-sms-otp", methods=["POST"]) +def send_sms_otp(): + if not current_app.features.has_sms_otp: + abort(404) + + if current_user(): + return redirect( + url_for("core.account.profile_edition", edited_user=current_user()) + ) + + if "attempt_login_with_correct_password" not in session: + flash(_("Cannot remember the login you attempted to sign in with"), "warning") + return redirect(url_for("core.auth.login")) + + user = Backend.instance.get_user_from_login( + session["attempt_login_with_correct_password"] + ) + + if user.can_send_new_otp(): + if user.generate_and_send_otp_sms(): + Backend.instance.save(user) + request_ip = request.remote_addr or "unknown IP" + current_app.logger.security( + f'Sent one-time password for {session["attempt_login_with_correct_password"]} to {user.phone_numbers[0]} from {request_ip}' + ) + flash( + "Code successfully sent!", + "success", + ) + else: + flash("Error while sending one-time password. Please try again.", "danger") + else: + flash( + f"Too many attempts. Please try again in {SEND_NEW_OTP_DELAY} seconds.", + "danger", + ) + + return redirect(url_for("core.auth.verify_two_factor_auth")) + + +def redirect_to_verify_2fa(user, otp_method, request_ip, fail_redirect_url): + if otp_method in ["HOTP", "TOTP"]: + if not user.last_otp_login: + flash( + "You have not enabled Two-Factor Authentication. Please enable it first to login.", + "info", + ) + return redirect(url_for("core.auth.setup_two_factor_auth")) + flash( + "Please enter the one-time password from your authenticator app.", + "info", + ) + return redirect(url_for("core.auth.verify_two_factor_auth")) + elif otp_method == "EMAIL_OTP": + if user.can_send_new_otp(): + if user.generate_and_send_otp_mail(): + Backend.instance.save(user) + flash( + f"A one-time password has been sent to your email address {mask_email(user.emails[0])}. Please enter it below to login.", + "info", + ) + current_app.logger.security( + f'Sent one-time password for {session["attempt_login_with_correct_password"]} to {user.emails[0]} from {request_ip}' + ) + return redirect(url_for("core.auth.verify_two_factor_auth")) + else: + flash( + "Error while sending one-time password. Please try again.", "danger" + ) + return redirect(fail_redirect_url) + else: + flash( + f"Too many attempts. Please try again in {SEND_NEW_OTP_DELAY} seconds.", + "danger", + ) + return redirect(fail_redirect_url) + else: # sms + if user.can_send_new_otp(): + if user.generate_and_send_otp_sms(): + Backend.instance.save(user) + flash( + f"A one-time password has been sent to your phone number {mask_phone(user.phone_numbers[0])}. Please enter it below to login.", + "info", + ) + current_app.logger.security( + f'Sent one-time password for {session["attempt_login_with_correct_password"]} to {user.phone_numbers[0]} from {request_ip}' + ) + return redirect(url_for("core.auth.verify_two_factor_auth")) + else: + flash( + "Error while sending one-time password. Please try again.", + "danger", + ) + return redirect(fail_redirect_url) + else: + flash( + f"Too many attempts. Please try again in {SEND_NEW_OTP_DELAY} seconds.", + "danger", + ) + return redirect(fail_redirect_url) diff --git a/canaille/core/endpoints/forms.py b/canaille/core/endpoints/forms.py index 43dacdab..5a019ee9 100644 --- a/canaille/core/endpoints/forms.py +++ b/canaille/core/endpoints/forms.py @@ -22,6 +22,7 @@ from canaille.app.i18n import gettext from canaille.app.i18n import lazy_gettext as _ from canaille.app.i18n import native_language_name_from_code from canaille.backends import Backend +from canaille.core.models import OTP_DIGITS def unique_user_name(form, field): @@ -480,3 +481,18 @@ class EmailConfirmationForm(Form): "autocorrect": "off", }, ) + + +class TwoFactorForm(Form): + otp = wtforms.StringField( + _("One-time password"), + validators=[ + wtforms.validators.DataRequired(), + wtforms.validators.Length(min=OTP_DIGITS, max=OTP_DIGITS), + ], + render_kw={ + "placeholder": _("123456"), + "spellcheck": "false", + "autocorrect": "off", + }, + ) diff --git a/canaille/core/mails.py b/canaille/core/mails.py index 0859314c..db9e28dc 100644 --- a/canaille/core/mails.py +++ b/canaille/core/mails.py @@ -249,3 +249,34 @@ def send_compromised_password_check_failure_mail( html=html_body, attachments=[(logo_cid, logo_filename, logo_raw)] if logo_filename else None, ) + + +def send_one_time_password_mail(mail, otp): + base_url = url_for("core.account.index", _external=True) + logo_cid, logo_filename, logo_raw = logo() + + subject = _("One-time password authentication on {website_name}").format( + website_name=current_app.config["CANAILLE"]["NAME"] + ) + text_body = render_template( + "mails/email_otp.txt", + site_name=current_app.config["CANAILLE"]["NAME"], + site_url=base_url, + otp=otp, + ) + html_body = render_template( + "mails/email_otp.html", + site_name=current_app.config["CANAILLE"]["NAME"], + site_url=base_url, + otp=otp, + logo=f"cid:{logo_cid[1:-1]}" if logo_cid else None, + title=subject, + ) + + return send_email( + subject=subject, + recipient=mail, + text=text_body, + html=html_body, + attachments=[(logo_cid, logo_filename, logo_raw)] if logo_filename else None, + ) diff --git a/canaille/core/models.py b/canaille/core/models.py index a532c76c..b5b543a8 100644 --- a/canaille/core/models.py +++ b/canaille/core/models.py @@ -1,4 +1,5 @@ import datetime +import secrets from typing import Annotated from typing import ClassVar @@ -6,6 +7,13 @@ from flask import current_app from canaille.backends.models import Model from canaille.core.configuration import Permission +from canaille.core.mails import send_one_time_password_mail +from canaille.core.sms import send_one_time_password_sms + +HOTP_LOOK_AHEAD_WINDOW = 10 +OTP_DIGITS = 6 +OTP_VALIDITY = 600 +SEND_NEW_OTP_DELAY = 10 class User(Model): @@ -241,6 +249,24 @@ class User(Model): lock_date: datetime.datetime | None = None """A DateTime indicating when the resource was locked.""" + last_otp_login: datetime.datetime | None = None + """A DateTime indicating when the user last logged in with a one-time password. + This attribute is currently used to check whether the user has activated one-time password authentication or not.""" + + secret_token: str | None = None + """Unique token generated for each user, used for + two-factor authentication.""" + + hotp_counter: int | None = None + """HMAC-based One Time Password counter, used for + two-factor authentication.""" + + one_time_password: str | None = None + """One time password used for email or sms two-factor authentication.""" + + one_time_password_emission_date: datetime.datetime | None = None + """A DateTime indicating when the user last emitted an email or sms one-time password.""" + _readable_fields = None _writable_fields = None _permissions = None @@ -258,10 +284,14 @@ class User(Model): def __getattribute__(self, name): prefix = "can_" - if name.startswith(prefix) and name != "can_read": - return self.can(name[len(prefix) :]) - return super().__getattribute__(name) + try: + return super().__getattribute__(name) + + except AttributeError: + if name.startswith(prefix) and name != "can_read": + return self.can(name[len(prefix) :]) + raise def can(self, *permissions: Permission): """Whether or not the user has the @@ -318,6 +348,110 @@ class User(Model): self._writable_fields |= set(details["WRITE"]) return self._writable_fields + def initialize_otp(self): + self.secret_token = secrets.token_hex(32) + self.last_otp_login = None + if current_app.features.otp_method == "HOTP": + self.hotp_counter = 1 + + def generate_otp(self, counter_delta=0): + import otpauth + + method = current_app.features.otp_method + if method == "TOTP": + totp = otpauth.TOTP(bytes(self.secret_token, "utf-8")) + return totp.string_code(totp.generate()) + elif method == "HOTP": + hotp = otpauth.HOTP(bytes(self.secret_token, "utf-8")) + return hotp.string_code(hotp.generate(self.hotp_counter + counter_delta)) + else: # pragma: no cover + raise RuntimeError("Invalid one-time password method") + + def generate_sms_or_mail_otp(self): + otp = string_code(secrets.randbelow(10**OTP_DIGITS), OTP_DIGITS) + self.one_time_password = otp + self.one_time_password_emission_date = datetime.datetime.now( + datetime.timezone.utc + ) + return otp + + def generate_and_send_otp_mail(self): + otp = self.generate_sms_or_mail_otp() + if send_one_time_password_mail(self.preferred_email, otp): + return otp + return False + + def generate_and_send_otp_sms(self): + otp = self.generate_sms_or_mail_otp() + if send_one_time_password_sms(self.phone_numbers[0], otp): + return otp + return False + + def get_otp_authentication_setup_uri(self): + import otpauth + + method = current_app.features.otp_method + if method == "TOTP": + return otpauth.TOTP(bytes(self.secret_token, "utf-8")).to_uri( + label=self.user_name, issuer=current_app.config["CANAILLE"]["NAME"] + ) + elif method == "HOTP": + return otpauth.HOTP(bytes(self.secret_token, "utf-8")).to_uri( + label=self.user_name, + issuer=current_app.config["CANAILLE"]["NAME"], + counter=self.hotp_counter, + ) + else: # pragma: no cover + raise RuntimeError("Invalid one-time password method") + + def is_otp_valid(self, user_otp, method): + if method == "TOTP": + return self.is_totp_valid(user_otp) + elif method == "HOTP": + return self.is_hotp_valid(user_otp) + elif method == "EMAIL_OTP" or method == "SMS_OTP": + return self.is_email_or_sms_otp_valid(user_otp) + else: # pragma: no cover + raise RuntimeError("Invalid one-time password method") + + def is_totp_valid(self, user_otp): + import otpauth + + return otpauth.TOTP(bytes(self.secret_token, "utf-8")).verify(user_otp) + + def is_hotp_valid(self, user_otp): + import otpauth + + counter = self.hotp_counter + is_valid = False + # if user token's counter is ahead of canaille's, try to catch up to it + while counter - self.hotp_counter <= HOTP_LOOK_AHEAD_WINDOW: + is_valid = otpauth.HOTP(bytes(self.secret_token, "utf-8")).verify( + user_otp, counter + ) + counter += 1 + if is_valid: + self.hotp_counter = counter + return True + return False + + def is_email_or_sms_otp_valid(self, user_otp): + return user_otp == self.one_time_password and self.is_otp_still_valid() + + def is_otp_still_valid(self): + return datetime.datetime.now( + datetime.timezone.utc + ) - self.one_time_password_emission_date < datetime.timedelta( + seconds=OTP_VALIDITY + ) + + def can_send_new_otp(self): + return self.one_time_password_emission_date is None or ( + datetime.datetime.now(datetime.timezone.utc) + - self.one_time_password_emission_date + >= datetime.timedelta(seconds=SEND_NEW_OTP_DELAY) + ) + class Group(Model): """User model, based on the `SCIM Group schema @@ -347,3 +481,15 @@ class Group(Model): """ description: str | None = None + + +def string_code(code: int, digit: int) -> str: + """Add leading 0 if the code length does not match the defined length. + + For instance, parameter ``digit=6``, but ``code=123``, this method would + return ``000123``:: + + >>> otp.string_code(123) + '000123' + """ + return f"{code:0{digit}}" diff --git a/canaille/core/sms.py b/canaille/core/sms.py new file mode 100644 index 00000000..dcfc4de0 --- /dev/null +++ b/canaille/core/sms.py @@ -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) diff --git a/canaille/core/templates/mails/admin.html b/canaille/core/templates/mails/admin.html index c628689e..a8bf5419 100644 --- a/canaille/core/templates/mails/admin.html +++ b/canaille/core/templates/mails/admin.html @@ -160,6 +160,18 @@ +
+
+
+ TXT + HTML +
+
+
+ {{ _("Email one-time password") }} +
+
+ {% endblock %} diff --git a/canaille/core/templates/mails/email_otp.html b/canaille/core/templates/mails/email_otp.html new file mode 100644 index 00000000..2a7468a1 --- /dev/null +++ b/canaille/core/templates/mails/email_otp.html @@ -0,0 +1,43 @@ + + + + + + + {{ title }} + + + + + + + + + + + + + + + +
+

+ {% if logo %} + {{ site_name }} + {% endif %} +
+ {% trans %}One-time password authentication{% endtrans %} +
+

+
+ {% 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 %} +

{% trans %}Your one-time password{% endtrans %}: +
{{ otp }} +

+
+ {{ site_name }} +
+ + diff --git a/canaille/core/templates/mails/email_otp.txt b/canaille/core/templates/mails/email_otp.txt new file mode 100644 index 00000000..4c01da55 --- /dev/null +++ b/canaille/core/templates/mails/email_otp.txt @@ -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 }} diff --git a/canaille/core/templates/modals/reset-otp.html b/canaille/core/templates/modals/reset-otp.html new file mode 100644 index 00000000..57c5c5d0 --- /dev/null +++ b/canaille/core/templates/modals/reset-otp.html @@ -0,0 +1,32 @@ +{% extends theme('base.html') %} + +{% block content %} + +{% endblock %} diff --git a/canaille/core/templates/partial/login_field.html b/canaille/core/templates/partial/login_field.html index 09cf4fe3..0d045b5a 100644 --- a/canaille/core/templates/partial/login_field.html +++ b/canaille/core/templates/partial/login_field.html @@ -6,4 +6,7 @@ {% if field.name == "password" %} {{ fui.render_field(field, icon="key", noindicator=true, **kwargs) }} {% endif %} + {% if field.name == "otp" %} + {{ fui.render_field(field, icon="key", noindicator=true, **kwargs) }} + {% endif %} {% endmacro %} diff --git a/canaille/core/templates/profile_settings.html b/canaille/core/templates/profile_settings.html index 6021e832..7b74caa8 100644 --- a/canaille/core/templates/profile_settings.html +++ b/canaille/core/templates/profile_settings.html @@ -140,6 +140,12 @@
+ {% if features.has_otp and user.can_manage_users %} + + {% endif %} + {% if features.has_account_lockability and "lock_date" in user.writable_fields and not edited_user.locked %} + + {% block buttons %} + + {% endblock %} +
+
+ +{% endblock %} +{% block script %} + +{% endblock %} diff --git a/canaille/core/templates/sms/sms_otp.txt b/canaille/core/templates/sms/sms_otp.txt new file mode 100644 index 00000000..4db313d2 --- /dev/null +++ b/canaille/core/templates/sms/sms_otp.txt @@ -0,0 +1 @@ +{% trans %}Here is the one-time password you requested for {{ website_name }}: {{ otp }}{% endtrans %} diff --git a/canaille/core/templates/verify-2fa.html b/canaille/core/templates/verify-2fa.html new file mode 100644 index 00000000..acb0f32b --- /dev/null +++ b/canaille/core/templates/verify-2fa.html @@ -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 %} +
+
+
+ {% block header %} + {% if logo_url %} + + {{ website_name }} + + {% else %} + + {% endif %} + +

+
+ {{ _("Sign in as %(username)s", username=username) }} +
+
{% trans %}One-time password authentication.{% endtrans %}
+

+ {% 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 %} + + {% endblock %} + {% endcall %} + {% if method == "EMAIL_OTP" or method == "SMS_OTP" %} + {% if method == "EMAIL_OTP" %} +
+ {% endif %} + {% if method == "SMS_OTP" %} + + {% endif %} + + +
+ {% endif %} +
+
+
+{% endblock %} +{% block script %} + +{% endblock %} diff --git a/canaille/static/css/base.css b/canaille/static/css/base.css index 33d21501..419db24c 100644 --- a/canaille/static/css/base.css +++ b/canaille/static/css/base.css @@ -78,6 +78,29 @@ footer a { margin: 1em 0 .28571429rem 0; } +.ui.label.token-label { + padding-left: 0; +} + +.send-code-button { + background: none!important; + border: none; + padding: 0!important; + text-decoration: underline; + cursor: pointer; + color: rgba(0, 0, 0, 0.87) !important; + } + +.send-code-button:disabled { + text-decoration: none; + cursor: auto; + color: rgba(0, 0, 0, 0.524) !important; +} + +#countdown { + font-size: 1.5em; +} + /* Fix button appearance for semantic-ui on webkit */ [type=button] { -webkit-appearance: none; @@ -261,4 +284,12 @@ select.ui.multiple.dropdown option[selected] { .ui.progress { background: #222222; } + .send-code-button { + color: rgba(255,255,255,.87) !important; + } + .send-code-button:disabled { + text-decoration: none; + cursor: auto; + color: rgba(255, 255, 255, 0.524) !important; + } } diff --git a/demo/conf/canaille-ldap.toml b/demo/conf/canaille-ldap.toml index d15d070c..d0cfbcd4 100644 --- a/demo/conf/canaille-ldap.toml +++ b/demo/conf/canaille-ldap.toml @@ -27,6 +27,7 @@ BIND_PW = "admin" TIMEOUT = 10 USER_BASE = "ou=users,dc=mydomain,dc=tld" GROUP_BASE = "ou=groups,dc=mydomain,dc=tld" +USER_CLASS = ["inetOrgPerson", "oathHOTPToken"] [CANAILLE.ACL.DEFAULT] PERMISSIONS = ["edit_self", "use_oidc"] diff --git a/demo/ldap-server.py b/demo/ldap-server.py index 7bda0dc3..1386392d 100644 --- a/demo/ldap-server.py +++ b/demo/ldap-server.py @@ -17,6 +17,7 @@ schemas = [ "ldif/memberof-config.ldif", "ldif/refint-config.ldif", "ldif/ppolicy-config.ldif", + "ldif/otp-config.ldif", "../canaille/backends/ldap/schemas/oauth2-openldap.ldif", ] diff --git a/demo/ldif/otp-config.ldif b/demo/ldif/otp-config.ldif new file mode 100644 index 00000000..1c93e103 --- /dev/null +++ b/demo/ldif/otp-config.ldif @@ -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 diff --git a/doc/features.rst b/doc/features.rst index 44f46c14..dd03bc9c 100644 --- a/doc/features.rst +++ b/doc/features.rst @@ -174,6 +174,17 @@ Password compromission check If :attr:`password compromission check feature ` 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 `. +.. _feature_multi_factor_authentication: + +Multi-factor authentication +=========================== + +If the :attr:`one-time password feature ` 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 `. +If a :class:`mail server ` is configured and the :attr:`email one-time password feature ` 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 ` is configured and the :attr:`sms one-time password feature ` is enabled, then users will need to authenticate themselves via a one-time password sent to their primary phone number. + Web interface ************* @@ -273,12 +284,14 @@ Logging Canaille writes :attr:`logs ` for every important event happening, to help administrators understand what is going on and debug funky situations. -The following security events are logged with the tag [SECURITY] for easy retrieval: +The following security events are logged with the log level "security" for easy retrieval : - Authentication attempt - Password update - Email update - Forgotten password mail sent to user +- One-time password mail sent to user +- Multi-factor authentication reset - Token emission - Token refresh - Token revokation diff --git a/doc/references/commands.rst b/doc/references/commands.rst index 236aba62..be7af188 100644 --- a/doc/references/commands.rst +++ b/doc/references/commands.rst @@ -58,3 +58,9 @@ For the sake of readability, it is omitted in the following examples. .. click:: doc.commands:delete :prog: canaille delete :nested: full + +.. _cli_reset_otp: + +.. click:: doc.commands:reset-otp + :prog: canaille reset-otp + :nested: full diff --git a/doc/references/configuration.rst b/doc/references/configuration.rst index dfa51be5..7d0fd643 100644 --- a/doc/references/configuration.rst +++ b/doc/references/configuration.rst @@ -74,6 +74,7 @@ Parameters .. autopydantic_settings:: canaille.core.configuration.CoreSettings .. autopydantic_settings:: canaille.core.configuration.SMTPSettings +.. autopydantic_settings:: canaille.core.configuration.SMPPSettings .. autopydantic_settings:: canaille.core.configuration.ACLSettings .. autoclass:: canaille.core.configuration.Permission :members: diff --git a/doc/tutorial/databases.rst b/doc/tutorial/databases.rst index 33c1d0a3..b99e1bdb 100644 --- a/doc/tutorial/databases.rst +++ b/doc/tutorial/databases.rst @@ -49,10 +49,15 @@ It is used when the ``CANAILLE_LDAP`` configuration parameter is defined. For in GROUP_BASE = "ou=groups,dc=mydomain,dc=tld" +If you want to use TOTP/HOTP authentication, you will need to add the ``oathHOTPToken`` class to the user : + +.. code-block:: toml + USER_CLASS = ["inetOrgPerson", "oathHOTPToken"] + You can find more details on the LDAP configuration in the :class:`dedicated section `. .. note :: - Currently, only the ``inetOrgPerson`` and ``groupOfNames`` schemas have been tested. + Currently, only the ``inetOrgPerson``, ``oathHOTPToken`` and ``groupOfNames`` schemas have been tested. If you want to use different schemas or LDAP servers, adaptations may be needed. Patches are welcome. @@ -108,3 +113,30 @@ You can adapt and load those configuration files with: # Adapt those commands according to your setup sudo ldapadd -Q -H ldapi:/// -Y EXTERNAL -f ppolicy-config.ldif sudo ldapadd -Q -H ldapi:/// -Y EXTERNAL -f ppolicy.ldif + +otp +~~~~~~~ + +If the `otp `_ 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"] diff --git a/doc/tutorial/install.rst b/doc/tutorial/install.rst index 13f4efaf..d4cdf9d9 100644 --- a/doc/tutorial/install.rst +++ b/doc/tutorial/install.rst @@ -32,6 +32,8 @@ Canaille provides different package options: - `postgresql` provides the dependencies to enable the PostgreSQL backend; - `mysql` provides the dependencies to enable the MySQL backend; - `sentry` provides sentry integration to watch Canaille exceptions; +- `otp` provides the dependencies to enable one-time password authentication; +- `sms` provides the dependencies to enable sms sending; - `all` provides all the extras above. They can be installed with: diff --git a/pyproject.toml b/pyproject.toml index 6fc31233..a924afb5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["hatchling", "babel", "setuptools >= 50.0.0; python_version>='3.12'"] +requires = ["hatchling", "babel", "setuptools >= 50.0.0; python_version<'3.13'"] build-backend = "hatchling.build" [project] @@ -81,6 +81,16 @@ mysql = [ "sqlalchemy-utils >= 0.41.1", ] +otp = [ + "otpauth>=2.1.1", + "pillow>=11.0.0", + "qrcode>=8.0", +] + +sms = [ + "smpplib>=2.2.3", +] + [project.urls] homepage = "https://canaille.yaal.coop" documentation = "https://canaille.readthedocs.io/en/latest/" diff --git a/tests/app/commands/test_check.py b/tests/app/commands/test_check.py index 5163580f..32867e40 100644 --- a/tests/app/commands/test_check.py +++ b/tests/app/commands/test_check.py @@ -1,7 +1,7 @@ from canaille.commands import cli -def test_check_command(testclient): +def test_check_command(testclient, mock_smpp): runner = testclient.app.test_cli_runner() res = runner.invoke(cli, ["check"]) assert res.exit_code == 0, res.stdout @@ -14,7 +14,7 @@ def test_check_command_fail(testclient): assert res.exit_code == 1, res.stdout -def test_check_command_no_smtp(testclient): +def test_check_command_no_smtp(testclient, mock_smpp): testclient.app.config["CANAILLE"]["SMTP"] = None runner = testclient.app.test_cli_runner() res = runner.invoke(cli, ["check"]) diff --git a/tests/app/commands/test_reset_otp.py b/tests/app/commands/test_reset_otp.py new file mode 100644 index 00000000..cbf979c7 --- /dev/null +++ b/tests/app/commands/test_reset_otp.py @@ -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" diff --git a/tests/app/test_configuration.py b/tests/app/test_configuration.py index 9886577c..521dc7df 100644 --- a/tests/app/test_configuration.py +++ b/tests/app/test_configuration.py @@ -142,7 +142,9 @@ def test_smtp_connection_remote_smtp_wrong_credentials( validate(config_dict, validate_remote=True) -def test_smtp_connection_remote_smtp_no_credentials(testclient, backend, configuration): +def test_smtp_connection_remote_smtp_no_credentials( + testclient, backend, configuration, mock_smpp +): del configuration["CANAILLE"]["SMTP"]["LOGIN"] del configuration["CANAILLE"]["SMTP"]["PASSWORD"] config_obj = settings_factory(configuration) @@ -229,3 +231,87 @@ def test_enable_password_compromission_check_with_and_without_admin_email( config_obj = settings_factory(configuration) config_dict = config_obj.model_dump() validate(config_dict, validate_remote=False) + + +def test_invalid_otp_option(configuration, backend): + config_obj = settings_factory(configuration) + config_dict = config_obj.model_dump() + + validate(config_dict, validate_remote=False) + + with pytest.raises( + ConfigurationException, + match=r"Invalid OTP method", + ): + configuration["CANAILLE"]["OTP_METHOD"] = "invalid" + config_obj = settings_factory(configuration) + config_dict = config_obj.model_dump() + validate(config_dict, validate_remote=False) + + +def test_email_otp_without_smtp(configuration, backend): + config_obj = settings_factory(configuration) + config_dict = config_obj.model_dump() + + validate(config_dict, validate_remote=False) + + with pytest.raises( + ConfigurationException, + match=r"Cannot activate email one-time password authentication without SMTP", + ): + configuration["CANAILLE"]["SMTP"] = None + configuration["CANAILLE"]["EMAIL_OTP"] = True + config_obj = settings_factory(configuration) + config_dict = config_obj.model_dump() + validate(config_dict, validate_remote=False) + + +def test_sms_otp_without_smpp(configuration, backend): + config_obj = settings_factory(configuration) + config_dict = config_obj.model_dump() + + validate(config_dict, validate_remote=False) + + with pytest.raises( + ConfigurationException, + match=r"Cannot activate sms one-time password authentication without SMPP", + ): + configuration["CANAILLE"]["SMPP"] = None + configuration["CANAILLE"]["SMS_OTP"] = True + config_obj = settings_factory(configuration) + config_dict = config_obj.model_dump() + validate(config_dict, validate_remote=False) + + +def test_smpp_connection_remote_smpp_unreachable(testclient, backend, configuration): + configuration["CANAILLE"]["SMPP"] = { + "HOST": "invalid-smpp.com", + "PORT": 2775, + "LOGIN": "user", + "PASSWORD": "user", + } + config_obj = settings_factory(configuration) + config_dict = config_obj.model_dump() + with pytest.raises( + ConfigurationException, + match=r"Could not connect to the SMPP server 'invalid-smpp.com' on port '2775'", + ): + validate(config_dict, validate_remote=True) + + +def test_validate_without_smpp(configuration, backend, mock_smpp): + configuration["CANAILLE"]["SMPP"] = None + config_obj = settings_factory(configuration) + config_dict = config_obj.model_dump() + + validate(config_dict, validate_remote=True) + + +def test_smpp_connection_remote_smpp_no_credentials( + testclient, backend, configuration, mock_smpp +): + del configuration["CANAILLE"]["SMPP"]["LOGIN"] + del configuration["CANAILLE"]["SMPP"]["PASSWORD"] + config_obj = settings_factory(configuration) + config_dict = config_obj.model_dump() + validate(config_dict, validate_remote=True) diff --git a/tests/app/test_mails.py b/tests/app/test_mails.py index 219d41f8..6f910c1e 100644 --- a/tests/app/test_mails.py +++ b/tests/app/test_mails.py @@ -179,6 +179,7 @@ def test_mail_debug_pages(testclient, logged_admin): "admin/admin@admin.com/email-confirmation", "admin@admin.com/registration", "compromised_password_check_failure", + "email_otp", ]: testclient.get(f"/admin/mail/{base}.html") testclient.get(f"/admin/mail/{base}.txt") diff --git a/tests/backends/ldap/fixtures.py b/tests/backends/ldap/fixtures.py index 4953e582..8310009e 100644 --- a/tests/backends/ldap/fixtures.py +++ b/tests/backends/ldap/fixtures.py @@ -16,6 +16,7 @@ def slapd_server(): "demo/ldif/memberof-config.ldif", "demo/ldif/ppolicy-config.ldif", "demo/ldif/ppolicy.ldif", + "demo/ldif/otp-config.ldif", "canaille/backends/ldap/schemas/oauth2-openldap.ldif", "demo/ldif/bootstrap-users-tree.ldif", "demo/ldif/bootstrap-oidc-tree.ldif", @@ -38,6 +39,7 @@ def ldap_configuration(configuration, slapd_server): "USER_FILTER": "(uid={{ login }})", "GROUP_BASE": "ou=groups", "TIMEOUT": 0.1, + "USER_CLASS": ["inetOrgPerson", "oathHOTPToken"], } yield configuration del configuration["CANAILLE_LDAP"] diff --git a/tests/backends/ldap/test_utils.py b/tests/backends/ldap/test_utils.py index faa47b8e..afdc416a 100644 --- a/tests/backends/ldap/test_utils.py +++ b/tests/backends/ldap/test_utils.py @@ -186,7 +186,7 @@ def test_ldap_connection_no_remote(testclient, configuration): validate(config_dict) -def test_ldap_connection_remote(testclient, configuration, backend): +def test_ldap_connection_remote(testclient, configuration, backend, mock_smpp): config_obj = settings_factory(configuration) config_dict = config_obj.model_dump() validate(config_dict, validate_remote=True) diff --git a/tests/conftest.py b/tests/conftest.py index 3cdd656e..680e7c08 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,9 @@ +import datetime import os +from unittest.mock import patch import pytest +import smpplib from babel.messages.frontend import compile_catalog from flask_webtest import TestApp from jinja2 import FileSystemBytecodeCache @@ -132,6 +135,12 @@ def configuration(smtpd): "PASSWORD": smtpd.config.login_password, "FROM_ADDR": "admin@mydomain.test", }, + "SMPP": { + "HOST": "localhost", + "PORT": 2775, + "LOGIN": "user", + "PASSWORD": "user", + }, "LOGGING": { "version": 1, "formatters": { @@ -199,6 +208,17 @@ def user(app, backend): backend.delete(u) +@pytest.fixture +def user_otp(app, user, backend): + user.secret_token = ( + "fefe9b106b8a033d3fcb4de16ac06b2cae71c7d95a41b158c30380d1bc35b2ba" + ) + user.hotp_counter = 1 + user.last_otp_login = datetime.datetime(2020, 1, 1) + backend.save(user) + yield user + + @pytest.fixture def admin(app, backend): u = models.User( @@ -234,6 +254,13 @@ def logged_user(user, testclient): return user +@pytest.fixture +def logged_user_otp(user_otp, testclient): + with testclient.session_transaction() as sess: + sess["user_id"] = [user_otp.id] + return user_otp + + @pytest.fixture def logged_admin(admin, testclient): with testclient.session_transaction() as sess: @@ -275,3 +302,23 @@ def bar_group(app, admin, backend): @pytest.fixture def jpeg_photo(): return b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x01\x01,\x01,\x00\x00\xff\xfe\x00\x13Created with GIMP\xff\xe2\x02\xb0ICC_PROFILE\x00\x01\x01\x00\x00\x02\xa0lcms\x040\x00\x00mntrRGB XYZ \x07\xe5\x00\x0c\x00\x08\x00\x0f\x00\x16\x00(acspAPPL\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xf6\xd6\x00\x01\x00\x00\x00\x00\xd3-lcms\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\rdesc\x00\x00\x01 \x00\x00\x00@cprt\x00\x00\x01`\x00\x00\x006wtpt\x00\x00\x01\x98\x00\x00\x00\x14chad\x00\x00\x01\xac\x00\x00\x00,rXYZ\x00\x00\x01\xd8\x00\x00\x00\x14bXYZ\x00\x00\x01\xec\x00\x00\x00\x14gXYZ\x00\x00\x02\x00\x00\x00\x00\x14rTRC\x00\x00\x02\x14\x00\x00\x00 gTRC\x00\x00\x02\x14\x00\x00\x00 bTRC\x00\x00\x02\x14\x00\x00\x00 chrm\x00\x00\x024\x00\x00\x00$dmnd\x00\x00\x02X\x00\x00\x00$dmdd\x00\x00\x02|\x00\x00\x00$mluc\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x0cenUS\x00\x00\x00$\x00\x00\x00\x1c\x00G\x00I\x00M\x00P\x00 \x00b\x00u\x00i\x00l\x00t\x00-\x00i\x00n\x00 \x00s\x00R\x00G\x00Bmluc\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x0cenUS\x00\x00\x00\x1a\x00\x00\x00\x1c\x00P\x00u\x00b\x00l\x00i\x00c\x00 \x00D\x00o\x00m\x00a\x00i\x00n\x00\x00XYZ \x00\x00\x00\x00\x00\x00\xf6\xd6\x00\x01\x00\x00\x00\x00\xd3-sf32\x00\x00\x00\x00\x00\x01\x0cB\x00\x00\x05\xde\xff\xff\xf3%\x00\x00\x07\x93\x00\x00\xfd\x90\xff\xff\xfb\xa1\xff\xff\xfd\xa2\x00\x00\x03\xdc\x00\x00\xc0nXYZ \x00\x00\x00\x00\x00\x00o\xa0\x00\x008\xf5\x00\x00\x03\x90XYZ \x00\x00\x00\x00\x00\x00$\x9f\x00\x00\x0f\x84\x00\x00\xb6\xc4XYZ \x00\x00\x00\x00\x00\x00b\x97\x00\x00\xb7\x87\x00\x00\x18\xd9para\x00\x00\x00\x00\x00\x03\x00\x00\x00\x02ff\x00\x00\xf2\xa7\x00\x00\rY\x00\x00\x13\xd0\x00\x00\n[chrm\x00\x00\x00\x00\x00\x03\x00\x00\x00\x00\xa3\xd7\x00\x00T|\x00\x00L\xcd\x00\x00\x99\x9a\x00\x00&g\x00\x00\x0f\\mluc\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x0cenUS\x00\x00\x00\x08\x00\x00\x00\x1c\x00G\x00I\x00M\x00Pmluc\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x0cenUS\x00\x00\x00\x08\x00\x00\x00\x1c\x00s\x00R\x00G\x00B\xff\xdb\x00C\x00\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\xff\xdb\x00C\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\xff\xc2\x00\x11\x08\x00\x01\x00\x01\x03\x01\x11\x00\x02\x11\x01\x03\x11\x01\xff\xc4\x00\x14\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\t\xff\xc4\x00\x14\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xda\x00\x0c\x03\x01\x00\x02\x10\x03\x10\x00\x00\x01\x7f\x0f\xff\xc4\x00\x14\x10\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xda\x00\x08\x01\x01\x00\x01\x05\x02\x7f\xff\xc4\x00\x14\x11\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xda\x00\x08\x01\x03\x01\x01?\x01\x7f\xff\xc4\x00\x14\x11\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xda\x00\x08\x01\x02\x01\x01?\x01\x7f\xff\xc4\x00\x14\x10\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xda\x00\x08\x01\x01\x00\x06?\x02\x7f\xff\xc4\x00\x14\x10\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xda\x00\x08\x01\x01\x00\x01?!\x7f\xff\xda\x00\x0c\x03\x01\x00\x02\x00\x03\x00\x00\x00\x10\x1f\xff\xc4\x00\x14\x11\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xda\x00\x08\x01\x03\x01\x01?\x10\x7f\xff\xc4\x00\x14\x11\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xda\x00\x08\x01\x02\x01\x01?\x10\x7f\xff\xc4\x00\x14\x10\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xda\x00\x08\x01\x01\x00\x01?\x10\x7f\xff\xd9" + + +pdu = None + + +@pytest.fixture() +def mock_smpp(testclient): + class MockSmppClient(smpplib.client.Client): + def connect(self): + pass + + def bind_transmitter(self, system_id, password): + pass + + def send_pdu(self, p): + global pdu + pdu = p + + with patch("smpplib.client.Client", MockSmppClient): + yield diff --git a/tests/core/test_email_sms_otp.py b/tests/core/test_email_sms_otp.py new file mode 100644 index 00000000..6611c15c --- /dev/null +++ b/tests/core/test_email_sms_otp.py @@ -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" diff --git a/tests/core/test_profile_settings.py b/tests/core/test_profile_settings.py index a90f3f7f..c4d290f2 100644 --- a/tests/core/test_profile_settings.py +++ b/tests/core/test_profile_settings.py @@ -2,6 +2,7 @@ import datetime import logging from unittest import mock +import pytest from flask import current_app from flask import g @@ -733,3 +734,32 @@ def test_edition_invalid_group(testclient, logged_admin, user, foo_group): res = res.form.submit(name="action", value="edit-settings") assert res.flashes == [("error", "Profile edition failed.")] res.mustcontain("Invalid choice(s): one or more data inputs could not be coerced.") + + +@pytest.mark.parametrize("otp_method", ["TOTP", "HOTP"]) +def test_account_reset_otp( + testclient, backend, caplog, logged_admin, user_otp, otp_method +): + testclient.app.config["CANAILLE"]["OTP_METHOD"] = otp_method + + old_token = user_otp.secret_token + assert old_token is not None + assert user_otp.last_otp_login is not None + + res = testclient.get("/profile/user/settings") + res.mustcontain("Reset one-time password authentication") + + res = res.form.submit(name="action", value="confirm-reset-otp") + res = res.form.submit(name="action", value="reset-otp") + user = backend.get(models.User, id=user_otp.id) + assert user.secret_token is not None + assert user.secret_token != old_token + assert user.last_otp_login is None + if otp_method == "HOTP": + assert user.hotp_counter == 1 + res.mustcontain("One-time password authentication has been reset") + assert ( + "canaille", + logging.SECURITY, + "Reset one-time password authentication for user by admin from unknown IP", + ) in caplog.record_tuples diff --git a/tests/core/test_totp_hotp.py b/tests/core/test_totp_hotp.py new file mode 100644 index 00000000..7fd21e74 --- /dev/null +++ b/tests/core/test_totp_hotp.py @@ -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 diff --git a/uv.lock b/uv.lock index 046d3dbd..c54537d5 100644 --- a/uv.lock +++ b/uv.lock @@ -153,6 +153,11 @@ mysql = [ oidc = [ { name = "authlib" }, ] +otp = [ + { name = "otpauth" }, + { name = "pillow" }, + { name = "qrcode" }, +] postgresql = [ { name = "passlib" }, { name = "sqlalchemy", extra = ["postgresql-psycopg2binary"] }, @@ -162,6 +167,9 @@ postgresql = [ sentry = [ { name = "sentry-sdk" }, ] +sms = [ + { name = "smpplib" }, +] sqlite = [ { name = "passlib" }, { name = "sqlalchemy" }, @@ -215,15 +223,19 @@ requires-dist = [ { name = "flask-babel", marker = "extra == 'front'", specifier = ">=4.0.0" }, { name = "flask-themer", marker = "extra == 'front'", specifier = ">=2.0.0" }, { name = "flask-wtf", specifier = ">=1.2.1" }, + { name = "otpauth", marker = "extra == 'otp'", specifier = ">=2.1.1" }, { name = "passlib", marker = "extra == 'mysql'", specifier = ">=1.7.4" }, { name = "passlib", marker = "extra == 'postgresql'", specifier = ">=1.7.4" }, { name = "passlib", marker = "extra == 'sqlite'", specifier = ">=1.7.4" }, + { name = "pillow", marker = "extra == 'otp'", specifier = ">=11.0.0" }, { name = "pycountry", marker = "extra == 'front'", specifier = ">=23.12.7" }, { name = "pydantic-settings", specifier = ">=2.0.3" }, { name = "python-ldap", marker = "extra == 'ldap'", specifier = ">=3.4.0" }, { name = "pytz", marker = "extra == 'front'", specifier = ">=2022.7" }, + { name = "qrcode", marker = "extra == 'otp'", specifier = ">=8.0" }, { name = "requests", specifier = ">=2.32.3" }, { name = "sentry-sdk", marker = "extra == 'sentry'", specifier = ">=2.0.0" }, + { name = "smpplib", marker = "extra == 'sms'", specifier = ">=2.2.3" }, { name = "sqlalchemy", marker = "extra == 'sqlite'", specifier = ">=2.0.23" }, { name = "sqlalchemy", extras = ["mysql-connector"], marker = "extra == 'mysql'", specifier = ">=2.0.23" }, { name = "sqlalchemy", extras = ["postgresql-psycopg2binary"], marker = "extra == 'postgresql'", specifier = ">=2.0.23" }, @@ -1020,6 +1032,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314 }, ] +[[package]] +name = "otpauth" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/28/bc2428f072cc3ceb9eecead6e41e2914fab4f0d00c1d5ad2e5f018eb080a/otpauth-2.1.1.tar.gz", hash = "sha256:e09ed63a01b35acd78b7bb0f981fdcdbd1d9d9c01be1a3e0d7024c65d0e74e2a", size = 6138 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/7b/37eb712ecf1c9472baff30eb16d1c87ea92e396312de3ac1962da22f3a3b/otpauth-2.1.1-py3-none-any.whl", hash = "sha256:b08d2bf30c0118e4d6f5bd41c5d537761d119c001da4b69cba0e0ae7b93d7793", size = 6551 }, +] + [[package]] name = "packaging" version = "24.2" @@ -1038,6 +1059,73 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3b/a4/ab6b7589382ca3df236e03faa71deac88cae040af60c071a78d254a62172/passlib-1.7.4-py2.py3-none-any.whl", hash = "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1", size = 525554 }, ] +[[package]] +name = "pillow" +version = "11.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/26/0d95c04c868f6bdb0c447e3ee2de5564411845e36a858cfd63766bc7b563/pillow-11.0.0.tar.gz", hash = "sha256:72bacbaf24ac003fea9bff9837d1eedb6088758d41e100c1552930151f677739", size = 46737780 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/fb/a6ce6836bd7fd93fbf9144bf54789e02babc27403b50a9e1583ee877d6da/pillow-11.0.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:6619654954dc4936fcff82db8eb6401d3159ec6be81e33c6000dfd76ae189947", size = 3154708 }, + { url = "https://files.pythonhosted.org/packages/6a/1d/1f51e6e912d8ff316bb3935a8cda617c801783e0b998bf7a894e91d3bd4c/pillow-11.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b3c5ac4bed7519088103d9450a1107f76308ecf91d6dabc8a33a2fcfb18d0fba", size = 2979223 }, + { url = "https://files.pythonhosted.org/packages/90/83/e2077b0192ca8a9ef794dbb74700c7e48384706467067976c2a95a0f40a1/pillow-11.0.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a65149d8ada1055029fcb665452b2814fe7d7082fcb0c5bed6db851cb69b2086", size = 4183167 }, + { url = "https://files.pythonhosted.org/packages/0e/74/467af0146970a98349cdf39e9b79a6cc8a2e7558f2c01c28a7b6b85c5bda/pillow-11.0.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88a58d8ac0cc0e7f3a014509f0455248a76629ca9b604eca7dc5927cc593c5e9", size = 4283912 }, + { url = "https://files.pythonhosted.org/packages/85/b1/d95d4f7ca3a6c1ae120959605875a31a3c209c4e50f0029dc1a87566cf46/pillow-11.0.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:c26845094b1af3c91852745ae78e3ea47abf3dbcd1cf962f16b9a5fbe3ee8488", size = 4195815 }, + { url = "https://files.pythonhosted.org/packages/41/c3/94f33af0762ed76b5a237c5797e088aa57f2b7fa8ee7932d399087be66a8/pillow-11.0.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:1a61b54f87ab5786b8479f81c4b11f4d61702830354520837f8cc791ebba0f5f", size = 4366117 }, + { url = "https://files.pythonhosted.org/packages/ba/3c/443e7ef01f597497268899e1cca95c0de947c9bbf77a8f18b3c126681e5d/pillow-11.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:674629ff60030d144b7bca2b8330225a9b11c482ed408813924619c6f302fdbb", size = 4278607 }, + { url = "https://files.pythonhosted.org/packages/26/95/1495304448b0081e60c0c5d63f928ef48bb290acee7385804426fa395a21/pillow-11.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:598b4e238f13276e0008299bd2482003f48158e2b11826862b1eb2ad7c768b97", size = 4410685 }, + { url = "https://files.pythonhosted.org/packages/45/da/861e1df971ef0de9870720cb309ca4d553b26a9483ec9be3a7bf1de4a095/pillow-11.0.0-cp310-cp310-win32.whl", hash = "sha256:9a0f748eaa434a41fccf8e1ee7a3eed68af1b690e75328fd7a60af123c193b50", size = 2249185 }, + { url = "https://files.pythonhosted.org/packages/d5/4e/78f7c5202ea2a772a5ab05069c1b82503e6353cd79c7e474d4945f4b82c3/pillow-11.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:a5629742881bcbc1f42e840af185fd4d83a5edeb96475a575f4da50d6ede337c", size = 2566726 }, + { url = "https://files.pythonhosted.org/packages/77/e4/6e84eada35cbcc646fc1870f72ccfd4afacb0fae0c37ffbffe7f5dc24bf1/pillow-11.0.0-cp310-cp310-win_arm64.whl", hash = "sha256:ee217c198f2e41f184f3869f3e485557296d505b5195c513b2bfe0062dc537f1", size = 2254585 }, + { url = "https://files.pythonhosted.org/packages/f0/eb/f7e21b113dd48a9c97d364e0915b3988c6a0b6207652f5a92372871b7aa4/pillow-11.0.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:1c1d72714f429a521d8d2d018badc42414c3077eb187a59579f28e4270b4b0fc", size = 3154705 }, + { url = "https://files.pythonhosted.org/packages/25/b3/2b54a1d541accebe6bd8b1358b34ceb2c509f51cb7dcda8687362490da5b/pillow-11.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:499c3a1b0d6fc8213519e193796eb1a86a1be4b1877d678b30f83fd979811d1a", size = 2979222 }, + { url = "https://files.pythonhosted.org/packages/20/12/1a41eddad8265c5c19dda8fb6c269ce15ee25e0b9f8f26286e6202df6693/pillow-11.0.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c8b2351c85d855293a299038e1f89db92a2f35e8d2f783489c6f0b2b5f3fe8a3", size = 4190220 }, + { url = "https://files.pythonhosted.org/packages/a9/9b/8a8c4d07d77447b7457164b861d18f5a31ae6418ef5c07f6f878fa09039a/pillow-11.0.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f4dba50cfa56f910241eb7f883c20f1e7b1d8f7d91c750cd0b318bad443f4d5", size = 4291399 }, + { url = "https://files.pythonhosted.org/packages/fc/e4/130c5fab4a54d3991129800dd2801feeb4b118d7630148cd67f0e6269d4c/pillow-11.0.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:5ddbfd761ee00c12ee1be86c9c0683ecf5bb14c9772ddbd782085779a63dd55b", size = 4202709 }, + { url = "https://files.pythonhosted.org/packages/39/63/b3fc299528d7df1f678b0666002b37affe6b8751225c3d9c12cf530e73ed/pillow-11.0.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:45c566eb10b8967d71bf1ab8e4a525e5a93519e29ea071459ce517f6b903d7fa", size = 4372556 }, + { url = "https://files.pythonhosted.org/packages/c6/a6/694122c55b855b586c26c694937d36bb8d3b09c735ff41b2f315c6e66a10/pillow-11.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b4fd7bd29610a83a8c9b564d457cf5bd92b4e11e79a4ee4716a63c959699b306", size = 4287187 }, + { url = "https://files.pythonhosted.org/packages/ba/a9/f9d763e2671a8acd53d29b1e284ca298bc10a595527f6be30233cdb9659d/pillow-11.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:cb929ca942d0ec4fac404cbf520ee6cac37bf35be479b970c4ffadf2b6a1cad9", size = 4418468 }, + { url = "https://files.pythonhosted.org/packages/6e/0e/b5cbad2621377f11313a94aeb44ca55a9639adabcaaa073597a1925f8c26/pillow-11.0.0-cp311-cp311-win32.whl", hash = "sha256:006bcdd307cc47ba43e924099a038cbf9591062e6c50e570819743f5607404f5", size = 2249249 }, + { url = "https://files.pythonhosted.org/packages/dc/83/1470c220a4ff06cd75fc609068f6605e567ea51df70557555c2ab6516b2c/pillow-11.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:52a2d8323a465f84faaba5236567d212c3668f2ab53e1c74c15583cf507a0291", size = 2566769 }, + { url = "https://files.pythonhosted.org/packages/52/98/def78c3a23acee2bcdb2e52005fb2810ed54305602ec1bfcfab2bda6f49f/pillow-11.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:16095692a253047fe3ec028e951fa4221a1f3ed3d80c397e83541a3037ff67c9", size = 2254611 }, + { url = "https://files.pythonhosted.org/packages/1c/a3/26e606ff0b2daaf120543e537311fa3ae2eb6bf061490e4fea51771540be/pillow-11.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d2c0a187a92a1cb5ef2c8ed5412dd8d4334272617f532d4ad4de31e0495bd923", size = 3147642 }, + { url = "https://files.pythonhosted.org/packages/4f/d5/1caabedd8863526a6cfa44ee7a833bd97f945dc1d56824d6d76e11731939/pillow-11.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:084a07ef0821cfe4858fe86652fffac8e187b6ae677e9906e192aafcc1b69903", size = 2978999 }, + { url = "https://files.pythonhosted.org/packages/d9/ff/5a45000826a1aa1ac6874b3ec5a856474821a1b59d838c4f6ce2ee518fe9/pillow-11.0.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8069c5179902dcdce0be9bfc8235347fdbac249d23bd90514b7a47a72d9fecf4", size = 4196794 }, + { url = "https://files.pythonhosted.org/packages/9d/21/84c9f287d17180f26263b5f5c8fb201de0f88b1afddf8a2597a5c9fe787f/pillow-11.0.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f02541ef64077f22bf4924f225c0fd1248c168f86e4b7abdedd87d6ebaceab0f", size = 4300762 }, + { url = "https://files.pythonhosted.org/packages/84/39/63fb87cd07cc541438b448b1fed467c4d687ad18aa786a7f8e67b255d1aa/pillow-11.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:fcb4621042ac4b7865c179bb972ed0da0218a076dc1820ffc48b1d74c1e37fe9", size = 4210468 }, + { url = "https://files.pythonhosted.org/packages/7f/42/6e0f2c2d5c60f499aa29be14f860dd4539de322cd8fb84ee01553493fb4d/pillow-11.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:00177a63030d612148e659b55ba99527803288cea7c75fb05766ab7981a8c1b7", size = 4381824 }, + { url = "https://files.pythonhosted.org/packages/31/69/1ef0fb9d2f8d2d114db982b78ca4eeb9db9a29f7477821e160b8c1253f67/pillow-11.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8853a3bf12afddfdf15f57c4b02d7ded92c7a75a5d7331d19f4f9572a89c17e6", size = 4296436 }, + { url = "https://files.pythonhosted.org/packages/44/ea/dad2818c675c44f6012289a7c4f46068c548768bc6c7f4e8c4ae5bbbc811/pillow-11.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3107c66e43bda25359d5ef446f59c497de2b5ed4c7fdba0894f8d6cf3822dafc", size = 4429714 }, + { url = "https://files.pythonhosted.org/packages/af/3a/da80224a6eb15bba7a0dcb2346e2b686bb9bf98378c0b4353cd88e62b171/pillow-11.0.0-cp312-cp312-win32.whl", hash = "sha256:86510e3f5eca0ab87429dd77fafc04693195eec7fd6a137c389c3eeb4cfb77c6", size = 2249631 }, + { url = "https://files.pythonhosted.org/packages/57/97/73f756c338c1d86bb802ee88c3cab015ad7ce4b838f8a24f16b676b1ac7c/pillow-11.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:8ec4a89295cd6cd4d1058a5e6aec6bf51e0eaaf9714774e1bfac7cfc9051db47", size = 2567533 }, + { url = "https://files.pythonhosted.org/packages/0b/30/2b61876e2722374558b871dfbfcbe4e406626d63f4f6ed92e9c8e24cac37/pillow-11.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:27a7860107500d813fcd203b4ea19b04babe79448268403172782754870dac25", size = 2254890 }, + { url = "https://files.pythonhosted.org/packages/63/24/e2e15e392d00fcf4215907465d8ec2a2f23bcec1481a8ebe4ae760459995/pillow-11.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:bcd1fb5bb7b07f64c15618c89efcc2cfa3e95f0e3bcdbaf4642509de1942a699", size = 3147300 }, + { url = "https://files.pythonhosted.org/packages/43/72/92ad4afaa2afc233dc44184adff289c2e77e8cd916b3ddb72ac69495bda3/pillow-11.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0e038b0745997c7dcaae350d35859c9715c71e92ffb7e0f4a8e8a16732150f38", size = 2978742 }, + { url = "https://files.pythonhosted.org/packages/9e/da/c8d69c5bc85d72a8523fe862f05ababdc52c0a755cfe3d362656bb86552b/pillow-11.0.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ae08bd8ffc41aebf578c2af2f9d8749d91f448b3bfd41d7d9ff573d74f2a6b2", size = 4194349 }, + { url = "https://files.pythonhosted.org/packages/cd/e8/686d0caeed6b998351d57796496a70185376ed9c8ec7d99e1d19ad591fc6/pillow-11.0.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d69bfd8ec3219ae71bcde1f942b728903cad25fafe3100ba2258b973bd2bc1b2", size = 4298714 }, + { url = "https://files.pythonhosted.org/packages/ec/da/430015cec620d622f06854be67fd2f6721f52fc17fca8ac34b32e2d60739/pillow-11.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:61b887f9ddba63ddf62fd02a3ba7add935d053b6dd7d58998c630e6dbade8527", size = 4208514 }, + { url = "https://files.pythonhosted.org/packages/44/ae/7e4f6662a9b1cb5f92b9cc9cab8321c381ffbee309210940e57432a4063a/pillow-11.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:c6a660307ca9d4867caa8d9ca2c2658ab685de83792d1876274991adec7b93fa", size = 4380055 }, + { url = "https://files.pythonhosted.org/packages/74/d5/1a807779ac8a0eeed57f2b92a3c32ea1b696e6140c15bd42eaf908a261cd/pillow-11.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:73e3a0200cdda995c7e43dd47436c1548f87a30bb27fb871f352a22ab8dcf45f", size = 4296751 }, + { url = "https://files.pythonhosted.org/packages/38/8c/5fa3385163ee7080bc13026d59656267daaaaf3c728c233d530e2c2757c8/pillow-11.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fba162b8872d30fea8c52b258a542c5dfd7b235fb5cb352240c8d63b414013eb", size = 4430378 }, + { url = "https://files.pythonhosted.org/packages/ca/1d/ad9c14811133977ff87035bf426875b93097fb50af747793f013979facdb/pillow-11.0.0-cp313-cp313-win32.whl", hash = "sha256:f1b82c27e89fffc6da125d5eb0ca6e68017faf5efc078128cfaa42cf5cb38798", size = 2249588 }, + { url = "https://files.pythonhosted.org/packages/fb/01/3755ba287dac715e6afdb333cb1f6d69740a7475220b4637b5ce3d78cec2/pillow-11.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:8ba470552b48e5835f1d23ecb936bb7f71d206f9dfeee64245f30c3270b994de", size = 2567509 }, + { url = "https://files.pythonhosted.org/packages/c0/98/2c7d727079b6be1aba82d195767d35fcc2d32204c7a5820f822df5330152/pillow-11.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:846e193e103b41e984ac921b335df59195356ce3f71dcfd155aa79c603873b84", size = 2254791 }, + { url = "https://files.pythonhosted.org/packages/eb/38/998b04cc6f474e78b563716b20eecf42a2fa16a84589d23c8898e64b0ffd/pillow-11.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4ad70c4214f67d7466bea6a08061eba35c01b1b89eaa098040a35272a8efb22b", size = 3150854 }, + { url = "https://files.pythonhosted.org/packages/13/8e/be23a96292113c6cb26b2aa3c8b3681ec62b44ed5c2bd0b258bd59503d3c/pillow-11.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:6ec0d5af64f2e3d64a165f490d96368bb5dea8b8f9ad04487f9ab60dc4bb6003", size = 2982369 }, + { url = "https://files.pythonhosted.org/packages/97/8a/3db4eaabb7a2ae8203cd3a332a005e4aba00067fc514aaaf3e9721be31f1/pillow-11.0.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c809a70e43c7977c4a42aefd62f0131823ebf7dd73556fa5d5950f5b354087e2", size = 4333703 }, + { url = "https://files.pythonhosted.org/packages/28/ac/629ffc84ff67b9228fe87a97272ab125bbd4dc462745f35f192d37b822f1/pillow-11.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:4b60c9520f7207aaf2e1d94de026682fc227806c6e1f55bba7606d1c94dd623a", size = 4412550 }, + { url = "https://files.pythonhosted.org/packages/d6/07/a505921d36bb2df6868806eaf56ef58699c16c388e378b0dcdb6e5b2fb36/pillow-11.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:1e2688958a840c822279fda0086fec1fdab2f95bf2b717b66871c4ad9859d7e8", size = 4461038 }, + { url = "https://files.pythonhosted.org/packages/d6/b9/fb620dd47fc7cc9678af8f8bd8c772034ca4977237049287e99dda360b66/pillow-11.0.0-cp313-cp313t-win32.whl", hash = "sha256:607bbe123c74e272e381a8d1957083a9463401f7bd01287f50521ecb05a313f8", size = 2253197 }, + { url = "https://files.pythonhosted.org/packages/df/86/25dde85c06c89d7fc5db17940f07aae0a56ac69aa9ccb5eb0f09798862a8/pillow-11.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5c39ed17edea3bc69c743a8dd3e9853b7509625c2462532e62baa0732163a904", size = 2572169 }, + { url = "https://files.pythonhosted.org/packages/51/85/9c33f2517add612e17f3381aee7c4072779130c634921a756c97bc29fb49/pillow-11.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:75acbbeb05b86bc53cbe7b7e6fe00fbcf82ad7c684b3ad82e3d711da9ba287d3", size = 2256828 }, + { url = "https://files.pythonhosted.org/packages/36/57/42a4dd825eab762ba9e690d696d894ba366e06791936056e26e099398cda/pillow-11.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1187739620f2b365de756ce086fdb3604573337cc28a0d3ac4a01ab6b2d2a6d2", size = 3119239 }, + { url = "https://files.pythonhosted.org/packages/98/f7/25f9f9e368226a1d6cf3507081a1a7944eddd3ca7821023377043f5a83c8/pillow-11.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:fbbcb7b57dc9c794843e3d1258c0fbf0f48656d46ffe9e09b63bbd6e8cd5d0a2", size = 2950803 }, + { url = "https://files.pythonhosted.org/packages/59/01/98ead48a6c2e31e6185d4c16c978a67fe3ccb5da5c2ff2ba8475379bb693/pillow-11.0.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d203af30149ae339ad1b4f710d9844ed8796e97fda23ffbc4cc472968a47d0b", size = 3281098 }, + { url = "https://files.pythonhosted.org/packages/51/c0/570255b2866a0e4d500a14f950803a2ec273bac7badc43320120b9262450/pillow-11.0.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21a0d3b115009ebb8ac3d2ebec5c2982cc693da935f4ab7bb5c8ebe2f47d36f2", size = 3323665 }, + { url = "https://files.pythonhosted.org/packages/0e/75/689b4ec0483c42bfc7d1aacd32ade7a226db4f4fac57c6fdcdf90c0731e3/pillow-11.0.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:73853108f56df97baf2bb8b522f3578221e56f646ba345a372c78326710d3830", size = 3310533 }, + { url = "https://files.pythonhosted.org/packages/3d/30/38bd6149cf53da1db4bad304c543ade775d225961c4310f30425995cb9ec/pillow-11.0.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:e58876c91f97b0952eb766123bfef372792ab3f4e3e1f1a2267834c2ab131734", size = 3414886 }, + { url = "https://files.pythonhosted.org/packages/ec/3d/c32a51d848401bd94cabb8767a39621496491ee7cd5199856b77da9b18ad/pillow-11.0.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:224aaa38177597bb179f3ec87eeefcce8e4f85e608025e9cfac60de237ba6316", size = 2567508 }, +] + [[package]] name = "platformdirs" version = "4.3.6" @@ -1506,6 +1594,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446 }, ] +[[package]] +name = "qrcode" +version = "8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d7/db/6fc9631cac1327f609d2c8ae3680ecd987a2e97472437f2de7ead1235156/qrcode-8.0.tar.gz", hash = "sha256:025ce2b150f7fe4296d116ee9bad455a6643ab4f6e7dce541613a4758cbce347", size = 42743 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/ab/df8d889fd01139db68ae9e5cb5c8f0ea016823559a6ecb427582d52b07dc/qrcode-8.0-py3-none-any.whl", hash = "sha256:9fc05f03305ad27a709eb742cf3097fa19e6f6f93bb9e2f039c0979190f6f1b1", size = 45710 }, +] + [[package]] name = "requests" version = "2.32.3" @@ -1573,6 +1673,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/45/8d/f0451d9d733de0ebe6ec4b461ea051374c4a53f161798deea3353222d151/slapd-0.1.5-py3-none-any.whl", hash = "sha256:b6aa9e75c6fb280e76877864a101564e3088e3ee211a74d11bedcfb6aec18923", size = 22124 }, ] +[[package]] +name = "smpplib" +version = "2.2.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dd/c0/9427c820f5514a5b43f98f99b746334fcae71f8385466766c3510b07d706/smpplib-2.2.3.tar.gz", hash = "sha256:5215a95b0538d26f189600e0982b31da8281f7453cd6e2862c5b21e3e1002331", size = 24819 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/cd/9af2de1d421807ed8c683346435d61dc6edcccc2ef30d22751ede3b96bd5/smpplib-2.2.3-py3-none-any.whl", hash = "sha256:62abc429602bd3f4e351ee786eed514f9d12fffc05d1b8d2dc715d96a733820a", size = 28875 }, +] + [[package]] name = "smtpdfix" version = "0.5.2"