Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion pms_api_rest/services/pms_login_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,13 @@ def login(self, user):
if not user_record:
raise werkzeug.exceptions.Unauthorized(_("wrong user/pass"))
try:
user_record.with_user(user_record)._check_credentials(user.password, None)
# This login cannot ask for a second factor, so it does not promise
# to: an account protected by one is refused here and has to use
# the login that can. An API key stays valid, which is the
# credential meant for callers with nobody in front of them.
user_record.with_user(user_record)._check_credentials(
user.password, {"interactive": False}
)
except AccessDenied as e:
raise werkzeug.exceptions.Unauthorized(_("wrong user/pass")) from e

Expand Down
1 change: 1 addition & 0 deletions pms_api_rest/tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@
from . import test_various_partner_protection
from . import test_current_user_service
from . import test_error_cors
from . import test_login_second_factor
59 changes: 59 additions & 0 deletions pms_api_rest/tests/test_login_second_factor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Copyright 2026 Commit [Sun]
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
"""This login cannot ask anyone for a verification code, so it must not let an
account that is protected by one through on its password alone. Those accounts
belong to the login that can ask, and everyone else keeps logging in here.
"""
import base64
import os
from unittest.mock import patch

import werkzeug.exceptions

from odoo.tests import tagged

from odoo.addons.base_rest.controllers.main import _PseudoCollection
from odoo.addons.component.core import WorkContext
from odoo.addons.pms.tests.common import TestPms

IMAGE_URL_BUILDER = (
"odoo.addons.pms_api_rest.services.pms_login_service.url_image_pms_api_rest"
)


@tagged("post_install", "-at_install")
class TestLoginSecondFactor(TestPms):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.api_user = cls.env["res.users"].create(
{
"name": "Api Caller",
"login": "api.caller@example.org",
"password": "supersecret",
"company_id": cls.company1.id,
"company_ids": [(6, 0, [cls.company1.id])],
"pms_property_ids": [(6, 0, [cls.pms_property1.id])],
"pms_property_id": cls.pms_property1.id,
}
)

def _login(self):
collection = _PseudoCollection("pms.services", self.env)
work = WorkContext(
model_name="rest.service.registration", collection=collection
)
credentials = self.env.datamodels["pms.api.rest.user.input"](
username="api.caller@example.org", password="supersecret"
)
with patch(IMAGE_URL_BUILDER, return_value=""):
return work.component(usage="login").login(credentials)

def test_an_account_without_a_second_factor_still_logs_in(self):
self.assertTrue(self._login().token)

def test_an_account_with_a_second_factor_is_refused(self):
self.api_user.sudo().totp_secret = base64.b32encode(os.urandom(20)).decode()

with self.assertRaises(werkzeug.exceptions.Unauthorized):
self._login()
1 change: 1 addition & 0 deletions pms_fastapi/__manifest__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"fastapi_auth_jwt",
"extendable_fastapi",
"auth_jwt_login",
"auth_totp",
"partner_firstname",
"phone_validation",
"account_payment_partner",
Expand Down
1 change: 1 addition & 0 deletions pms_fastapi/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@
from . import account_payment_method_line
from . import pms_payment_refund_line
from . import account_payment
from . import pms_mfa_challenge
30 changes: 30 additions & 0 deletions pms_fastapi/models/pms_mfa_challenge.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
from odoo import api, fields, models


class PmsFastapiMfaChallenge(models.Model):
"""A login that passed the password and still owes its second factor.

It is the API's equivalent of the pre-authenticated session the web login
keeps: on its own it grants nothing, it only says which user is allowed to
hand in a code. Attempts are counted here so that a wrong code costs the
caller its challenge instead of its account.
"""

_name = "pms.fastapi.mfa.challenge"
_description = "Pending second factor"

user_id = fields.Many2one(
"res.users", required=True, index=True, ondelete="cascade"
)
token = fields.Char(required=True, index=True)
expire = fields.Datetime(required=True)
attempts = fields.Integer(default=0)

_sql_constraints = [
("unique_token", "unique(token)", "The token must be unique!"),
]

@api.autovacuum
def _remove_expired_challenges(self):
records = self.search([("expire", "<", fields.Datetime.now())])
return records.unlink()
Loading
Loading