forked from Github-Mirrors/canaille
feat : Added sms OTP authentication and multi-factor authentication methods chaining
This commit is contained in:
parent
6d48ce9043
commit
e1d70ef8cd
33 changed files with 1281 additions and 3031 deletions
|
@ -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
|
||||
|
|
|
@ -5,7 +5,6 @@ import re
|
|||
from base64 import b64encode
|
||||
from io import BytesIO
|
||||
|
||||
import qrcode
|
||||
from flask import current_app
|
||||
from flask import request
|
||||
|
||||
|
@ -72,6 +71,11 @@ class classproperty:
|
|||
|
||||
|
||||
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)
|
||||
|
@ -86,3 +90,7 @@ def mask_email(email):
|
|||
if atpos > 0:
|
||||
return email[0] + "#####" + email[atpos - 1 :]
|
||||
return None
|
||||
|
||||
|
||||
def mask_phone(phone):
|
||||
return phone[0:3] + "#####" + phone[-2:]
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import importlib.util
|
||||
import os
|
||||
import smtplib
|
||||
import socket
|
||||
|
@ -175,9 +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_method(config["CANAILLE"])
|
||||
validate_mail_otp(config["CANAILLE"])
|
||||
|
||||
validate_otp_config(config["CANAILLE"])
|
||||
if not validate_remote:
|
||||
return
|
||||
|
||||
|
@ -186,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):
|
||||
|
@ -233,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"])
|
||||
|
@ -247,13 +273,23 @@ def validate_admin_email(config):
|
|||
)
|
||||
|
||||
|
||||
def validate_otp_method(config):
|
||||
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")
|
||||
|
||||
|
||||
def validate_mail_otp(config):
|
||||
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"
|
||||
)
|
||||
|
|
|
@ -26,6 +26,10 @@ class Features:
|
|||
def has_email_otp(self):
|
||||
return bool(self.app.config["CANAILLE"]["EMAIL_OTP"])
|
||||
|
||||
@property
|
||||
def has_sms_otp(self):
|
||||
return self.app.config["CANAILLE"]["SMS_OTP"]
|
||||
|
||||
@property
|
||||
def has_registration(self):
|
||||
return self.app.config["CANAILLE"]["ENABLE_REGISTRATION"]
|
||||
|
|
51
canaille/app/sms.py
Normal file
51
canaille/app/sms.py
Normal file
|
@ -0,0 +1,51 @@
|
|||
from flask import current_app
|
||||
|
||||
|
||||
def send_sms(recipient, sender, text):
|
||||
try:
|
||||
import smpplib.client
|
||||
import smpplib.consts
|
||||
import smpplib.gsm
|
||||
except ImportError as exc:
|
||||
raise RuntimeError(
|
||||
"You are trying to send a sms but the 'sms' extra is not installed."
|
||||
) from exc
|
||||
|
||||
port = current_app.config["CANAILLE"]["SMPP"]["PORT"]
|
||||
host = current_app.config["CANAILLE"]["SMPP"]["HOST"]
|
||||
login = current_app.config["CANAILLE"]["SMPP"]["LOGIN"]
|
||||
password = current_app.config["CANAILLE"]["SMPP"]["PASSWORD"]
|
||||
|
||||
try:
|
||||
client = smpplib.client.Client(host, port, allow_unknown_opt_params=True)
|
||||
client.connect()
|
||||
try:
|
||||
client.bind_transmitter(system_id=login, password=password)
|
||||
pdu = client.send_message(
|
||||
source_addr_ton=smpplib.consts.SMPP_TON_INTL,
|
||||
source_addr=sender,
|
||||
dest_addr_ton=smpplib.consts.SMPP_TON_INTL,
|
||||
destination_addr=recipient,
|
||||
short_message=bytes(text, "utf-8"),
|
||||
)
|
||||
current_app.logger.debug(pdu.generate())
|
||||
finally:
|
||||
if client.state in [
|
||||
smpplib.consts.SMPP_CLIENT_STATE_BOUND_TX
|
||||
]: # pragma: no cover
|
||||
# if bound to transmitter
|
||||
try:
|
||||
client.unbind()
|
||||
except smpplib.exceptions.UnknownCommandError:
|
||||
try:
|
||||
client.unbind()
|
||||
except smpplib.exceptions.PDUError:
|
||||
pass
|
||||
except Exception as exc:
|
||||
current_app.logger.warning(f"Could not send sms: {exc}")
|
||||
return False
|
||||
finally:
|
||||
if client: # pragma: no branch
|
||||
client.disconnect()
|
||||
|
||||
return True
|
|
@ -74,6 +74,11 @@ SECRET_KEY = "change me before you go in production"
|
|||
# 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
|
||||
|
@ -286,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 = ""
|
||||
|
|
|
@ -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.
|
||||
|
||||
|
@ -258,6 +276,10 @@ class CoreSettings(BaseModel):
|
|||
"""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.
|
||||
|
||||
|
@ -296,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.
|
||||
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -12,6 +12,7 @@ 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
|
||||
|
@ -20,6 +21,7 @@ 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
|
||||
|
@ -108,37 +110,20 @@ def password():
|
|||
"password.html", form=form, username=session["attempt_login"]
|
||||
)
|
||||
|
||||
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")
|
||||
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_to_verify_2fa(
|
||||
user, otp_methods[0], request_ip, url_for("core.auth.password")
|
||||
)
|
||||
return redirect(url_for("core.auth.verify_two_factor_auth"))
|
||||
elif current_app.features.has_email_otp:
|
||||
try:
|
||||
user.generate_otp_mail()
|
||||
Backend.instance.save(user)
|
||||
session["attempt_login_with_correct_password"] = session.pop(
|
||||
"attempt_login"
|
||||
)
|
||||
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}'
|
||||
)
|
||||
except Exception: # pragma: no cover
|
||||
flash("One-time password generation failed. Please try again.", "danger")
|
||||
return redirect(url_for("core.auth.password"))
|
||||
return redirect(url_for("core.auth.verify_two_factor_auth"))
|
||||
else:
|
||||
current_app.logger.security(
|
||||
f'Succeed login attempt for {session["attempt_login"]} from {request_ip}'
|
||||
|
@ -322,44 +307,57 @@ def setup_two_factor_auth():
|
|||
|
||||
@bp.route("/verify-2fa", methods=["GET", "POST"])
|
||||
def verify_two_factor_auth():
|
||||
if not current_app.features.has_otp and 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:
|
||||
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():
|
||||
method = "mail" if current_app.features.has_email_otp else "authenticator"
|
||||
return render_template(
|
||||
"verify-2fa.html",
|
||||
form=form,
|
||||
username=session["attempt_login_with_correct_password"],
|
||||
method=method,
|
||||
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):
|
||||
welcome_message = (
|
||||
"Two-factor authentication setup successful."
|
||||
if current_app.features.has_otp and user.last_otp_login is None
|
||||
else "Connection successful."
|
||||
)
|
||||
try:
|
||||
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)
|
||||
request_ip = request.remote_addr or "unknown IP"
|
||||
current_app.logger.security(
|
||||
f'Succeed login attempt for {session["attempt_login_with_correct_password"]} from {request_ip}'
|
||||
)
|
||||
|
@ -367,18 +365,14 @@ def verify_two_factor_auth():
|
|||
login_user(user)
|
||||
flash(
|
||||
_(
|
||||
"%(welcome_message)s Welcome %(user)s",
|
||||
"Connection successful. Welcome %(user)s",
|
||||
user=user.formatted_name,
|
||||
welcome_message=welcome_message,
|
||||
),
|
||||
"success",
|
||||
)
|
||||
return redirect(
|
||||
session.pop("redirect-after-login", url_for("core.account.index"))
|
||||
)
|
||||
except Exception: # pragma: no cover
|
||||
flash("Two-factor authentication setup failed. Please try again.", "danger")
|
||||
return redirect(url_for("core.auth.verify_two_factor_auth"))
|
||||
else:
|
||||
flash(
|
||||
"The one-time password you entered is invalid. Please try again",
|
||||
|
@ -409,19 +403,125 @@ def send_mail_otp():
|
|||
session["attempt_login_with_correct_password"]
|
||||
)
|
||||
|
||||
try:
|
||||
user.generate_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}'
|
||||
)
|
||||
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(
|
||||
"Code successfully sent!",
|
||||
"success",
|
||||
f"Too many attempts. Please try again in {SEND_NEW_OTP_DELAY} seconds.",
|
||||
"danger",
|
||||
)
|
||||
except Exception: # pragma: no cover
|
||||
flash("One-time password generation failed. Please try again.", "danger")
|
||||
return redirect(url_for("core.auth.verify_two_factor_auth"))
|
||||
|
||||
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)
|
||||
|
|
|
@ -22,8 +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
|
||||
|
||||
OTP_LENGTH = 6
|
||||
from canaille.core.models import OTP_DIGITS
|
||||
|
||||
|
||||
def unique_user_name(form, field):
|
||||
|
@ -489,7 +488,7 @@ class TwoFactorForm(Form):
|
|||
_("One-time password"),
|
||||
validators=[
|
||||
wtforms.validators.DataRequired(),
|
||||
wtforms.validators.Length(min=OTP_LENGTH, max=OTP_LENGTH),
|
||||
wtforms.validators.Length(min=OTP_DIGITS, max=OTP_DIGITS),
|
||||
],
|
||||
render_kw={
|
||||
"placeholder": _("123456"),
|
||||
|
|
|
@ -3,16 +3,17 @@ import secrets
|
|||
from typing import Annotated
|
||||
from typing import ClassVar
|
||||
|
||||
import otpauth
|
||||
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):
|
||||
|
@ -261,10 +262,10 @@ class User(Model):
|
|||
two-factor authentication."""
|
||||
|
||||
one_time_password: str | None = None
|
||||
"""One time password used for email two-factor authentication."""
|
||||
"""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 one-time password."""
|
||||
"""A DateTime indicating when the user last emitted an email or sms one-time password."""
|
||||
|
||||
_readable_fields = None
|
||||
_writable_fields = None
|
||||
|
@ -283,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
|
||||
|
@ -350,6 +355,8 @@ class User(Model):
|
|||
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"))
|
||||
|
@ -360,16 +367,29 @@ class User(Model):
|
|||
else: # pragma: no cover
|
||||
raise RuntimeError("Invalid one-time password method")
|
||||
|
||||
def generate_otp_mail(self):
|
||||
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
|
||||
)
|
||||
send_one_time_password_mail(self.emails[0], otp)
|
||||
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(
|
||||
|
@ -384,26 +404,24 @@ class User(Model):
|
|||
else: # pragma: no cover
|
||||
raise RuntimeError("Invalid one-time password method")
|
||||
|
||||
def is_otp_valid(self, user_otp):
|
||||
method = None
|
||||
if current_app.features.has_otp:
|
||||
method = current_app.features.otp_method
|
||||
elif current_app.features.has_email_otp: # pragma: no branch
|
||||
method = "EMAIL_OTP"
|
||||
|
||||
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":
|
||||
return self.is_email_otp_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
|
||||
|
@ -417,19 +435,23 @@ class User(Model):
|
|||
return True
|
||||
return False
|
||||
|
||||
def is_email_otp_valid(self, user_otp):
|
||||
return (
|
||||
user_otp == self.one_time_password
|
||||
and self.is_one_time_password_still_valid()
|
||||
)
|
||||
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_one_time_password_still_valid(self):
|
||||
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
|
||||
|
|
15
canaille/core/sms.py
Normal file
15
canaille/core/sms.py
Normal file
|
@ -0,0 +1,15 @@
|
|||
from flask import current_app
|
||||
|
||||
from canaille.app.sms import send_sms
|
||||
from canaille.app.themes import render_template
|
||||
|
||||
|
||||
def send_one_time_password_sms(phone_number, otp):
|
||||
website_name = current_app.config["CANAILLE"]["NAME"]
|
||||
|
||||
text_body = render_template(
|
||||
"sms/sms_otp.txt",
|
||||
website_name=website_name,
|
||||
otp=otp,
|
||||
)
|
||||
return send_sms(recipient=phone_number, sender=website_name, text=text_body)
|
|
@ -160,6 +160,18 @@
|
|||
</div>
|
||||
|
||||
|
||||
<div class="item">
|
||||
<div class="right floated content">
|
||||
<div class="ui buttons">
|
||||
<a class="ui button primary" href="{{ url_for("core.admin.email_otp_txt") }}">TXT</a>
|
||||
<a class="ui button primary" href="{{ url_for("core.admin.email_otp_html") }}">HTML</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="middle aligned content">
|
||||
{{ _("Email one-time password") }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
1
canaille/core/templates/sms/sms_otp.txt
Normal file
1
canaille/core/templates/sms/sms_otp.txt
Normal file
|
@ -0,0 +1 @@
|
|||
{% trans %}Here is the one-time password you requested for {{ website_name }}: {{ otp }}{% endtrans %}
|
|
@ -42,13 +42,38 @@
|
|||
</div>
|
||||
{% endblock %}
|
||||
{% endcall %}
|
||||
{% if method == "mail" %}
|
||||
<form action="{{ url_for('core.auth.send_mail_otp') }}" method="post">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button class="send-code-button" type="submit">Didn't receive the mail? Click here to send a new code.</button>
|
||||
{% if method == "EMAIL_OTP" or method == "SMS_OTP" %}
|
||||
{% if method == "EMAIL_OTP" %}
|
||||
<form action="{{ url_for('core.auth.send_mail_otp') }}" method="post">
|
||||
{% endif %}
|
||||
{% if method == "SMS_OTP" %}
|
||||
<form action="{{ url_for('core.auth.send_sms_otp') }}" method="post">
|
||||
{% endif %}
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button class="send-code-button" type="submit">Didn't receive the code? Click here to send another one. <span id="countdown"></span></button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block script %}
|
||||
<script>
|
||||
countDownDuration = 10;
|
||||
countdown = document.getElementById("countdown");
|
||||
countdown.innerHTML = countDownDuration;
|
||||
button = countdown.parentNode;
|
||||
button.setAttribute("disabled", "");
|
||||
|
||||
x = setInterval(() => {
|
||||
countDownDuration--;
|
||||
if (countDownDuration <= 0) {
|
||||
clearInterval(x);
|
||||
countdown.innerHTML = "";
|
||||
button.removeAttribute("disabled");
|
||||
} else {
|
||||
countdown.innerHTML = countDownDuration;
|
||||
}
|
||||
}, 1000);
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
|
|
@ -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,17 +284,12 @@ select.ui.multiple.dropdown option[selected] {
|
|||
.ui.progress {
|
||||
background: #222222;
|
||||
}
|
||||
|
||||
.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(255,255,255,.87) !important;
|
||||
}
|
||||
}
|
||||
.send-code-button:disabled {
|
||||
text-decoration: none;
|
||||
cursor: auto;
|
||||
color: rgba(255, 255, 255, 0.524) !important;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -74,12 +74,3 @@ DYNAMIC_CLIENT_REGISTRATION_OPEN = true
|
|||
DYNAMIC_CLIENT_REGISTRATION_TOKENS = [
|
||||
"xxxxxxx-yyyyyyy-zzzzzz",
|
||||
]
|
||||
|
||||
[CANAILLE.SMTP]
|
||||
HOST = "mailhog.yaal.coop"
|
||||
PORT = 1025
|
||||
TLS = false
|
||||
SSL = false
|
||||
LOGIN = ""
|
||||
PASSWORD = ""
|
||||
FROM_ADDR = "admin@mydomain.tld"
|
||||
|
|
|
@ -177,12 +177,13 @@ If :attr:`password compromission check feature <canaille.core.configuration.Core
|
|||
.. _feature_multi_factor_authentication:
|
||||
|
||||
Multi-factor authentication
|
||||
==============
|
||||
===========================
|
||||
|
||||
If the :attr:`one-time password feature <canaille.core.configuration.CoreSettings.OTP_METHOD>` is set, then users will need to authenticate themselves using a one-time password via an authenticator app.
|
||||
Two options are supported : "TOTP" for time one-time password, and "HOTP" for HMAC-based one-time password.
|
||||
In case of lost token, one-time password authentication can be reset by users with :attr:`user management permission <canaille.core.configuration.Permission.MANAGE_USERS>`.
|
||||
If the :attr:`email multi-factor authentication feature <canaille.core.configuration.CoreSettings.EMAIL_OTP>` is enabled, then users will need to authenticate themselves via a one-time password sent to their primary email address.
|
||||
In case of lost token, TOTP/HOTP authentication can be reset by users with :attr:`user management permission <canaille.core.configuration.Permission.MANAGE_USERS>`.
|
||||
If a :class:`mail server <canaille.core.configuration.SMTPSettings>` is configured and the :attr:`email one-time password feature <canaille.core.configuration.CoreSettings.EMAIL_OTP>` is enabled, then users will need to authenticate themselves via a one-time password sent to their primary email address.
|
||||
If a :class:`smpp server <canaille.core.configuration.SMPPSettings>` is configured and the :attr:`sms one-time password feature <canaille.core.configuration.CoreSettings.SMS_OTP>` is enabled, then users will need to authenticate themselves via a one-time password sent to their primary phone number.
|
||||
|
||||
Web interface
|
||||
*************
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -50,6 +50,7 @@ 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"]
|
||||
|
||||
|
@ -130,3 +131,12 @@ You can adapt and load this configuration file with:
|
|||
|
||||
# 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"]
|
||||
|
|
|
@ -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:
|
||||
|
|
2666
poetry.lock
generated
2666
poetry.lock
generated
File diff suppressed because it is too large
Load diff
|
@ -32,11 +32,8 @@ requires-python = ">=3.10"
|
|||
dependencies = [
|
||||
"flask >= 3.0.0",
|
||||
"flask-wtf >= 1.2.1",
|
||||
"otpauth>=2.1.1",
|
||||
"pillow>=11.0.0",
|
||||
"pydantic-settings >= 2.0.3",
|
||||
"requests>=2.32.3",
|
||||
"qrcode>=8.0",
|
||||
"wtforms >= 3.1.1",
|
||||
]
|
||||
|
||||
|
@ -84,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/"
|
||||
|
|
|
@ -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"])
|
||||
|
|
|
@ -30,7 +30,7 @@ def test_reset_otp_by_id(testclient, backend, caplog, user_otp, otp_method):
|
|||
"created": mock.ANY,
|
||||
"display_name": "Johnny",
|
||||
"emails": [
|
||||
"john@doe.com",
|
||||
"john@doe.test",
|
||||
],
|
||||
"family_name": "Doe",
|
||||
"formatted_address": "1235, somewhere",
|
||||
|
@ -43,7 +43,7 @@ def test_reset_otp_by_id(testclient, backend, caplog, user_otp, otp_method):
|
|||
"555-000-000",
|
||||
],
|
||||
"preferred_language": "en",
|
||||
"profile_url": "https://john.example",
|
||||
"profile_url": "https://john.test",
|
||||
"user_name": "user",
|
||||
"hotp_counter": 1,
|
||||
"secret_token": mock.ANY,
|
|
@ -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)
|
||||
|
@ -262,3 +264,54 @@ 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)
|
||||
|
||||
|
||||
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)
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -1,7 +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
|
||||
|
@ -133,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": {
|
||||
|
@ -294,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
|
||||
|
|
|
@ -1,228 +0,0 @@
|
|||
import datetime
|
||||
import logging
|
||||
|
||||
import time_machine
|
||||
|
||||
from canaille.app import mask_email
|
||||
from canaille.core.models import OTP_VALIDITY
|
||||
|
||||
|
||||
def test_email_otp_disabled(testclient):
|
||||
testclient.app.config["CANAILLE"]["EMAIL_OTP"] = None
|
||||
testclient.app.config["WTF_CSRF_ENABLED"] = False
|
||||
|
||||
testclient.get("/verify-2fa", status=404)
|
||||
testclient.post("/send-mail-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.com. Please enter it below to login.",
|
||||
) in res.flashes
|
||||
assert (
|
||||
"canaille",
|
||||
logging.SECURITY,
|
||||
"Sent one-time password for user to john@doe.com 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_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_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_verify_mail_otp_page_without_signin_in_redirects_to_login_page(
|
||||
testclient, user
|
||||
):
|
||||
testclient.app.config["CANAILLE"]["EMAIL_OTP"] = 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")
|
||||
]
|
||||
|
||||
|
||||
def test_verify_mail_otp_page_already_logged_in(testclient, logged_user):
|
||||
testclient.app.config["CANAILLE"]["EMAIL_OTP"] = 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_send_new_mail_otp_without_signin_in_redirects_to_login_page(testclient, user):
|
||||
testclient.app.config["CANAILLE"]["EMAIL_OTP"] = True
|
||||
testclient.app.config["WTF_CSRF_ENABLED"] = False
|
||||
|
||||
res = testclient.post("/send-mail-otp", status=302)
|
||||
assert res.location == "/login"
|
||||
assert res.flashes == [
|
||||
("warning", "Cannot remember the login you attempted to sign in with")
|
||||
]
|
||||
|
||||
|
||||
def test_send_new_mail_otp_already_logged_in(testclient, logged_user):
|
||||
testclient.app.config["CANAILLE"]["EMAIL_OTP"] = True
|
||||
testclient.app.config["WTF_CSRF_ENABLED"] = False
|
||||
|
||||
res = testclient.post("/send-mail-otp", 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.com from unknown IP",
|
||||
) in caplog.record_tuples
|
||||
|
||||
assert res.location == "/verify-2fa"
|
659
tests/core/test_email_sms_otp.py
Normal file
659
tests/core/test_email_sms_otp.py
Normal file
|
@ -0,0 +1,659 @@
|
|||
import datetime
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
import time_machine
|
||||
|
||||
import tests.conftest
|
||||
from canaille.app import mask_email
|
||||
from canaille.app import mask_phone
|
||||
from canaille.core.models import OTP_VALIDITY
|
||||
from canaille.core.models import SEND_NEW_OTP_DELAY
|
||||
|
||||
|
||||
def test_email_otp_disabled(testclient):
|
||||
testclient.app.config["CANAILLE"]["EMAIL_OTP"] = None
|
||||
with testclient.session_transaction() as session:
|
||||
session["remaining_otp_methods"] = ["EMAIL_OTP"]
|
||||
session["attempt_login_with_correct_password"] = "id"
|
||||
testclient.get("/verify-2fa", status=404)
|
||||
|
||||
testclient.app.config["WTF_CSRF_ENABLED"] = False
|
||||
testclient.post("/send-mail-otp", status=404)
|
||||
|
||||
|
||||
def test_sms_otp_disabled(testclient):
|
||||
testclient.app.config["CANAILLE"]["SMS_OTP"] = None
|
||||
with testclient.session_transaction() as session:
|
||||
session["remaining_otp_methods"] = ["SMS_OTP"]
|
||||
session["attempt_login_with_correct_password"] = "id"
|
||||
testclient.get("/verify-2fa", status=404)
|
||||
|
||||
testclient.app.config["WTF_CSRF_ENABLED"] = False
|
||||
testclient.post("/send-sms-otp", status=404)
|
||||
|
||||
|
||||
def test_signin_and_out_with_email_otp(smtpd, testclient, backend, user, caplog):
|
||||
testclient.app.config["CANAILLE"]["EMAIL_OTP"] = True
|
||||
|
||||
with testclient.session_transaction() as session:
|
||||
assert not session.get("user_id")
|
||||
|
||||
res = testclient.get("/login", status=200)
|
||||
|
||||
res.form["login"] = "user"
|
||||
res = res.form.submit(status=302)
|
||||
res = res.follow(status=200)
|
||||
|
||||
with testclient.session_transaction() as session:
|
||||
assert "user" == session.get("attempt_login")
|
||||
|
||||
res.form["password"] = "correct horse battery staple"
|
||||
res = res.form.submit(status=302)
|
||||
assert (
|
||||
"info",
|
||||
"A one-time password has been sent to your email address j#####n@doe.test. Please enter it below to login.",
|
||||
) in res.flashes
|
||||
assert (
|
||||
"canaille",
|
||||
logging.SECURITY,
|
||||
"Sent one-time password for user to john@doe.test from unknown IP",
|
||||
) in caplog.record_tuples
|
||||
res = res.follow(status=200)
|
||||
with testclient.session_transaction() as session:
|
||||
assert "attempt_login" not in session
|
||||
assert "user" == session.get("attempt_login_with_correct_password")
|
||||
|
||||
res = testclient.get("/verify-2fa")
|
||||
|
||||
backend.reload(user)
|
||||
otp = user.one_time_password
|
||||
assert len(smtpd.messages) == 1
|
||||
email_content = str(smtpd.messages[0].get_payload()[0]).replace("=\n", "")
|
||||
assert otp in email_content
|
||||
|
||||
main_form = res.forms[0]
|
||||
main_form["otp"] = otp
|
||||
res = main_form.submit(status=302)
|
||||
|
||||
assert (
|
||||
"success",
|
||||
"Connection successful. Welcome John (johnny) Doe",
|
||||
) in res.flashes
|
||||
assert (
|
||||
"canaille",
|
||||
logging.SECURITY,
|
||||
"Succeed login attempt for user from unknown IP",
|
||||
) in caplog.record_tuples
|
||||
res = res.follow(status=302)
|
||||
res = res.follow(status=200)
|
||||
|
||||
with testclient.session_transaction() as session:
|
||||
assert [user.id] == session.get("user_id")
|
||||
assert "attempt_login" not in session
|
||||
assert "attempt_login_with_correct_password" not in session
|
||||
|
||||
res = testclient.get("/login", status=302)
|
||||
|
||||
res = testclient.get("/logout")
|
||||
assert (
|
||||
"success",
|
||||
"You have been disconnected. See you next time John (johnny) Doe",
|
||||
) in res.flashes
|
||||
assert (
|
||||
"canaille",
|
||||
logging.SECURITY,
|
||||
"Logout user from unknown IP",
|
||||
) in caplog.record_tuples
|
||||
res = res.follow(status=302)
|
||||
res = res.follow(status=200)
|
||||
|
||||
|
||||
def test_signin_with_email_otp_failed_to_send_code(testclient, user):
|
||||
testclient.app.config["CANAILLE"]["EMAIL_OTP"] = True
|
||||
testclient.app.config["CANAILLE"]["SMTP"]["HOST"] = "invalid host"
|
||||
|
||||
with testclient.session_transaction() as session:
|
||||
assert not session.get("user_id")
|
||||
|
||||
res = testclient.get("/login", status=200)
|
||||
|
||||
res.form["login"] = "user"
|
||||
res = res.form.submit(status=302)
|
||||
res = res.follow(status=200)
|
||||
|
||||
with testclient.session_transaction() as session:
|
||||
assert "user" == session.get("attempt_login")
|
||||
|
||||
res.form["password"] = "correct horse battery staple"
|
||||
res = res.form.submit(status=302)
|
||||
assert (
|
||||
"danger",
|
||||
"Error while sending one-time password. Please try again.",
|
||||
) in res.flashes
|
||||
|
||||
assert res.location == "/password"
|
||||
|
||||
|
||||
def test_signin_wrong_email_otp(testclient, user, caplog):
|
||||
testclient.app.config["CANAILLE"]["EMAIL_OTP"] = True
|
||||
|
||||
with testclient.session_transaction() as session:
|
||||
assert not session.get("user_id")
|
||||
|
||||
res = testclient.get("/login", status=200)
|
||||
|
||||
res.form["login"] = "user"
|
||||
res = res.form.submit(status=302)
|
||||
res = res.follow(status=200)
|
||||
|
||||
res.form["password"] = "correct horse battery staple"
|
||||
res = res.form.submit(status=302)
|
||||
res = res.follow(status=200)
|
||||
|
||||
main_form = res.forms[0]
|
||||
main_form["otp"] = 111111
|
||||
res = main_form.submit()
|
||||
|
||||
assert (
|
||||
"error",
|
||||
"The one-time password you entered is invalid. Please try again",
|
||||
) in res.flashes
|
||||
assert (
|
||||
"canaille",
|
||||
logging.SECURITY,
|
||||
"Failed login attempt (wrong OTP) for user from unknown IP",
|
||||
) in caplog.record_tuples
|
||||
|
||||
|
||||
def test_signin_expired_email_otp(testclient, user, caplog):
|
||||
testclient.app.config["CANAILLE"]["EMAIL_OTP"] = True
|
||||
|
||||
with time_machine.travel("2020-01-01 01:00:00+00:00", tick=False) as traveller:
|
||||
with testclient.session_transaction() as session:
|
||||
assert not session.get("user_id")
|
||||
|
||||
res = testclient.get("/login", status=200)
|
||||
|
||||
res.form["login"] = "user"
|
||||
res = res.form.submit(status=302)
|
||||
res = res.follow(status=200)
|
||||
|
||||
res.form["password"] = "correct horse battery staple"
|
||||
res = res.form.submit(status=302)
|
||||
res = res.follow(status=200)
|
||||
|
||||
main_form = res.forms[0]
|
||||
main_form["otp"] = user.generate_and_send_otp_mail()
|
||||
|
||||
traveller.shift(datetime.timedelta(seconds=OTP_VALIDITY))
|
||||
res = main_form.submit()
|
||||
|
||||
assert (
|
||||
"error",
|
||||
"The one-time password you entered is invalid. Please try again",
|
||||
) in res.flashes
|
||||
assert (
|
||||
"canaille",
|
||||
logging.SECURITY,
|
||||
"Failed login attempt (wrong OTP) for user from unknown IP",
|
||||
) in caplog.record_tuples
|
||||
|
||||
|
||||
def test_signin_expired_sms_otp(testclient, user, caplog, mock_smpp):
|
||||
testclient.app.config["CANAILLE"]["SMS_OTP"] = True
|
||||
|
||||
with time_machine.travel("2020-01-01 01:00:00+00:00", tick=False) as traveller:
|
||||
with testclient.session_transaction() as session:
|
||||
assert not session.get("user_id")
|
||||
|
||||
res = testclient.get("/login", status=200)
|
||||
|
||||
res.form["login"] = "user"
|
||||
res = res.form.submit(status=302)
|
||||
res = res.follow(status=200)
|
||||
|
||||
res.form["password"] = "correct horse battery staple"
|
||||
res = res.form.submit(status=302)
|
||||
res = res.follow(status=200)
|
||||
|
||||
main_form = res.forms[0]
|
||||
otp = user.generate_and_send_otp_sms()
|
||||
main_form["otp"] = otp
|
||||
assert otp in str(tests.conftest.pdu.generate())
|
||||
traveller.shift(datetime.timedelta(seconds=OTP_VALIDITY))
|
||||
res = main_form.submit()
|
||||
|
||||
assert (
|
||||
"error",
|
||||
"The one-time password you entered is invalid. Please try again",
|
||||
) in res.flashes
|
||||
assert (
|
||||
"canaille",
|
||||
logging.SECURITY,
|
||||
"Failed login attempt (wrong OTP) for user from unknown IP",
|
||||
) in caplog.record_tuples
|
||||
|
||||
|
||||
def test_signin_and_out_with_sms_otp(testclient, backend, user, caplog, mock_smpp):
|
||||
testclient.app.config["CANAILLE"]["SMS_OTP"] = True
|
||||
|
||||
with testclient.session_transaction() as session:
|
||||
assert not session.get("user_id")
|
||||
|
||||
res = testclient.get("/login", status=200)
|
||||
|
||||
res.form["login"] = "user"
|
||||
res = res.form.submit(status=302)
|
||||
res = res.follow(status=200)
|
||||
|
||||
with testclient.session_transaction() as session:
|
||||
assert "user" == session.get("attempt_login")
|
||||
|
||||
res.form["password"] = "correct horse battery staple"
|
||||
res = res.form.submit(status=302)
|
||||
assert (
|
||||
"info",
|
||||
"A one-time password has been sent to your phone number 555#####00. Please enter it below to login.",
|
||||
) in res.flashes
|
||||
assert (
|
||||
"canaille",
|
||||
logging.SECURITY,
|
||||
"Sent one-time password for user to 555-000-000 from unknown IP",
|
||||
) in caplog.record_tuples
|
||||
res = res.follow(status=200)
|
||||
with testclient.session_transaction() as session:
|
||||
assert "attempt_login" not in session
|
||||
assert "user" == session.get("attempt_login_with_correct_password")
|
||||
|
||||
res = testclient.get("/verify-2fa")
|
||||
|
||||
backend.reload(user)
|
||||
otp = user.one_time_password
|
||||
|
||||
main_form = res.forms[0]
|
||||
main_form["otp"] = otp
|
||||
assert otp in str(tests.conftest.pdu.generate())
|
||||
|
||||
res = main_form.submit(status=302)
|
||||
|
||||
assert (
|
||||
"success",
|
||||
"Connection successful. Welcome John (johnny) Doe",
|
||||
) in res.flashes
|
||||
assert (
|
||||
"canaille",
|
||||
logging.SECURITY,
|
||||
"Succeed login attempt for user from unknown IP",
|
||||
) in caplog.record_tuples
|
||||
res = res.follow(status=302)
|
||||
res = res.follow(status=200)
|
||||
|
||||
with testclient.session_transaction() as session:
|
||||
assert [user.id] == session.get("user_id")
|
||||
assert "attempt_login" not in session
|
||||
assert "attempt_login_with_correct_password" not in session
|
||||
|
||||
res = testclient.get("/login", status=302)
|
||||
|
||||
res = testclient.get("/logout")
|
||||
assert (
|
||||
"success",
|
||||
"You have been disconnected. See you next time John (johnny) Doe",
|
||||
) in res.flashes
|
||||
assert (
|
||||
"canaille",
|
||||
logging.SECURITY,
|
||||
"Logout user from unknown IP",
|
||||
) in caplog.record_tuples
|
||||
res = res.follow(status=302)
|
||||
res = res.follow(status=200)
|
||||
|
||||
|
||||
def test_signin_with_sms_otp_failed_to_send_code(testclient, user):
|
||||
testclient.app.config["CANAILLE"]["SMS_OTP"] = True
|
||||
testclient.app.config["CANAILLE"]["SMPP"]["HOST"] = "invalid host"
|
||||
|
||||
with testclient.session_transaction() as session:
|
||||
assert not session.get("user_id")
|
||||
|
||||
res = testclient.get("/login", status=200)
|
||||
|
||||
res.form["login"] = "user"
|
||||
res = res.form.submit(status=302)
|
||||
res = res.follow(status=200)
|
||||
|
||||
with testclient.session_transaction() as session:
|
||||
assert "user" == session.get("attempt_login")
|
||||
|
||||
res.form["password"] = "correct horse battery staple"
|
||||
res = res.form.submit(status=302)
|
||||
assert (
|
||||
"danger",
|
||||
"Error while sending one-time password. Please try again.",
|
||||
) in res.flashes
|
||||
|
||||
assert res.location == "/password"
|
||||
|
||||
|
||||
def test_signin_wrong_sms_otp(testclient, user, caplog, mock_smpp):
|
||||
testclient.app.config["CANAILLE"]["SMS_OTP"] = True
|
||||
|
||||
with testclient.session_transaction() as session:
|
||||
assert not session.get("user_id")
|
||||
|
||||
res = testclient.get("/login", status=200)
|
||||
|
||||
res.form["login"] = "user"
|
||||
res = res.form.submit(status=302)
|
||||
res = res.follow(status=200)
|
||||
|
||||
res.form["password"] = "correct horse battery staple"
|
||||
res = res.form.submit(status=302)
|
||||
res = res.follow(status=200)
|
||||
|
||||
main_form = res.forms[0]
|
||||
main_form["otp"] = 111111
|
||||
res = main_form.submit()
|
||||
|
||||
assert (
|
||||
"error",
|
||||
"The one-time password you entered is invalid. Please try again",
|
||||
) in res.flashes
|
||||
assert (
|
||||
"canaille",
|
||||
logging.SECURITY,
|
||||
"Failed login attempt (wrong OTP) for user from unknown IP",
|
||||
) in caplog.record_tuples
|
||||
|
||||
|
||||
@pytest.mark.parametrize("otp_method", ["EMAIL_OTP", "SMS_OTP"])
|
||||
def test_verify_mail_or_sms_otp_page_without_signin_in_redirects_to_login_page(
|
||||
testclient, user, otp_method
|
||||
):
|
||||
testclient.app.config["CANAILLE"][otp_method] = True
|
||||
|
||||
res = testclient.get("/verify-2fa", status=302)
|
||||
assert res.location == "/login"
|
||||
assert res.flashes == [
|
||||
("warning", "Cannot remember the login you attempted to sign in with")
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("otp_method", ["EMAIL_OTP", "SMS_OTP"])
|
||||
def test_verify_mail_or_sms_otp_page_already_logged_in(
|
||||
testclient, logged_user, otp_method
|
||||
):
|
||||
testclient.app.config["CANAILLE"][otp_method] = True
|
||||
|
||||
res = testclient.get("/verify-2fa", status=302)
|
||||
assert res.location == "/profile/user"
|
||||
|
||||
|
||||
def test_mask_email():
|
||||
email = "foo@bar.com"
|
||||
assert mask_email(email) == "f#####o@bar.com"
|
||||
|
||||
email = "hello"
|
||||
assert mask_email(email) is None
|
||||
|
||||
|
||||
def test_mask_phone_number():
|
||||
phone = "+33123456789"
|
||||
assert mask_phone(phone) == "+33#####89"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"otp_method,new_otp_path",
|
||||
[("EMAIL_OTP", "/send-mail-otp"), ("SMS_OTP", "/send-sms-otp")],
|
||||
)
|
||||
def test_send_new_mail_or_sms_otp_without_signin_in_redirects_to_login_page(
|
||||
testclient, user, otp_method, new_otp_path
|
||||
):
|
||||
testclient.app.config["CANAILLE"][otp_method] = True
|
||||
testclient.app.config["WTF_CSRF_ENABLED"] = False
|
||||
|
||||
res = testclient.post(new_otp_path, status=302)
|
||||
assert res.location == "/login"
|
||||
assert res.flashes == [
|
||||
("warning", "Cannot remember the login you attempted to sign in with")
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"otp_method,new_otp_path",
|
||||
[("EMAIL_OTP", "/send-mail-otp"), ("SMS_OTP", "/send-sms-otp")],
|
||||
)
|
||||
def test_send_new_mail_or_sms_otp_already_logged_in(
|
||||
testclient, logged_user, otp_method, new_otp_path
|
||||
):
|
||||
testclient.app.config["CANAILLE"][otp_method] = True
|
||||
testclient.app.config["WTF_CSRF_ENABLED"] = False
|
||||
|
||||
res = testclient.post(new_otp_path, status=302)
|
||||
assert res.location == "/profile/user"
|
||||
|
||||
|
||||
def test_send_new_mail_otp(smtpd, testclient, backend, user, caplog):
|
||||
testclient.app.config["CANAILLE"]["EMAIL_OTP"] = True
|
||||
testclient.app.config["WTF_CSRF_ENABLED"] = False
|
||||
with testclient.session_transaction() as session:
|
||||
session["attempt_login_with_correct_password"] = user.user_name
|
||||
|
||||
res = testclient.post("/send-mail-otp", status=302)
|
||||
|
||||
backend.reload(user)
|
||||
otp = user.one_time_password
|
||||
assert len(smtpd.messages) == 1
|
||||
email_content = str(smtpd.messages[0].get_payload()[0]).replace("=\n", "")
|
||||
assert otp in email_content
|
||||
|
||||
assert (
|
||||
"success",
|
||||
"Code successfully sent!",
|
||||
) in res.flashes
|
||||
assert (
|
||||
"canaille",
|
||||
logging.SECURITY,
|
||||
"Sent one-time password for user to john@doe.test from unknown IP",
|
||||
) in caplog.record_tuples
|
||||
|
||||
assert res.location == "/verify-2fa"
|
||||
|
||||
|
||||
def test_send_mail_otp_multiple_attempts(testclient, backend, user, caplog):
|
||||
testclient.app.config["CANAILLE"]["EMAIL_OTP"] = True
|
||||
testclient.app.config["WTF_CSRF_ENABLED"] = False
|
||||
|
||||
with time_machine.travel("2020-01-01 01:00:00+00:00", tick=False) as traveller:
|
||||
with testclient.session_transaction() as session:
|
||||
session["attempt_login_with_correct_password"] = user.user_name
|
||||
|
||||
res = testclient.post("/send-mail-otp", status=302)
|
||||
assert res.location == "/verify-2fa"
|
||||
|
||||
traveller.shift(datetime.timedelta(seconds=SEND_NEW_OTP_DELAY - 1))
|
||||
res = testclient.post("/send-mail-otp", status=302)
|
||||
assert (
|
||||
"danger",
|
||||
f"Too many attempts. Please try again in {SEND_NEW_OTP_DELAY} seconds.",
|
||||
) in res.flashes
|
||||
assert res.location == "/verify-2fa"
|
||||
|
||||
traveller.shift(datetime.timedelta(seconds=1))
|
||||
|
||||
res = testclient.post("/send-mail-otp", status=302)
|
||||
assert (
|
||||
"success",
|
||||
"Code successfully sent!",
|
||||
) in res.flashes
|
||||
|
||||
|
||||
def test_send_new_mail_otp_failed(testclient, user):
|
||||
testclient.app.config["CANAILLE"]["EMAIL_OTP"] = True
|
||||
testclient.app.config["CANAILLE"]["SMTP"]["HOST"] = "invalid host"
|
||||
testclient.app.config["WTF_CSRF_ENABLED"] = False
|
||||
with testclient.session_transaction() as session:
|
||||
session["attempt_login_with_correct_password"] = user.user_name
|
||||
|
||||
res = testclient.post("/send-mail-otp", status=302)
|
||||
|
||||
assert (
|
||||
"danger",
|
||||
"Error while sending one-time password. Please try again.",
|
||||
) in res.flashes
|
||||
|
||||
assert res.location == "/verify-2fa"
|
||||
|
||||
|
||||
def test_send_new_sms_otp(testclient, backend, user, caplog, mock_smpp):
|
||||
testclient.app.config["CANAILLE"]["SMS_OTP"] = True
|
||||
testclient.app.config["WTF_CSRF_ENABLED"] = False
|
||||
with testclient.session_transaction() as session:
|
||||
session["attempt_login_with_correct_password"] = user.user_name
|
||||
|
||||
res = testclient.post("/send-sms-otp", status=302)
|
||||
|
||||
backend.reload(user)
|
||||
otp = user.one_time_password
|
||||
|
||||
assert otp in str(tests.conftest.pdu.generate())
|
||||
|
||||
assert (
|
||||
"success",
|
||||
"Code successfully sent!",
|
||||
) in res.flashes
|
||||
assert (
|
||||
"canaille",
|
||||
logging.SECURITY,
|
||||
"Sent one-time password for user to 555-000-000 from unknown IP",
|
||||
) in caplog.record_tuples
|
||||
|
||||
assert res.location == "/verify-2fa"
|
||||
|
||||
|
||||
def test_send_sms_otp_multiple_attempts(testclient, backend, user, caplog, mock_smpp):
|
||||
testclient.app.config["CANAILLE"]["SMS_OTP"] = True
|
||||
testclient.app.config["WTF_CSRF_ENABLED"] = False
|
||||
|
||||
with time_machine.travel("2020-01-01 01:00:00+00:00", tick=False) as traveller:
|
||||
with testclient.session_transaction() as session:
|
||||
session["attempt_login_with_correct_password"] = user.user_name
|
||||
|
||||
res = testclient.post("/send-sms-otp", status=302)
|
||||
assert res.location == "/verify-2fa"
|
||||
|
||||
traveller.shift(datetime.timedelta(seconds=SEND_NEW_OTP_DELAY - 1))
|
||||
res = testclient.post("/send-sms-otp", status=302)
|
||||
assert (
|
||||
"danger",
|
||||
f"Too many attempts. Please try again in {SEND_NEW_OTP_DELAY} seconds.",
|
||||
) in res.flashes
|
||||
assert res.location == "/verify-2fa"
|
||||
|
||||
traveller.shift(datetime.timedelta(seconds=1))
|
||||
|
||||
res = testclient.post("/send-sms-otp", status=302)
|
||||
assert (
|
||||
"success",
|
||||
"Code successfully sent!",
|
||||
) in res.flashes
|
||||
|
||||
|
||||
def test_send_new_sms_otp_failed(testclient, user):
|
||||
testclient.app.config["CANAILLE"]["SMS_OTP"] = True
|
||||
testclient.app.config["CANAILLE"]["SMPP"]["HOST"] = "invalid host"
|
||||
testclient.app.config["WTF_CSRF_ENABLED"] = False
|
||||
with testclient.session_transaction() as session:
|
||||
session["attempt_login_with_correct_password"] = user.user_name
|
||||
|
||||
res = testclient.post("/send-sms-otp", status=302)
|
||||
|
||||
assert (
|
||||
"danger",
|
||||
"Error while sending one-time password. Please try again.",
|
||||
) in res.flashes
|
||||
|
||||
assert res.location == "/verify-2fa"
|
||||
|
||||
|
||||
def test_signin_with_multiple_otp_methods(
|
||||
smtpd, testclient, backend, user_otp, caplog, mock_smpp
|
||||
):
|
||||
testclient.app.config["CANAILLE"]["OTP_METHOD"] = "HOTP"
|
||||
testclient.app.config["CANAILLE"]["EMAIL_OTP"] = True
|
||||
testclient.app.config["CANAILLE"]["SMS_OTP"] = True
|
||||
|
||||
with testclient.session_transaction() as session:
|
||||
assert not session.get("user_id")
|
||||
|
||||
res = testclient.get("/login", status=200)
|
||||
|
||||
res.form["login"] = "user"
|
||||
res = res.form.submit(status=302)
|
||||
res = res.follow(status=200)
|
||||
|
||||
with testclient.session_transaction() as session:
|
||||
assert "user" == session.get("attempt_login")
|
||||
|
||||
res.form["password"] = "correct horse battery staple"
|
||||
res = res.form.submit(status=302).follow(status=200)
|
||||
|
||||
with testclient.session_transaction() as session:
|
||||
assert "attempt_login" not in session
|
||||
assert "user" == session.get("attempt_login_with_correct_password")
|
||||
|
||||
# TOTP/HOTP
|
||||
res = testclient.get("/verify-2fa")
|
||||
res.form["otp"] = user_otp.generate_otp()
|
||||
res = res.form.submit(status=302).follow(status=200)
|
||||
|
||||
# EMAIL_OTP
|
||||
res = testclient.get("/verify-2fa")
|
||||
backend.reload(user_otp)
|
||||
otp = user_otp.one_time_password
|
||||
main_form = res.forms[0]
|
||||
main_form["otp"] = otp
|
||||
res = main_form.submit(status=302).follow(status=200)
|
||||
|
||||
# SMS_OTP
|
||||
res = testclient.get("/verify-2fa")
|
||||
backend.reload(user_otp)
|
||||
otp = user_otp.one_time_password
|
||||
main_form = res.forms[0]
|
||||
main_form["otp"] = otp
|
||||
res = main_form.submit(status=302).follow(status=302).follow(status=200)
|
||||
|
||||
with testclient.session_transaction() as session:
|
||||
assert [user_otp.id] == session.get("user_id")
|
||||
assert "attempt_login" not in session
|
||||
assert "attempt_login_with_correct_password" not in session
|
||||
|
||||
res = testclient.get("/login", status=302)
|
||||
|
||||
|
||||
def test_send_password_form_multiple_times(smtpd, testclient, backend, user, caplog):
|
||||
testclient.app.config["CANAILLE"]["EMAIL_OTP"] = True
|
||||
|
||||
res = testclient.get("/login", status=200)
|
||||
|
||||
res.form["login"] = "user"
|
||||
res = res.form.submit(status=302)
|
||||
res = res.follow(status=200)
|
||||
|
||||
res.form["password"] = "correct horse battery staple"
|
||||
res = res.form.submit(status=302)
|
||||
|
||||
with testclient.session_transaction() as session:
|
||||
session["attempt_login"] = user.user_name
|
||||
del session["attempt_login_with_correct_password"]
|
||||
|
||||
res = testclient.get("/password", status=200)
|
||||
res.form["password"] = "correct horse battery staple"
|
||||
res = res.form.submit(status=302)
|
||||
|
||||
assert (
|
||||
"danger",
|
||||
f"Too many attempts. Please try again in {SEND_NEW_OTP_DELAY} seconds.",
|
||||
) in res.flashes
|
||||
assert res.location == "/password"
|
|
@ -10,7 +10,9 @@ 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)
|
||||
|
||||
|
@ -151,7 +153,7 @@ def test_new_user_setup_otp(testclient, backend, caplog, otp_method):
|
|||
formatted_name="Otp User",
|
||||
family_name="Otp",
|
||||
user_name="otp",
|
||||
emails=["john@doe.com"],
|
||||
emails=["john@doe.test"],
|
||||
password="correct horse battery staple",
|
||||
)
|
||||
backend.save(u)
|
||||
|
@ -186,7 +188,7 @@ def test_new_user_setup_otp(testclient, backend, caplog, otp_method):
|
|||
|
||||
assert (
|
||||
"success",
|
||||
"Two-factor authentication setup successful. Welcome Otp User",
|
||||
"Connection successful. Welcome Otp User",
|
||||
) in res.flashes
|
||||
assert (
|
||||
"canaille",
|
||||
|
|
30
uv.lock
30
uv.lock
|
@ -126,11 +126,8 @@ source = { editable = "." }
|
|||
dependencies = [
|
||||
{ name = "flask" },
|
||||
{ name = "flask-wtf" },
|
||||
{ name = "otpauth" },
|
||||
{ name = "pillow" },
|
||||
{ name = "pydantic-settings" },
|
||||
{ name = "requests" },
|
||||
{ name = "qrcode" },
|
||||
{ name = "wtforms" },
|
||||
]
|
||||
|
||||
|
@ -156,6 +153,11 @@ mysql = [
|
|||
oidc = [
|
||||
{ name = "authlib" },
|
||||
]
|
||||
otp = [
|
||||
{ name = "otpauth" },
|
||||
{ name = "pillow" },
|
||||
{ name = "qrcode" },
|
||||
]
|
||||
postgresql = [
|
||||
{ name = "passlib" },
|
||||
{ name = "sqlalchemy", extra = ["postgresql-psycopg2binary"] },
|
||||
|
@ -165,6 +167,9 @@ postgresql = [
|
|||
sentry = [
|
||||
{ name = "sentry-sdk" },
|
||||
]
|
||||
sms = [
|
||||
{ name = "smpplib" },
|
||||
]
|
||||
sqlite = [
|
||||
{ name = "passlib" },
|
||||
{ name = "sqlalchemy" },
|
||||
|
@ -218,18 +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", specifier = ">=2.1.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", specifier = ">=11.0.0" },
|
||||
{ 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 = "qrcode", specifier = ">=8.0" },
|
||||
{ name = "sentry-sdk", marker = "extra == 'sentry'", specifier = ">=2.0.0" },
|
||||
{ name = "smpplib", marker = "extra == 'sms'", specifier = ">=2.2.3" },
|
||||
{ name = "sqlalchemy", marker = "extra == 'sqlite'", specifier = ">=2.0.23" },
|
||||
{ name = "sqlalchemy", extras = ["mysql-connector"], marker = "extra == 'mysql'", specifier = ">=2.0.23" },
|
||||
{ name = "sqlalchemy", extras = ["postgresql-psycopg2binary"], marker = "extra == 'postgresql'", specifier = ">=2.0.23" },
|
||||
|
@ -1667,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"
|
||||
|
|
Loading…
Reference in a new issue