diff --git a/canaille/app/configuration.py b/canaille/app/configuration.py
index 961239ed..f85afef5 100644
--- a/canaille/app/configuration.py
+++ b/canaille/app/configuration.py
@@ -175,6 +175,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"])
if not validate_remote:
return
@@ -243,3 +244,8 @@ def validate_admin_email(config):
raise ConfigurationException(
"You must set an administration email if you want to check if users' passwords are compromised."
)
+
+
+def validate_otp_method(config):
+ if config["OTP_METHOD"] not in [None, "TOTP", "HOTP"]:
+ raise ConfigurationException("Invalid OTP method")
diff --git a/canaille/app/features.py b/canaille/app/features.py
index ff4e6169..03820212 100644
--- a/canaille/app/features.py
+++ b/canaille/app/features.py
@@ -15,8 +15,12 @@ class Features:
return self.app.config["CANAILLE"]["ENABLE_PASSWORD_RECOVERY"]
@property
- def has_totp(self):
- return self.app.config["CANAILLE"]["ENABLE_TOTP"]
+ def otp_method(self):
+ return self.app.config["CANAILLE"]["OTP_METHOD"]
+
+ @property
+ def has_otp(self):
+ return bool(self.app.config["CANAILLE"]["OTP_METHOD"])
@property
def has_registration(self):
diff --git a/canaille/backends/ldap/models.py b/canaille/backends/ldap/models.py
index 63d1dc3f..e4cab4ab 100644
--- a/canaille/backends/ldap/models.py
+++ b/canaille/backends/ldap/models.py
@@ -37,6 +37,7 @@ class User(canaille.core.models.User, LDAPObject):
"lock_date": "pwdEndTime",
"secret_token": "oathSecret",
"last_otp_login": "oathLastLogin",
+ "hotp_counter": "oathHOTPCounter",
}
def match_filter(self, filter):
@@ -47,8 +48,8 @@ class User(canaille.core.models.User, LDAPObject):
return super().match_filter(filter)
def save(self):
- if current_app.features.has_totp and not self.secret_token:
- self.generate_otp_token()
+ if current_app.features.has_otp and not self.secret_token:
+ self.initialize_otp()
group_attr = self.python_attribute_to_ldap("groups")
if group_attr not in self.changes:
diff --git a/canaille/backends/memory/backend.py b/canaille/backends/memory/backend.py
index 29015c49..ca0df63c 100644
--- a/canaille/backends/memory/backend.py
+++ b/canaille/backends/memory/backend.py
@@ -129,10 +129,10 @@ class MemoryBackend(Backend):
def save(self, instance):
if (
isinstance(instance, canaille.backends.memory.models.User)
- and current_app.features.has_totp
+ and current_app.features.has_otp
and not instance.secret_token
):
- instance.generate_otp_token()
+ instance.initialize_otp()
if not instance.id:
instance.id = str(uuid.uuid4())
diff --git a/canaille/backends/sql/models.py b/canaille/backends/sql/models.py
index 12630eca..dd21e738 100644
--- a/canaille/backends/sql/models.py
+++ b/canaille/backends/sql/models.py
@@ -105,10 +105,11 @@ class User(canaille.core.models.User, Base, SqlAlchemyModel):
TZDateTime(timezone=True), nullable=True
)
secret_token: Mapped[str] = mapped_column(String, nullable=True, unique=True)
+ hotp_counter: Mapped[int] = mapped_column(Integer, nullable=True)
def save(self):
- if current_app.features.has_totp and not self.secret_token:
- self.generate_otp_token()
+ if current_app.features.has_otp and not self.secret_token:
+ self.initialize_otp()
class Group(canaille.core.models.Group, Base, SqlAlchemyModel):
diff --git a/canaille/config.sample.toml b/canaille/config.sample.toml
index a2543ae0..9a7e5879 100644
--- a/canaille/config.sample.toml
+++ b/canaille/config.sample.toml
@@ -62,9 +62,12 @@ SECRET_KEY = "change me before you go in production"
# recovery link by email. This option is true by default.
# ENABLE_PASSWORD_RECOVERY = true
-# If ENABLE_TOTP is true, then users will need to authenticate themselves
-# using a time one-time password via an authenticator app. This option is false by default.
-# ENABLE_TOTP = false
+# If OTP_METHOD is defined, then users will need to authenticate themselves
+# using a one-time password (OTP) via an authenticator app.
+# Two options are supported : "TOTP" for time one-time password,
+# and "HOTP" for HMAC-based one-time password.
+# This option is deactivated by default.
+# OTP_METHOD = "TOTP"
# The validity duration of registration invitations, in seconds.
# Defaults to 2 days
@@ -119,7 +122,7 @@ SECRET_KEY = "change me before you go in production"
# USER_BASE = "ou=users,dc=mydomain,dc=tld"
# The object class to use for creating new users
-# Note : If you plan on using TOTP authentication, use : USER_CLASS = ["inetOrgPerson", "oathTOTPToken"]
+# Note : If you plan on using TOTP/HOTP authentication, use : USER_CLASS = ["inetOrgPerson", "oathHOTPToken"]
# USER_CLASS = "inetOrgPerson"
# The attribute to identify an object in the User dn.
diff --git a/canaille/core/commands.py b/canaille/core/commands.py
index b5f34643..844eb48e 100644
--- a/canaille/core/commands.py
+++ b/canaille/core/commands.py
@@ -1,7 +1,12 @@
+import json
+
import click
from flask.cli import with_appcontext
+from canaille.app import models
from canaille.app.commands import with_backendcontext
+from canaille.backends import Backend
+from canaille.backends.commands import serialize
try:
HAS_FAKER = True
@@ -50,3 +55,30 @@ def groups(ctx, nb_users_max):
def register(cli):
if HAS_FAKER: # pragma: no branch
cli.add_command(populate)
+ cli.add_command(reset_mfa)
+
+
+@click.command()
+@with_appcontext
+@with_backendcontext
+@click.argument("identifier")
+def reset_mfa(identifier):
+ """Reset multi-factor authentication for a user and display the
+ edited user in JSON format in the standard output.
+
+ IDENTIFIER should be a user id or user_name
+ """
+
+ instance = Backend.instance.get(models.User, identifier)
+ if not instance:
+ raise click.ClickException(f"No user with id '{identifier}'")
+
+ instance.initialize_otp()
+
+ try:
+ Backend.instance.save(instance)
+ except Exception as exc: # pragma: no cover
+ raise click.ClickException(exc) from exc
+
+ output = json.dumps(serialize(instance))
+ click.echo(output)
diff --git a/canaille/core/configuration.py b/canaille/core/configuration.py
index a529f135..160e7309 100644
--- a/canaille/core/configuration.py
+++ b/canaille/core/configuration.py
@@ -248,9 +248,11 @@ class CoreSettings(BaseModel):
"""If :py:data:`False`, then users cannot ask for a password recovery link
by email."""
- ENABLE_TOTP: bool = False
- """If :py:data:`True`, then users will need to authenticate themselves
- using a time one-time password via an authenticator app."""
+ OTP_METHOD: str = None
+ """If OTP_METHOD is defined, then users will need to authenticate themselves
+ using a one-time password (OTP) via an authenticator app.
+ If set to :py:data:`TOTP`, the application will use time one-time passwords,
+ If set to :py:data:`HOTP`, the application will use HMAC-based one-time passwords."""
INVITATION_EXPIRATION: int = 172800
"""The validity duration of registration invitations, in seconds.
diff --git a/canaille/core/endpoints/account.py b/canaille/core/endpoints/account.py
index b73a523a..0dc5f0ed 100644
--- a/canaille/core/endpoints/account.py
+++ b/canaille/core/endpoints/account.py
@@ -753,6 +753,19 @@ def profile_settings(user, edited_user):
return profile_settings_edit(user, edited_user)
+ if (
+ request.form.get("action") == "confirm-reset-mfa"
+ and current_app.features.has_otp
+ ):
+ return render_template("modals/reset-mfa.html", edited_user=edited_user)
+
+ if request.form.get("action") == "reset-mfa" and current_app.features.has_otp:
+ flash(_("Multi-factor authentication has been reset"), "success")
+ edited_user.initialize_otp()
+ Backend.instance.save(edited_user)
+
+ return profile_settings_edit(user, edited_user)
+
abort(400, f"bad form action: {request.form.get('action')}")
diff --git a/canaille/core/endpoints/auth.py b/canaille/core/endpoints/auth.py
index 7d974a37..2fda9344 100644
--- a/canaille/core/endpoints/auth.py
+++ b/canaille/core/endpoints/auth.py
@@ -107,7 +107,7 @@ def password():
"password.html", form=form, username=session["attempt_login"]
)
- if not current_app.features.has_totp:
+ if not current_app.features.has_otp:
current_app.logger.security(
f'Succeed login attempt for {session["attempt_login"]} from {request_ip}'
)
@@ -271,7 +271,7 @@ def reset(user, hash):
@bp.route("/setup-2fa")
def setup_two_factor_auth():
- if not current_app.features.has_totp:
+ if not current_app.features.has_otp:
abort(404)
if current_user():
@@ -287,7 +287,7 @@ def setup_two_factor_auth():
session["attempt_login_with_correct_password"]
)
- uri = user.get_authentication_setup_uri()
+ uri = user.get_otp_authentication_setup_uri()
base64_qr_image = get_b64encoded_qr_image(uri)
return render_template(
"setup-2fa.html",
@@ -299,7 +299,7 @@ def setup_two_factor_auth():
@bp.route("/verify-2fa", methods=["GET", "POST"])
def verify_two_factor_auth():
- if not current_app.features.has_totp:
+ if not current_app.features.has_otp:
abort(404)
if current_user():
@@ -361,6 +361,6 @@ def verify_two_factor_auth():
)
request_ip = request.remote_addr or "unknown IP"
current_app.logger.security(
- f'Failed login attempt (wrong TOTP) for {session["attempt_login_with_correct_password"]} from {request_ip}'
+ f'Failed login attempt (wrong OTP) for {session["attempt_login_with_correct_password"]} from {request_ip}'
)
return redirect(url_for("core.auth.verify_two_factor_auth"))
diff --git a/canaille/core/models.py b/canaille/core/models.py
index 8339b55a..0927545f 100644
--- a/canaille/core/models.py
+++ b/canaille/core/models.py
@@ -9,6 +9,8 @@ from flask import current_app
from canaille.backends.models import Model
from canaille.core.configuration import Permission
+HOTP_LOOK_AHEAD_WINDOW = 10
+
class User(Model):
"""User model, based on the `SCIM User schema
@@ -251,6 +253,10 @@ class User(Model):
"""Unique token generated for each user, used for
two-factor authentication."""
+ hotp_counter: int | None = None
+ """HMAC-based One Time Password counter, used for
+ two-factor authentication."""
+
_readable_fields = None
_writable_fields = None
_permissions = None
@@ -328,19 +334,51 @@ class User(Model):
self._writable_fields |= set(details["WRITE"])
return self._writable_fields
- def generate_otp_token(self):
+ def initialize_otp(self):
self.secret_token = secrets.token_hex(32)
+ self.last_otp_login = None
+ if current_app.features.otp_method == "HOTP":
+ self.hotp_counter = 1
- def generate_otp(self):
- return otpauth.TOTP(bytes(self.secret_token, "utf-8")).generate()
+ def generate_otp(self, counter_delta=0):
+ method = current_app.features.otp_method
+ if method == "TOTP":
+ totp = otpauth.TOTP(bytes(self.secret_token, "utf-8"))
+ return totp.string_code(totp.generate())
+ elif method == "HOTP":
+ hotp = otpauth.HOTP(bytes(self.secret_token, "utf-8"))
+ return hotp.string_code(hotp.generate(self.hotp_counter + counter_delta))
- def get_authentication_setup_uri(self):
- return otpauth.TOTP(bytes(self.secret_token, "utf-8")).to_uri(
- label=self.user_name, issuer=current_app.config["CANAILLE"]["NAME"]
- )
+ def get_otp_authentication_setup_uri(self):
+ method = current_app.features.otp_method
+ if method == "TOTP":
+ return otpauth.TOTP(bytes(self.secret_token, "utf-8")).to_uri(
+ label=self.user_name, issuer=current_app.config["CANAILLE"]["NAME"]
+ )
+ elif method == "HOTP":
+ return otpauth.HOTP(bytes(self.secret_token, "utf-8")).to_uri(
+ label=self.user_name,
+ issuer=current_app.config["CANAILLE"]["NAME"],
+ counter=self.hotp_counter,
+ )
def is_otp_valid(self, user_otp):
- return otpauth.TOTP(bytes(self.secret_token, "utf-8")).verify(user_otp)
+ method = current_app.features.otp_method
+ if method == "TOTP":
+ return otpauth.TOTP(bytes(self.secret_token, "utf-8")).verify(user_otp)
+ elif method == "HOTP":
+ counter = self.hotp_counter
+ is_valid = False
+ # if user token's counter is ahead of canaille's, try to catch up to it
+ while counter - self.hotp_counter <= HOTP_LOOK_AHEAD_WINDOW:
+ is_valid = otpauth.HOTP(bytes(self.secret_token, "utf-8")).verify(
+ user_otp, counter
+ )
+ counter += 1
+ if is_valid:
+ self.hotp_counter = counter
+ return True
+ return False
class Group(Model):
diff --git a/canaille/core/templates/modals/reset-mfa.html b/canaille/core/templates/modals/reset-mfa.html
new file mode 100644
index 00000000..07544cea
--- /dev/null
+++ b/canaille/core/templates/modals/reset-mfa.html
@@ -0,0 +1,32 @@
+{% extends theme('base.html') %}
+
+{% block content %}
+
+ {% if features.has_otp and user.can_manage_users %}
+
+ {% endif %}
+
{% if features.has_account_lockability and "lock_date" in user.writable_fields and not edited_user.locked %}