feat(fusion_login_audit): hook unknown-user failures via _login

Overrides res.users._login. When the login string does not resolve to
any user, super() raises AccessDenied; we record a row with user_id=NULL
and failure_reason="unknown_user", then re-raise. Closes the gap where
typo'd or scanned logins would otherwise vanish from the audit trail.

The existing _fc_record_login_event helper writes through an independent
registry.cursor(), so the audit row survives the rollback that follows
the re-raised AccessDenied.

Note: in Odoo 19 _login is a plain instance method (not the classmethod
it was in earlier versions) and takes (credential, user_agent_env). The
original plan was written for the classmethod signature; corrected here
and recorded in CLAUDE.md rule #10 so future-Claude does not waste time
re-discovering it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-05-26 21:11:57 -04:00
parent 0e6ebe7bc6
commit a7cf44249d
3 changed files with 59 additions and 0 deletions

View File

@@ -165,3 +165,31 @@ class ResUsers(models.Model):
_credential=credential,
)
raise
def _login(self, credential, user_agent_env):
"""Catch the unknown-user branch of upstream _login.
In Odoo 19 ``_login`` is an *instance* method (not a classmethod as in
earlier versions). Upstream raises ``AccessDenied`` when the login
string does not resolve to any user — at that point no user record
exists, so the ``bad_password`` path in ``_check_credentials`` never
fires. We catch the propagating exception here and write a row with
``user_id=NULL`` and ``failure_reason='unknown_user'``.
``_fc_record_login_event`` already writes through an INDEPENDENT
cursor (``self.env.registry.cursor()``), so the audit row survives
the outer transaction rollback that follows the re-raised
``AccessDenied``. We never block the re-raise: any audit-side
exception is caught + logged inside the helper.
"""
try:
return super()._login(credential, user_agent_env)
except AccessDenied:
self._fc_record_login_event(
result='failure',
failure_reason='unknown_user',
user_id=False,
attempted_login=(credential or {}).get('login') or 'unknown',
_credential=credential,
)
raise

View File

@@ -197,3 +197,33 @@ class TestFusionLoginAuditModel(TransactionCase):
'database'):
self.assertNotIn(secret, (row[fname] or ''),
f"Password leaked into field {fname}")
def test_unknown_user_writes_failure_row(self):
"""A login attempt for a username that does not exist gets logged
with user_id=NULL and failure_reason='unknown_user'."""
from odoo.exceptions import AccessDenied
bogus = 'this-user-does-not-exist@example.com'
Audit = self.env['fusion.login.audit'].sudo()
before = Audit.search_count([('attempted_login', '=', bogus)])
# NB: manual try/except instead of assertRaises — see comment in
# test_bad_password_writes_failure_row. _login is an instance method
# in Odoo 19 (not a classmethod as in earlier versions); we call it
# on the empty recordset of res.users, which matches what
# authenticate() does internally.
raised = False
try:
self.env['res.users']._login(
{'login': bogus, 'password': 'whatever',
'type': 'password'},
{'interactive': False},
)
except AccessDenied:
raised = True
self.assertTrue(raised, "AccessDenied must propagate after the audit write")
after = Audit.search_count([('attempted_login', '=', bogus)])
self.assertEqual(after, before + 1)
row = Audit.search([('attempted_login', '=', bogus)],
order='event_time desc', limit=1)
self.assertFalse(row.user_id)
self.assertEqual(row.failure_reason, 'unknown_user')
self.assertEqual(row.result, 'failure')