Skip to content

Commit

Permalink
feat : Added sms OTP authentication and multi-factor authentication m…
Browse files Browse the repository at this point in the history
…ethods chaining
  • Loading branch information
Félix Rohrlich committed Dec 10, 2024
1 parent 6d48ce9 commit e1d70ef
Show file tree
Hide file tree
Showing 33 changed files with 1,280 additions and 3,030 deletions.
6 changes: 6 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 9 additions & 1 deletion canaille/app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
from base64 import b64encode
from io import BytesIO

import qrcode
from flask import current_app
from flask import request

Expand Down Expand Up @@ -72,6 +71,11 @@ def __get__(self, obj, 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)
Expand All @@ -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:]
48 changes: 42 additions & 6 deletions canaille/app/configuration.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import importlib.util
import os
import smtplib
import socket
Expand Down Expand Up @@ -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

Expand All @@ -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):
Expand Down Expand Up @@ -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"])
Expand All @@ -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"
)
4 changes: 4 additions & 0 deletions canaille/app/features.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ def has_otp(self):
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"]
Expand Down
51 changes: 51 additions & 0 deletions canaille/app/sms.py
Original file line number Diff line number Diff line change
@@ -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
13 changes: 13 additions & 0 deletions canaille/config.sample.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 = ""
29 changes: 29 additions & 0 deletions canaille/core/configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -296,6 +318,13 @@ class = "logging.handlers.WatchedFileHandler"
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.
Expand Down
32 changes: 32 additions & 0 deletions canaille/core/endpoints/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Loading

0 comments on commit e1d70ef

Please sign in to comment.