diff --git a/auth_sms/README.rst b/auth_sms/README.rst index 84df123809..6800f71b1a 100644 --- a/auth_sms/README.rst +++ b/auth_sms/README.rst @@ -119,6 +119,14 @@ 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. +.. |maintainer-NL66278| image:: https://github.com/NL66278.png?size=40px + :target: https://github.com/NL66278 + :alt: NL66278 + +Current `maintainer `__: + +|maintainer-NL66278| + 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_sms/__manifest__.py b/auth_sms/__manifest__.py index 431cbb5062..898e2c1004 100644 --- a/auth_sms/__manifest__.py +++ b/auth_sms/__manifest__.py @@ -4,6 +4,7 @@ "name": "Two factor authentication via SMS", "version": "16.0.1.0.0", "author": "Therp BV,Odoo Community Association (OCA)", + "maintainers": ["NL66278"], "license": "AGPL-3", "category": "Tools", "website": "https://github.com/OCA/server-auth", diff --git a/auth_sms/models/res_users.py b/auth_sms/models/res_users.py index a1805c21d2..818811af7e 100644 --- a/auth_sms/models/res_users.py +++ b/auth_sms/models/res_users.py @@ -1,9 +1,9 @@ -# Copyright 2019 Therp BV +# Copyright 2019-2025 Therp BV # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). import logging import random import string -from datetime import datetime, timedelta +from datetime import timedelta from odoo import _, api, fields, models from odoo.exceptions import UserError @@ -114,9 +114,24 @@ def _auth_sms_send(self, user_id): raise UserError(_("Sending SMS failed")) def _auth_sms_check_rate_limit(self): - """return false if the user has requested an SMS code too often""" + """Return false if the user has requested an SMS code too often""" self.ensure_one() - rate_limit_hours = float( + rate_limit_hours = self._get_rate_limit_hours() + rate_limit_limit = self._get_rate_limit_limit() + if not (rate_limit_hours and rate_limit_limit): + return False + cutoff_time = fields.Datetime.now() - timedelta(hours=rate_limit_hours) + already_sent = self.env["auth_sms.code"].search_count( + [("create_date", ">=", cutoff_time), ("user_id", "=", self.id)] + ) + within_limit = already_sent <= rate_limit_limit + if not within_limit: + _logger.info("To many sms's send to user %(login)s", {"login": self.login}) + return within_limit + + def _get_rate_limit_hours(self): + """Return timeframe in which to check count of sms's send to user.""" + return float( self.env["ir.config_parameter"] .sudo() .get_param( @@ -124,7 +139,10 @@ def _auth_sms_check_rate_limit(self): 24, ) ) - rate_limit_limit = float( + + def _get_rate_limit_limit(self): + """Return limit of times sms send to user within a specific timeframe.""" + return float( self.env["ir.config_parameter"] .sudo() .get_param( @@ -132,24 +150,6 @@ def _auth_sms_check_rate_limit(self): 10, ) ) - return ( - rate_limit_hours - and rate_limit_limit - and self.env["auth_sms.code"].search( - [ - ( - "create_date", - ">=", - fields.Datetime.to_string( - datetime.now() - timedelta(hours=rate_limit_hours), - ), - ), - ("user_id", "=", self.id), - ], - count=True, - ) - <= rate_limit_limit - ) def _mfa_type(self): """If auth_sms enabled, disable other totp methods.""" diff --git a/auth_sms/models/sms_provider.py b/auth_sms/models/sms_provider.py index c376f0533b..84050bdf58 100644 --- a/auth_sms/models/sms_provider.py +++ b/auth_sms/models/sms_provider.py @@ -20,8 +20,7 @@ class SmsProvider(models.Model): # could be the preparation for a module base_sms that doesn't rely on # Odoo's in app purchases as the v12 sms module does _name = "sms.provider" - _description = "Holds whatever data necessary to send an SMS via some " - "provider" + _description = "Holds whatever data necessary to send an SMS via some provider" _rec_name = "provider" _order = "sequence desc" diff --git a/auth_sms/static/description/index.html b/auth_sms/static/description/index.html index 349c0bfe96..1afb5795d7 100644 --- a/auth_sms/static/description/index.html +++ b/auth_sms/static/description/index.html @@ -464,6 +464,8 @@

Maintainers

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.

+

Current maintainer:

+

NL66278

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_sms/tests/__init__.py b/auth_sms/tests/__init__.py index 75b2585923..129bcc9957 100644 --- a/auth_sms/tests/__init__.py +++ b/auth_sms/tests/__init__.py @@ -1,3 +1,2 @@ # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). -from . import common from . import test_auth_sms diff --git a/auth_sms/tests/common.py b/auth_sms/tests/common.py index bf064a0f04..382484e8bc 100644 --- a/auth_sms/tests/common.py +++ b/auth_sms/tests/common.py @@ -1,47 +1,29 @@ -# Copyright 2019 Therp BV +# Copyright 2019-2025 Therp BV # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). -from contextlib import contextmanager -from functools import partial +from odoo.tests import HttpCase, new_test_user -from werkzeug.test import EnvironBuilder -from werkzeug.wrappers import Request as WerkzeugRequest -from odoo import http -from odoo.tests.common import TransactionCase +class HttpCaseSMS(HttpCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.admin_user = cls.env.ref("base.user_admin") + cls.username = "dportier" + cls.password = "!asdQWE12345_3" # strong password + cls.demo_user = cls._create_user() + cls.code = None + cls.secret = None - -class Common(TransactionCase): - def setUp(self): - super(Common, self).setUp() - self.session = http.root.session_store.new() - self.env["res.users"]._register_hook() - self.demo_user = self.env.ref("auth_sms.demo_user") - self.env["auth_sms.code"].search([]).unlink() - - @contextmanager - def _request(self, path, method="POST", data=None): - """yield request, endpoint for given http request data""" - werkzeug_env = EnvironBuilder( - method=method, - path=path, - data=data, - headers=[("cookie", "session_id=%s" % self.session.sid)], - environ_base={ - "HTTP_HOST": "localhost", - "REMOTE_ADDR": "127.0.0.1", - }, - ).get_environ() - werkzeug_request = WerkzeugRequest(werkzeug_env) - http.root.setup_session(werkzeug_request) - werkzeug_request.session.db = self.env.cr.dbname - http.root.setup_db(werkzeug_request) - http.root.setup_lang(werkzeug_request) - - request = http.HttpRequest(werkzeug_request) - request._env = self.env - with request: - routing_map = self.env["ir.http"].routing_map() - endpoint, dummy = routing_map.bind_to_environ(werkzeug_env).match( - return_rule=False, - ) - yield request, partial(endpoint, **request.params) + @classmethod + def _create_user(cls): + """Create auth_sms_enabled user.""" + return new_test_user( + cls.env, + login=cls.username, + context={"no_reset_password": True}, + password=cls.password, + name="Auth SMS test user", + mobile="0123456789", + email="auth_sms_test_user@yourcompany.com", + auth_sms_enabled=True, + ) diff --git a/auth_sms/tests/test_auth_sms.py b/auth_sms/tests/test_auth_sms.py index 100cbc3ecb..bfc008cf14 100644 --- a/auth_sms/tests/test_auth_sms.py +++ b/auth_sms/tests/test_auth_sms.py @@ -1,88 +1,108 @@ -# Copyright 2019 Therp BV +# Copyright 2019-2025 Therp BV # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). -from unittest.mock import patch +from unittest import mock + +from lxml.html import document_fromstring from odoo import http +from odoo.tests import HOST, Opener, get_db_name, tagged + +from .common import HttpCaseSMS -from .common import Common +_module_ns = "odoo.addons.auth_sms" +_requests_class = _module_ns + ".models.sms_provider.requests" +_users_class = _module_ns + ".models.res_users.ResUsers" -class TestAuthSms(Common): +@tagged("post_install", "-at_install") +class TestAuthSms(HttpCaseSMS): def test_auth_sms_login_no_2fa(self): # admin doesn't have sms verification turned on - with self._request( - "/web/login", - method="POST", - data={ - "login": self.env.user.login, - "password": self.env.user.login, - }, - ) as (request, endpoint): - response = endpoint() - self.assertFalse(response.template) + response = self._login_user(self.admin_user.login, self.admin_user.login) + self.assertEqual(response.request.path_url, "/web") + self.assertEqual(response.status_code, 200) + + def test_auth_sms_login_no_error(self): + # first request: login + response = self._mock_login_user(self.demo_user.login, self.password) + self.assertEqual(response.request.path_url, "/web/login") + # fill the correct code + response = self._enter_code(self.code) + self.assertEqual(response.request.path_url, "/web") def test_auth_sms_login(self): # first request: login - with self._request( - "/web/login", - data={ - "login": self.demo_user.login, - "password": self.demo_user.login, - }, - ) as (request, endpoint), patch( - "odoo.addons.auth_sms.models.sms_provider.requests.post", - ) as mock_request_post: + response = self._mock_login_user(self.demo_user.login, self.password) + self.assertEqual(response.request.path_url, "/web/login") + # then fill in a wrong code + response = self._enter_code("wrong code") + self.assertEqual(response.request.path_url, "/auth_sms/code") + # fill the correct code + response = self._enter_code(self.code) + self.assertEqual(response.request.path_url, "/web") + + def test_auth_sms_rate_limit(self): + """Request codes until we hit the rate limit.""" + # Make sure there are no codes left. + self.env["auth_sms.code"].search([("user_id", "=", self.demo_user.id)]).unlink() + for _i in range(10): + response = self._mock_login_user(self.demo_user.login, self.password) + self.assertEqual(response.request.path_url, "/web/login") + # 10th time should result in error (assuming default limit). + # DO not call with _mock, as sms will not be send anyway. + response = self._login_user(self.demo_user.login, self.password) + self.assertEqual(response.request.path_url, "/web/login") + self.assertEqual(response.status_code, 200) + self.assertIn( + "Rate limit for SMS exceeded", + response.text, + ) + + def _mock_login_user(self, login, password): + """Login as a specific user (assume password is same as login).""" + with mock.patch(_requests_class + ".post") as mock_request_post: mock_request_post.return_value.json.return_value = { "originator": "originator", } - response = endpoint() - self.assertEqual(response.template, "auth_sms.template_code") - self.assertTrue(request.session["auth_sms.password"]) - mock_request_post.assert_called_once() - http.root.session_store.save(request.session) + response = self._login_user(login, password) + # retrieve the code to use from the mocked call + self.code = mock_request_post.mock_calls[0][2]["data"]["body"] + # retrieve the secret from the response, if present. + document = document_fromstring(response.content) + secret_inputs = document.xpath("//input[@name='secret']") + self.secret = secret_inputs[0].get("value") if secret_inputs else None + return response - # then fill in a wrong code - with self._request( - "/auth_sms/code", - data={ - "secret": response.qcontext["secret"], - "user_login": response.qcontext["login"], - "password": "wrong code", - }, - ) as (request, endpoint): - response = endpoint() - self.assertEqual(response.template, "auth_sms.template_code") - self.assertTrue(response.qcontext["error"]) + def _login_user(self, login, password): + """Login as a specific user.""" + # Code largely taken from password_security/tests/test_login.py. + # session must be part of self, because of csrf_token method. + self.session = http.root.session_store.new() + self.opener = Opener(self.env.cr) + self.opener.cookies.set("session_id", self.session.sid, domain=HOST, path="/") + with mock.patch("odoo.http.db_filter") as db_filter: + db_filter.side_effect = lambda dbs, host=None: [get_db_name()] + # The response returned here is not the odoo.http.Response class, + # but the requests.Response. + response = self.url_open( + "/web/login", + data={ + "login": login, + "password": password, + "csrf_token": http.Request.csrf_token(self), + }, + ) + response.raise_for_status() + return response - # fill the correct code - with self._request( + def _enter_code(self, code): + """Enter code from sms (wrong or correct).""" + return self.url_open( "/auth_sms/code", data={ - "secret": response.qcontext["secret"], - "user_login": response.qcontext["login"], - "password": mock_request_post.mock_calls[0][2]["data"]["body"], + "secret": self.secret, + "user_login": self.demo_user.login, + "password": code, + "csrf_token": http.Request.csrf_token(self), }, - ) as (request, endpoint): - response = endpoint() - self.assertFalse(response.is_qweb) - self.assertTrue(response.data) - - def test_auth_sms_rate_limit(self): - # request codes until we hit the rate limit - with self._request( - "/web/login", - data={ - "login": self.demo_user.login, - "password": self.demo_user.login, - }, - ) as (request, endpoint), patch( - "odoo.addons.auth_sms.models.sms_provider.requests.post", - ) as mock_request_post: - mock_request_post.return_value.json.return_value = { - "originator": "originator", - } - for _i in range(9): - response = endpoint() - self.assertNotIn("error", response.qcontext) - response = endpoint() - self.assertTrue(response.qcontext["error"]) + ) diff --git a/auth_sms_auth_signup/README.rst b/auth_sms_auth_signup/README.rst index d1f202e477..03efa761f3 100644 --- a/auth_sms_auth_signup/README.rst +++ b/auth_sms_auth_signup/README.rst @@ -77,6 +77,14 @@ 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. +.. |maintainer-NL66278| image:: https://github.com/NL66278.png?size=40px + :target: https://github.com/NL66278 + :alt: NL66278 + +Current `maintainer `__: + +|maintainer-NL66278| + 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_sms_auth_signup/__manifest__.py b/auth_sms_auth_signup/__manifest__.py index 35f69c47d4..13f570c63e 100644 --- a/auth_sms_auth_signup/__manifest__.py +++ b/auth_sms_auth_signup/__manifest__.py @@ -1,9 +1,10 @@ -# Copyright 2019 Therp BV +# Copyright 2019-2025 Therp BV # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). { "name": "Two factor authentication via SMS - password reset", "version": "16.0.1.0.0", "author": "Therp BV,Odoo Community Association (OCA)", + "maintainers": ["NL66278"], "license": "AGPL-3", "category": "Tools", "website": "https://github.com/OCA/server-auth", diff --git a/auth_sms_auth_signup/static/description/index.html b/auth_sms_auth_signup/static/description/index.html index 8ce8802bd8..b198410a51 100644 --- a/auth_sms_auth_signup/static/description/index.html +++ b/auth_sms_auth_signup/static/description/index.html @@ -419,6 +419,8 @@

Maintainers

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.

+

Current maintainer:

+

NL66278

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_sms_auth_signup/tests/test_auth_sms_auth_signup.py b/auth_sms_auth_signup/tests/test_auth_sms_auth_signup.py index 3827130bac..42b37f8edc 100644 --- a/auth_sms_auth_signup/tests/test_auth_sms_auth_signup.py +++ b/auth_sms_auth_signup/tests/test_auth_sms_auth_signup.py @@ -1,44 +1,67 @@ -# Copyright 2019 Therp BV +# Copyright 2019-2025 Therp BV # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). -from unittest.mock import patch +from unittest import mock -from odoo.addons.auth_sms.tests.common import Common +from odoo import http +from odoo.tests import HOST, Opener, tagged +from odoo.addons.auth_sms.tests.common import HttpCaseSMS -class TestAuthSmsAuthSignup(Common): +_module_auth_sms = "odoo.addons.auth_sms" +_requests_class = _module_auth_sms + ".models.sms_provider.requests" + + +@tagged("post_install", "-at_install") +class TestAuthSmsAuthSignup(HttpCaseSMS): def test_auth_sms_auth_signup(self): + self.session = http.root.session_store.new() + self.opener = Opener(self.env.cr) + self.opener.cookies.set("session_id", self.session.sid, domain=HOST, path="/") partner = self.demo_user.partner_id partner.signup_prepare(signup_type="reset") - with self._request( - "/web/reset_password", - method="GET", - data={ - "token": partner.signup_token, - }, - ) as (request, endpoint): - response = endpoint() - self.assertTrue(response.qcontext["auth_sms_enabled"]) - with self._request( - "/web/reset_password", - data={ - "token": partner.signup_token, - "auth_sms_request_code": 1, - }, - ) as (request, endpoint), patch( - "odoo.addons.auth_sms.models.sms_provider.requests.post", - ) as mock_request_post: + # 1, Get password reset form. + url = self.base_url() + "/web/reset_password" + params = { + "token": partner.signup_token, + "csrf_token": http.Request.csrf_token(self), + } + response = self.opener.get( + url, params=params, timeout=12, headers=None, allow_redirects=True + ) + path_url = self._bare_url(response) + self.assertEqual(path_url, "/web/reset_password") + # 2. Post and request sms code. + with mock.patch(_requests_class + ".post") as mock_request_post: mock_request_post.return_value.json.return_value = { "originator": "originator", } - response = endpoint() - mock_request_post.assert_called_once() - with self._request( - "/web/reset_password", - data={ + params = { "token": partner.signup_token, - "password": "demo1", - "password2": "demo1", - "auth_sms_code": mock_request_post.mock_calls[0][2]["data"]["body"], - }, - ) as (request, endpoint): - response = endpoint() + "auth_sms_request_code": 1, + "csrf_token": http.Request.csrf_token(self), + } + response = self.url_open(url, data=params) + path_url = self._bare_url(response) + self.assertEqual(path_url, "/web/reset_password") + # retrieve the code to use from the mocked call + self.code = mock_request_post.mock_calls[0][2]["data"]["body"] + mock_request_post.assert_called_once() + # 3. Set new password, while returning token. + params = { + "token": partner.signup_token, + "password": "demo1", + "password2": "demo1", + "auth_sms_code": self.code, + "csrf_token": http.Request.csrf_token(self), + } + response = self.url_open(url, data=params) + path_url = self._bare_url(response) + self.assertEqual(path_url, "/web/reset_password") + + def _bare_url(self, response): + """Return bare url, that is withouth query parameters.""" + self.assertTrue(response.request.path_url) # We have a path + path_url = response.request.path_url + if "?" in path_url: + return path_url.split("?")[0] + return path_url