You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

150 lines
5.3 KiB

  1. # -*- coding: utf-8 -*-
  2. # Copyright 2017 Tecnativa - Jairo Llopis
  3. # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
  4. import logging
  5. from contextlib import contextmanager
  6. from threading import current_thread
  7. from odoo import api, models, SUPERUSER_ID
  8. from odoo.exceptions import AccessDenied
  9. from odoo.service import wsgi_server
  10. _logger = logging.getLogger(__name__)
  11. class ResUsers(models.Model):
  12. _inherit = "res.users"
  13. # HACK https://github.com/odoo/odoo/issues/24183
  14. # TODO Remove in v12, and use normal odoo.http.request to get details
  15. @api.model_cr
  16. def _register_hook(self):
  17. """🐒-patch XML-RPC controller to know remote address."""
  18. original_fn = wsgi_server.application_unproxied
  19. def _patch(environ, start_response):
  20. current_thread().environ = environ
  21. return original_fn(environ, start_response)
  22. wsgi_server.application_unproxied = _patch
  23. # Helpers to track authentication attempts
  24. @classmethod
  25. @contextmanager
  26. def _auth_attempt(cls, login):
  27. """Start an authentication attempt and track its state."""
  28. try:
  29. # Check if this call is nested
  30. attempt_id = current_thread().auth_attempt_id
  31. except AttributeError:
  32. # Not nested; create a new attempt
  33. attempt_id = cls._auth_attempt_new(login)
  34. if not attempt_id:
  35. # No attempt was created, so there's nothing to do here
  36. yield
  37. return
  38. try:
  39. current_thread().auth_attempt_id = attempt_id
  40. result = "successful"
  41. try:
  42. yield
  43. except AccessDenied as error:
  44. result = getattr(error, "reason", "failed")
  45. raise
  46. finally:
  47. cls._auth_attempt_update({"result": result})
  48. finally:
  49. try:
  50. del current_thread().auth_attempt_id
  51. except AttributeError:
  52. pass # It was deleted already
  53. @classmethod
  54. def _auth_attempt_force_raise(cls, login, method):
  55. """Force a method to raise an AccessDenied on falsey return."""
  56. try:
  57. with cls._auth_attempt(login):
  58. result = method()
  59. if not result:
  60. # Force exception to record auth failure
  61. raise AccessDenied()
  62. except AccessDenied:
  63. pass # `_auth_attempt()` did the hard part already
  64. return result
  65. @classmethod
  66. def _auth_attempt_new(cls, login):
  67. """Store one authentication attempt, not knowing the result."""
  68. # Get the right remote address
  69. try:
  70. remote_addr = current_thread().environ["REMOTE_ADDR"]
  71. except (KeyError, AttributeError):
  72. remote_addr = False
  73. # Exit if it doesn't make sense to store this attempt
  74. if not remote_addr:
  75. return False
  76. # Use a separate cursor to keep changes always
  77. with cls.pool.cursor() as cr:
  78. env = api.Environment(cr, SUPERUSER_ID, {})
  79. attempt = env["res.authentication.attempt"].create({
  80. "login": login,
  81. "remote": remote_addr,
  82. })
  83. return attempt.id
  84. @classmethod
  85. def _auth_attempt_update(cls, values):
  86. """Update a given auth attempt if we still ignore its result."""
  87. auth_id = getattr(current_thread(), "auth_attempt_id", False)
  88. if not auth_id:
  89. return {} # No running auth attempt; nothing to do
  90. # Use a separate cursor to keep changes always
  91. with cls.pool.cursor() as cr:
  92. env = api.Environment(cr, SUPERUSER_ID, {})
  93. attempt = env["res.authentication.attempt"].browse(auth_id)
  94. # Update only on 1st call
  95. if not attempt.result:
  96. attempt.write(values)
  97. return attempt.copy_data()[0] if attempt else {}
  98. # Override all auth-related core methods
  99. @classmethod
  100. def _login(cls, db, login, password):
  101. return cls._auth_attempt_force_raise(
  102. login,
  103. lambda: super(ResUsers, cls)._login(db, login, password),
  104. )
  105. @classmethod
  106. def authenticate(cls, db, login, password, user_agent_env):
  107. return cls._auth_attempt_force_raise(
  108. login,
  109. lambda: super(ResUsers, cls).authenticate(
  110. db, login, password, user_agent_env),
  111. )
  112. @api.model
  113. def check_credentials(self, password):
  114. """This is the most important and specific auth check method.
  115. When we get here, it means that Odoo already checked the user exists
  116. in this database.
  117. Other auth methods usually plug here.
  118. """
  119. login = self.env.user.login
  120. with self._auth_attempt(login):
  121. # Update login, just in case we stored the UID before
  122. attempt = self._auth_attempt_update({"login": login})
  123. remote = attempt.get("remote")
  124. # Fail if the remote is banned
  125. trusted = self.env["res.authentication.attempt"]._trusted(
  126. remote,
  127. login,
  128. )
  129. if not trusted:
  130. error = AccessDenied()
  131. error.reason = "banned"
  132. raise error
  133. # Continue with other auth systems
  134. return super(ResUsers, self).check_credentials(password)