From 3fe056c048359e856352d9d293e1f56c4d98823c Mon Sep 17 00:00:00 2001 From: LauraCForgeFlow Date: Tue, 14 Jul 2026 12:30:40 +0200 Subject: [PATCH] [ADD] auth_totp_enforce --- .github/workflows/test.yml | 11 +- auth_totp_enforce/README.rst | 100 ++++ auth_totp_enforce/__init__.py | 5 + auth_totp_enforce/__manifest__.py | 18 + auth_totp_enforce/controllers/__init__.py | 4 + auth_totp_enforce/controllers/home.py | 71 +++ auth_totp_enforce/models/__init__.py | 4 + auth_totp_enforce/models/res_users.py | 57 +++ auth_totp_enforce/pyproject.toml | 3 + auth_totp_enforce/readme/CONTRIBUTORS.md | 2 + auth_totp_enforce/readme/DESCRIPTION.md | 10 + auth_totp_enforce/readme/USAGE.md | 7 + auth_totp_enforce/security/res_groups.xml | 10 + .../static/description/index.html | 447 ++++++++++++++++++ auth_totp_enforce/tests/__init__.py | 5 + auth_totp_enforce/tests/test_enforce.py | 54 +++ auth_totp_enforce/tests/test_enforce_flow.py | 91 ++++ auth_totp_enforce/views/templates.xml | 89 ++++ 18 files changed, 986 insertions(+), 2 deletions(-) create mode 100644 auth_totp_enforce/README.rst create mode 100644 auth_totp_enforce/__init__.py create mode 100644 auth_totp_enforce/__manifest__.py create mode 100644 auth_totp_enforce/controllers/__init__.py create mode 100644 auth_totp_enforce/controllers/home.py create mode 100644 auth_totp_enforce/models/__init__.py create mode 100644 auth_totp_enforce/models/res_users.py create mode 100644 auth_totp_enforce/pyproject.toml create mode 100644 auth_totp_enforce/readme/CONTRIBUTORS.md create mode 100644 auth_totp_enforce/readme/DESCRIPTION.md create mode 100644 auth_totp_enforce/readme/USAGE.md create mode 100644 auth_totp_enforce/security/res_groups.xml create mode 100644 auth_totp_enforce/static/description/index.html create mode 100644 auth_totp_enforce/tests/__init__.py create mode 100644 auth_totp_enforce/tests/test_enforce.py create mode 100644 auth_totp_enforce/tests/test_enforce_flow.py create mode 100644 auth_totp_enforce/views/templates.xml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a3e2701930..c48e6dc7c6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -43,10 +43,17 @@ jobs: name: test with OCB makepot: "true" - container: ghcr.io/oca/oca-ci/py3.10-odoo18.0:latest - exclude: "password_security" + include: "auth_totp_enforce" name: test with Odoo - container: ghcr.io/oca/oca-ci/py3.10-ocb18.0:latest - exclude: "password_security" + include: "auth_totp_enforce" + name: test with OCB + makepot: "true" + - container: ghcr.io/oca/oca-ci/py3.10-odoo18.0:latest + exclude: "password_security,auth_totp_enforce" + name: test with Odoo + - container: ghcr.io/oca/oca-ci/py3.10-ocb18.0:latest + exclude: "password_security,auth_totp_enforce" name: test with OCB makepot: "true" services: diff --git a/auth_totp_enforce/README.rst b/auth_totp_enforce/README.rst new file mode 100644 index 0000000000..37b4b0ceb0 --- /dev/null +++ b/auth_totp_enforce/README.rst @@ -0,0 +1,100 @@ +================================= +Enforce Two-Factor Authentication +================================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:d7a9647142270eff465347946c269fc042be4388b9f9d22a0d3ff1239f105dae + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fserver--auth-lightgray.png?logo=github + :target: https://github.com/OCA/server-auth/tree/18.0/auth_totp_enforce + :alt: OCA/server-auth +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/server-auth-18-0/server-auth-18-0-auth_totp_enforce + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/server-auth&target_branch=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module forces internal users to configure two-factor authentication +(a TOTP authenticator app) before they log in. + +Enforcement works like the password expiry flow of +``password_security``: nobody is logged out when the module is +installed, but on their next login users are held in a mandatory step +and cannot reach the backend until they have enabled an authenticator +app. + +Members of the **Exempt from 2FA enforcement** group are never forced. +Portal and public users are out of scope at the moment. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +Once installed, any enforced internal user without an authenticator app +configured will, on their next login: + +1. Enter their login and password as usual. +2. Be redirected to a mandatory setup page showing a QR code and secret + key. +3. Scan the code with an authenticator app and enter the generated code. +4. Be handed over to the standard two-factor step to complete the login. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* ForgeFlow + +Contributors +------------ + +- ForgeFlow S.L. + + - Laura Cazorla + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/server-auth `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/auth_totp_enforce/__init__.py b/auth_totp_enforce/__init__.py new file mode 100644 index 0000000000..97141152b6 --- /dev/null +++ b/auth_totp_enforce/__init__.py @@ -0,0 +1,5 @@ +# Copyright 2026 ForgeFlow S.L. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import models +from . import controllers diff --git a/auth_totp_enforce/__manifest__.py b/auth_totp_enforce/__manifest__.py new file mode 100644 index 0000000000..f28292e851 --- /dev/null +++ b/auth_totp_enforce/__manifest__.py @@ -0,0 +1,18 @@ +# Copyright 2026 ForgeFlow S.L. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "Enforce Two-Factor Authentication", + "summary": "Force users to set up an authenticator app before they can log in.", + "version": "18.0.1.0.0", + "author": "ForgeFlow, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/server-auth", + "category": "Authentication", + "license": "AGPL-3", + "depends": ["auth_totp"], + "data": [ + "security/res_groups.xml", + "views/templates.xml", + ], + "installable": True, +} diff --git a/auth_totp_enforce/controllers/__init__.py b/auth_totp_enforce/controllers/__init__.py new file mode 100644 index 0000000000..fba520a076 --- /dev/null +++ b/auth_totp_enforce/controllers/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2026 ForgeFlow S.L. +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import home diff --git a/auth_totp_enforce/controllers/home.py b/auth_totp_enforce/controllers/home.py new file mode 100644 index 0000000000..c4723e964d --- /dev/null +++ b/auth_totp_enforce/controllers/home.py @@ -0,0 +1,71 @@ +# Copyright 2026 ForgeFlow S.L. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import _, http +from odoo.http import request + +import odoo.addons.auth_totp.controllers.home + +SESSION_KEY = "totp_enforce_setup" + + +class Home(odoo.addons.auth_totp.controllers.home.Home): + @http.route( + "/web/login/totp/setup", + type="http", + auth="public", + methods=["GET", "POST"], + sitemap=False, + website=True, + multilang=False, + ) + def web_totp_setup(self, redirect=None, **kwargs): + if request.session.uid: + return request.redirect( + self._login_redirect(request.session.uid, redirect=redirect) + ) + if not request.session.pre_uid: + return request.redirect("/web/login") + user = request.env["res.users"].sudo().browse(request.session.pre_uid) + if not user._mfa_enforced() or user.totp_enabled: + return request.redirect("/web/login/totp") + stored = request.session.get(SESSION_KEY) + if not stored or stored.get("uid") != user.id: + secret = user._generate_totp_setup_secret() + request.session[SESSION_KEY] = {"uid": user.id, "secret": secret} + else: + secret = stored["secret"] + error = None + if request.httprequest.method == "POST" and kwargs.get("totp_token"): + if user._totp_enforce_setup(secret, kwargs["totp_token"]): + request.session.pop(SESSION_KEY, None) + # Persist secret, session stays partial and delegate to standard MFA + request.env.flush_all() + user.invalidate_recordset(["totp_secret", "totp_enabled"]) + request.session.touch() + return request.redirect( + self._login_redirect(user.id, redirect=redirect) + ) + error = _("Verification failed, please double-check the 6-digit code") + wizard = ( + request.env["auth_totp.wizard"] + .sudo() + .new( + { + "user_id": user.id, + "secret": secret, + } + ) + ) + request.session.touch() + return request.render( + "auth_totp_enforce.auth_totp_setup_form", + { + "user": user, + "error": error, + "redirect": redirect, + "secret": secret, + "qrcode": wizard.qrcode, + "url": wizard.url, + }, + ) diff --git a/auth_totp_enforce/models/__init__.py b/auth_totp_enforce/models/__init__.py new file mode 100644 index 0000000000..56c7b1c2fe --- /dev/null +++ b/auth_totp_enforce/models/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2026 ForgeFlow S.L. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import res_users diff --git a/auth_totp_enforce/models/res_users.py b/auth_totp_enforce/models/res_users.py new file mode 100644 index 0000000000..bdd991f153 --- /dev/null +++ b/auth_totp_enforce/models/res_users.py @@ -0,0 +1,57 @@ +# Copyright 2026 ForgeFlow S.L. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import base64 +import functools +import logging +import os +import re + +from odoo import models +from odoo.http import request + +from odoo.addons.auth_totp.models.totp import TOTP, TOTP_SECRET_SIZE + +_logger = logging.getLogger(__name__) + +compress = functools.partial(re.sub, r"\s", "") + + +class ResUsers(models.Model): + _inherit = "res.users" + + def _mfa_enforced(self): + self.ensure_one() + user = self.sudo() + exempt_group = "auth_totp_enforce.group_mfa_exempt" + return user._is_internal() and not user.has_group(exempt_group) + + def _mfa_url(self): + r = super()._mfa_url() + if r is not None: + return r + if self._mfa_enforced() and not self.totp_enabled: + return "/web/login/totp/setup" + + def _generate_totp_setup_secret(self): + secret = base64.b32encode(os.urandom(TOTP_SECRET_SIZE // 8)).decode() + return " ".join(map("".join, zip(*[iter(secret)] * 4, strict=False))) + + def _totp_enforce_setup(self, secret, code): + self.ensure_one() + assert self.env.su or ( + request + and not request.session.uid + and request.session.get("pre_uid") == self.id + ), "Only callable for the user currently in the pre-authentication phase" + secret = compress(secret).upper() + try: + code = int(compress(str(code))) + except ValueError: + return False + if TOTP(base64.b32decode(secret)).match(code) is None: + _logger.info("2FA enforce setup: REJECT CODE for %r", self.login) + return False + self.sudo().totp_secret = secret + _logger.info("2FA enforce setup: SUCCESS for %r", self.login) + return True diff --git a/auth_totp_enforce/pyproject.toml b/auth_totp_enforce/pyproject.toml new file mode 100644 index 0000000000..4231d0cccb --- /dev/null +++ b/auth_totp_enforce/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/auth_totp_enforce/readme/CONTRIBUTORS.md b/auth_totp_enforce/readme/CONTRIBUTORS.md new file mode 100644 index 0000000000..c14e159d67 --- /dev/null +++ b/auth_totp_enforce/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- ForgeFlow S.L. \<\> + - Laura Cazorla \<\> diff --git a/auth_totp_enforce/readme/DESCRIPTION.md b/auth_totp_enforce/readme/DESCRIPTION.md new file mode 100644 index 0000000000..4e4b6f899e --- /dev/null +++ b/auth_totp_enforce/readme/DESCRIPTION.md @@ -0,0 +1,10 @@ +This module forces internal users to configure two-factor authentication +(a TOTP authenticator app) before they log in. + +Enforcement works like the password expiry flow of `password_security`: nobody +is logged out when the module is installed, but on their next login users are +held in a mandatory step and cannot reach the backend until they have enabled +an authenticator app. + +Members of the **Exempt from 2FA enforcement** group are never forced. Portal +and public users are out of scope at the moment. diff --git a/auth_totp_enforce/readme/USAGE.md b/auth_totp_enforce/readme/USAGE.md new file mode 100644 index 0000000000..76d114f76a --- /dev/null +++ b/auth_totp_enforce/readme/USAGE.md @@ -0,0 +1,7 @@ +Once installed, any enforced internal user without an authenticator app +configured will, on their next login: + +1. Enter their login and password as usual. +2. Be redirected to a mandatory setup page showing a QR code and secret key. +3. Scan the code with an authenticator app and enter the generated code. +4. Be handed over to the standard two-factor step to complete the login. diff --git a/auth_totp_enforce/security/res_groups.xml b/auth_totp_enforce/security/res_groups.xml new file mode 100644 index 0000000000..80b17f4f29 --- /dev/null +++ b/auth_totp_enforce/security/res_groups.xml @@ -0,0 +1,10 @@ + + + + Exempt from 2FA enforcement + + Members of this group are NOT forced to configure MFA at login. + + diff --git a/auth_totp_enforce/static/description/index.html b/auth_totp_enforce/static/description/index.html new file mode 100644 index 0000000000..5c807c6e82 --- /dev/null +++ b/auth_totp_enforce/static/description/index.html @@ -0,0 +1,447 @@ + + + + + +Enforce Two-Factor Authentication + + + +
+

Enforce Two-Factor Authentication

+ + +

Beta License: AGPL-3 OCA/server-auth Translate me on Weblate Try me on Runboat

+

This module forces internal users to configure two-factor authentication +(a TOTP authenticator app) before they log in.

+

Enforcement works like the password expiry flow of +password_security: nobody is logged out when the module is +installed, but on their next login users are held in a mandatory step +and cannot reach the backend until they have enabled an authenticator +app.

+

Members of the Exempt from 2FA enforcement group are never forced. +Portal and public users are out of scope at the moment.

+

Table of contents

+ +
+

Usage

+

Once installed, any enforced internal user without an authenticator app +configured will, on their next login:

+
    +
  1. Enter their login and password as usual.
  2. +
  3. Be redirected to a mandatory setup page showing a QR code and secret +key.
  4. +
  5. Scan the code with an authenticator app and enter the generated code.
  6. +
  7. Be handed over to the standard two-factor step to complete the login.
  8. +
+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • ForgeFlow
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/server-auth project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/auth_totp_enforce/tests/__init__.py b/auth_totp_enforce/tests/__init__.py new file mode 100644 index 0000000000..eb7a2fd2cc --- /dev/null +++ b/auth_totp_enforce/tests/__init__.py @@ -0,0 +1,5 @@ +# Copyright 2026 ForgeFlow S.L. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import test_enforce +from . import test_enforce_flow diff --git a/auth_totp_enforce/tests/test_enforce.py b/auth_totp_enforce/tests/test_enforce.py new file mode 100644 index 0000000000..fd23e84efd --- /dev/null +++ b/auth_totp_enforce/tests/test_enforce.py @@ -0,0 +1,54 @@ +# Copyright 2026 ForgeFlow S.L. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import base64 +import time + +from odoo.tests import TransactionCase, new_test_user, tagged + +from odoo.addons.auth_totp.models.totp import TIMESTEP, hotp + + +@tagged("post_install", "-at_install") +class TestEnforce(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.internal_user = new_test_user( + cls.env, + login="totp_internal", + password="totp_internal_pwd", + ) + + def _valid_code(self, secret): + key = base64.b32decode(secret.replace(" ", "").upper()) + return hotp(key, int(time.time() / TIMESTEP)) + + def test_setup_with_valid_code_enables_totp(self): + secret = self.internal_user._generate_totp_setup_secret() + self.assertFalse(self.internal_user.totp_enabled) + code = self._valid_code(secret) + enforce = self.internal_user.sudo()._totp_enforce_setup(secret, code) + self.assertTrue(enforce) + self.assertTrue(self.internal_user.totp_enabled) + + def test_setup_with_wrong_code_is_rejected(self): + secret = self.internal_user._generate_totp_setup_secret() + # A code from a far-away time window is outside the match window + key = base64.b32decode(secret.replace(" ", "").upper()) + wrong_code = hotp(key, int(time.time() / TIMESTEP) - 10000) + enforce = self.internal_user.sudo()._totp_enforce_setup(secret, wrong_code) + self.assertFalse(enforce) + self.assertFalse(self.internal_user.totp_enabled) + + def test_setup_with_non_numeric_code_is_rejected(self): + secret = self.internal_user._generate_totp_setup_secret() + enforce = self.internal_user.sudo()._totp_enforce_setup(secret, "not-a-code") + self.assertFalse(enforce) + self.assertFalse(self.internal_user.totp_enabled) + + def test_generated_secret_is_valid_base32(self): + secret = self.internal_user._generate_totp_setup_secret() + # Must decode without error and yield the expected key size + key = base64.b32decode(secret.replace(" ", "").upper()) + self.assertEqual(len(key) * 8, 160) diff --git a/auth_totp_enforce/tests/test_enforce_flow.py b/auth_totp_enforce/tests/test_enforce_flow.py new file mode 100644 index 0000000000..cf042e0ce1 --- /dev/null +++ b/auth_totp_enforce/tests/test_enforce_flow.py @@ -0,0 +1,91 @@ +# Copyright 2026 ForgeFlow S.L. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import base64 +import json +import re +import time + +from odoo.tests import HttpCase, get_db_name, new_test_user, tagged +from odoo.tools import mute_logger + +from odoo.addons.auth_totp.models.totp import TIMESTEP, hotp + + +@tagged("post_install", "-at_install") +class TestEnforceFlow(HttpCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.enforced = new_test_user( + cls.env, + login="flow_enforced", + password="flow_enforced_pwd", + tz="UTC", + ) + cls.exempt = new_test_user( + cls.env, + login="flow_exempt", + password="flow_exempt_pwd", + tz="UTC", + groups="base.group_user,auth_totp_enforce.group_mfa_exempt", + ) + + def _password_login(self, login, password): + # POST to the JSON auth endpoint and return the ``result`` dict + payload = { + "jsonrpc": "2.0", + "method": "call", + "id": 0, + "params": {"db": get_db_name(), "login": login, "password": password}, + } + response = self.url_open( + "/web/session/authenticate", + data=json.dumps(payload), + headers={"Content-Type": "application/json"}, + ) + return response.json()["result"] + + @mute_logger("odoo.http") + def test_enforced_user_is_held_before_backend(self): + # Enforced user must not be fully logged in until 2FA is set up + result = self._password_login("flow_enforced", "flow_enforced_pwd") + self.assertIsNone(result["uid"]) + + def test_exempt_user_logs_in_directly(self): + # Exempt user must reach the backend without any 2FA step + result = self._password_login("flow_exempt", "flow_exempt_pwd") + self.assertEqual(result["uid"], self.exempt.id) + + @mute_logger("odoo.http") + def test_setup_page_activates_and_hands_off_to_standard_step(self): + # Password step -> partial session (blocked) + u_id = self._password_login("flow_enforced", "flow_enforced_pwd")["uid"] + self.assertIsNone(u_id) + # The mandatory setup page is served on the partial session + page = self.url_open("/web/login/totp/setup") + self.assertEqual(page.status_code, 200) + self.assertIn("Set up MFA", page.text) + # Read the generated secret and CSRF token off the page + sec = re.search(r"]*>\s*([A-Z2-7 ]+?)\s*", page.text).group(1) + csrf = re.search(r'name="csrf_token"[^>]*value="([^"]+)"', page.text).group(1) + # Submit a valid code computed from that secret + key = base64.b32decode(sec.replace(" ", "")) + code = hotp(key, int(time.time() / TIMESTEP)) + response_data = {"totp_token": str(code), "csrf_token": csrf} + response = self.url_open("/web/login/totp/setup", data=response_data) + self.assertEqual(response.status_code, 200) + # The secret is now stored on the user + self.enforced.invalidate_recordset(["totp_secret", "totp_enabled"]) + self.assertTrue(self.enforced.totp_enabled) + # We were redirected to standard MFA (still not logged in) + self.assertNotIn("Set up MFA", response.text) + self.assertIn("Authentication Code", response.text) + + def test_setup_page_redirects_when_no_partial_session(self): + # Setup URL without a pre-authenticated session bounce back to login page + response = self.url_open("/web/login/totp/setup", allow_redirects=False) + self.assertIn(response.status_code, (302, 303)) + # Location may be absolute (http://host/web/login) or relative. + location = response.headers.get("Location", "") + self.assertTrue(location.endswith("/web/login")) diff --git a/auth_totp_enforce/views/templates.xml b/auth_totp_enforce/views/templates.xml new file mode 100644 index 0000000000..b3b15d9046 --- /dev/null +++ b/auth_totp_enforce/views/templates.xml @@ -0,0 +1,89 @@ + + + +