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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user