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>
168 lines
6.9 KiB
Python
168 lines
6.9 KiB
Python
# -*- coding: utf-8 -*-
|
|
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
|
|
# (see CLAUDE.md Workflow) — Odoo crashes at registry load with a clear
|
|
# `ModuleNotFoundError`, not deep in the auth path after the first login.
|
|
from user_agents import parse as ua_parse
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
class ResUsers(models.Model):
|
|
_inherit = 'res.users'
|
|
|
|
@api.model
|
|
def _fc_build_event_vals(
|
|
self,
|
|
result,
|
|
attempted_login,
|
|
failure_reason=None,
|
|
user_id=None,
|
|
_override_ip=None,
|
|
_override_ua=None,
|
|
_credential=None,
|
|
):
|
|
"""Build the dict of values for a fusion.login.audit row.
|
|
|
|
Pulls IP / User-Agent from the live HTTP request when available.
|
|
Falls back to ('internal', '<no-request>') for XML-RPC / cron-initiated
|
|
auth, with geo_lookup_state='internal' so the geo cron skips them.
|
|
|
|
An empty IP from an otherwise-live request (rare; misconfigured
|
|
reverse proxy) also routes to the 'internal' fallback — an empty
|
|
string isn't useful audit data and is arguably suspicious.
|
|
|
|
The _override_* kwargs exist for tests so we don't have to fake a
|
|
full request. They are NOT a public API.
|
|
|
|
Password safety: `_credential` MAY contain a 'password' key from the
|
|
Odoo auth flow. We never read that key, never log it, never put it
|
|
in vals. The test `test_build_event_vals_strips_password` locks
|
|
this property in via `assertNotIn(secret, repr(vals))`.
|
|
"""
|
|
vals = {
|
|
'attempted_login': (attempted_login or '')[:255],
|
|
'result': result,
|
|
'failure_reason': failure_reason,
|
|
'event_time': fields.Datetime.now(),
|
|
'database': self.env.cr.dbname,
|
|
'user_id': user_id,
|
|
}
|
|
|
|
ip = _override_ip
|
|
ua_str = _override_ua
|
|
|
|
if ip is None or ua_str is None:
|
|
try:
|
|
from odoo.http import request
|
|
if request and getattr(request, 'httprequest', None):
|
|
if ip is None:
|
|
ip = request.httprequest.remote_addr
|
|
if ua_str is None:
|
|
ua_str = request.httprequest.user_agent.string or ''
|
|
except Exception:
|
|
_logger.debug("fusion_login_audit: no request context", exc_info=True)
|
|
|
|
if ip and ua_str is not None:
|
|
ua_text = ua_str or ''
|
|
vals['ip_address'] = ip[:45]
|
|
vals['user_agent_raw'] = ua_text[:512]
|
|
ua = ua_parse(ua_text)
|
|
vals['browser'] = (f"{ua.browser.family} {ua.browser.version_string}".strip())[:64]
|
|
vals['os'] = (f"{ua.os.family} {ua.os.version_string}".strip())[:64]
|
|
if ua.is_bot:
|
|
vals['device_type'] = 'bot'
|
|
elif ua.is_mobile:
|
|
vals['device_type'] = 'mobile'
|
|
elif ua.is_tablet:
|
|
vals['device_type'] = 'tablet'
|
|
elif ua.is_pc:
|
|
vals['device_type'] = 'desktop'
|
|
else:
|
|
vals['device_type'] = 'unknown'
|
|
vals['geo_lookup_state'] = 'pending'
|
|
else:
|
|
vals['ip_address'] = 'internal'
|
|
vals['user_agent_raw'] = '<no-request>'
|
|
vals['device_type'] = 'unknown'
|
|
vals['geo_lookup_state'] = 'internal'
|
|
|
|
# _credential is accepted in the signature so callers (T6 _check_credentials,
|
|
# T7 _login) can hand the dict in without filtering. The helper deliberately
|
|
# touches NO keys from it — see the password-safety note in the docstring.
|
|
# `_credential` is intentionally unread here; the parameter exists so future
|
|
# work can read `credential.get('type')` for `2fa_failed` discrimination
|
|
# only via the explicit failure_reason kwarg, never from the dict directly.
|
|
del _credential # explicit no-op — locks down the read surface
|
|
|
|
return vals
|
|
|
|
def _fc_record_login_event(self, result, failure_reason=None,
|
|
user_id=None, attempted_login=None,
|
|
_credential=None):
|
|
"""Build vals + create the audit row via sudo. Never raises.
|
|
|
|
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(
|
|
result=result,
|
|
attempted_login=attempted_login
|
|
or (self.login if self else None)
|
|
or 'unknown',
|
|
failure_reason=failure_reason,
|
|
user_id=user_id or (self.id if self else None),
|
|
_credential=_credential,
|
|
)
|
|
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",
|
|
result, attempted_login or (self.login if self else 'unknown'),
|
|
)
|
|
|
|
def _update_last_login(self):
|
|
result = super()._update_last_login()
|
|
# 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
|