# -*- 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', '') 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'] = '' 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 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