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 %} + +{% endblock %} diff --git a/canaille/core/templates/profile_settings.html b/canaille/core/templates/profile_settings.html index 6021e832..56a37471 100644 --- a/canaille/core/templates/profile_settings.html +++ b/canaille/core/templates/profile_settings.html @@ -140,6 +140,12 @@
+ {% if features.has_otp and user.can_manage_users %} + + {% endif %} + {% if features.has_account_lockability and "lock_date" in user.writable_fields and not edited_user.locked %}