feat(fusion_login_audit): hook bad-password failures via _check_credentials

Wraps res.users._check_credentials. On AccessDenied, records a row with
result=failure and failure_reason='bad_password' (or '2fa_failed' when
credential['type'] == 'totp'), then re-raises. Regression test asserts
the attempted password value never lands in any audit field.

The audit row is written through registry.cursor() (independent cursor) so
it survives the rollback that follows AccessDenied — in production
odoo/service/model.py::retrying resets the transaction and http.py closes
the cursor without committing, in tests assertRaises opens its own
savepoint. Either way an inline write would vanish. Tests
enter registry_test_mode and use manual try/except to keep the audit row
visible across the savepoint hierarchy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-05-26 21:02:51 -04:00
parent dced0c66a4
commit 0e6ebe7bc6
2 changed files with 112 additions and 6 deletions

View File

@@ -2,6 +2,7 @@
import logging
from odoo import api, fields, models
from odoo.exceptions import AccessDenied
# Top-level import (vs lazy inside the method): if the dep is missing — most
# likely because the dev container got recreated and dropped its pip install
@@ -106,9 +107,22 @@ class ResUsers(models.Model):
_credential=None):
"""Build vals + create the audit row via sudo. Never raises.
Audit writes are wrapped so that a broken audit table can never
block a real user from logging in. The exception is logged and
swallowed; auth proceeds normally.
The row is written through an INDEPENDENT cursor
(``registry.cursor()``) so that:
* A failure-path call from ``_check_credentials`` survives the
outer transaction rollback that follows ``AccessDenied``
(the HTTP layer closes the cursor without committing, see
``odoo/service/model.py:retrying``).
* A broken audit table can never block a real user from logging
in: the cursor block is wrapped in try/except; exceptions are
logged and swallowed.
The independent cursor commits on context exit. Note that this
means the row is durable even if the caller's transaction later
rolls back — intentional for audit semantics: a recorded bad
password should NOT disappear because some unrelated downstream
op blew up.
"""
try:
vals = self._fc_build_event_vals(
@@ -120,9 +134,11 @@ class ResUsers(models.Model):
user_id=user_id or (self.id if self else None),
_credential=_credential,
)
self.env['fusion.login.audit'].sudo().with_context(
mail_create_nolog=True
).create(vals)
with self.env.registry.cursor() as audit_cr:
audit_env = api.Environment(audit_cr, self.env.uid, self.env.context)
audit_env['fusion.login.audit'].sudo().with_context(
mail_create_nolog=True
).create(vals)
except Exception:
_logger.exception(
"fusion_login_audit: failed to record %s row for %s",
@@ -134,3 +150,18 @@ class ResUsers(models.Model):
# Self is the singleton recordset of the user that just logged in.
self._fc_record_login_event(result='success')
return result
def _check_credentials(self, credential, env):
try:
return super()._check_credentials(credential, env)
except AccessDenied:
cred_type = (credential or {}).get('type', 'password')
reason = '2fa_failed' if cred_type == 'totp' else 'bad_password'
self._fc_record_login_event(
result='failure',
failure_reason=reason,
user_id=self.id,
attempted_login=(credential or {}).get('login') or self.login,
_credential=credential,
)
raise