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.

456 lines
16 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. from threading import current_thread
  5. from urllib import urlencode
  6. from decorator import decorator
  7. from mock import patch
  8. from werkzeug.utils import redirect
  9. from odoo import http
  10. from odoo.tests.common import at_install, HttpCase, post_install
  11. from odoo.tools import mute_logger
  12. from ..models import res_authentication_attempt, res_users
  13. GARBAGE_LOGGERS = (
  14. "werkzeug",
  15. res_authentication_attempt.__name__,
  16. res_users.__name__,
  17. )
  18. # HACK https://github.com/odoo/odoo/pull/24833
  19. def skip_unless_addons_installed(*addons):
  20. """Decorator to skip a test unless some addons are installed.
  21. :param *str addons:
  22. Addon names that should be installed.
  23. :param reason:
  24. Explain why you must skip this test.
  25. """
  26. @decorator
  27. def _wrapper(method, self, *args, **kwargs):
  28. installed = self.addons_installed(*addons)
  29. if not installed:
  30. missing = set(addons) - installed
  31. self.skipTest("Required addons not installed: %s" %
  32. ",".join(sorted(missing)))
  33. return method(self, *args, **kwargs)
  34. return _wrapper
  35. def patch_cursor(func):
  36. """ Decorator that patches the current TestCursor for nested savepoint
  37. support """
  38. def acquire(cursor):
  39. cursor._depth += 1
  40. cursor._lock.acquire()
  41. cursor.execute("SAVEPOINT test_cursor%d" % cursor._depth)
  42. def release(cursor):
  43. cursor.execute("RELEASE SAVEPOINT test_cursor%d" % cursor._depth)
  44. cursor._depth -= 1
  45. cursor._lock.release()
  46. def close(cursor):
  47. cursor.release()
  48. def commit(cursor):
  49. cursor.execute("RELEASE SAVEPOINT test_cursor%d" % cursor._depth)
  50. cursor.execute("SAVEPOINT test_cursor%d" % cursor._depth)
  51. def rollback(cursor):
  52. cursor.execute(
  53. "ROLLBACK TO SAVEPOINT test_cursor%d" % cursor._depth)
  54. cursor.execute("SAVEPOINT test_cursor%d" % cursor._depth)
  55. def wrap(func, *args):
  56. def wrapped_function(self, *args):
  57. with self.cursor() as cursor:
  58. cursor.execute("SAVEPOINT test_cursor0")
  59. cursor._depth = 1
  60. cursor.execute("SAVEPOINT test_cursor%d" % cursor._depth)
  61. cursor.__acquire = cursor.acquire
  62. cursor.__release = cursor.release
  63. cursor.__commit = cursor.commit
  64. cursor.__rollback = cursor.rollback
  65. cursor.__close = cursor.close
  66. cursor.acquire = lambda: acquire(cursor)
  67. cursor.release = lambda: release(cursor)
  68. cursor.commit = lambda: commit(cursor)
  69. cursor.rollback = lambda: rollback(cursor)
  70. cursor.close = lambda: close(cursor)
  71. try:
  72. func(self, *args)
  73. finally:
  74. with self.cursor() as cursor:
  75. cursor.acquire = cursor.__acquire
  76. cursor.release = cursor.__release
  77. cursor.commit = cursor.__commit
  78. cursor.rollback = cursor.__rollback
  79. cursor.close = cursor.__close
  80. return wrapped_function
  81. return wrap
  82. @at_install(False)
  83. @post_install(True)
  84. # Skip CSRF validation on tests
  85. @patch(http.__name__ + ".WebRequest.validate_csrf", return_value=True)
  86. # Skip specific browser forgery on redirections
  87. @patch(http.__name__ + ".redirect_with_hash", side_effect=redirect)
  88. # Faster tests without calls to geolocation API
  89. @patch(res_authentication_attempt.__name__ + ".urlopen", return_value="")
  90. class BruteForceCase(HttpCase):
  91. def setUp(self):
  92. super(BruteForceCase, self).setUp()
  93. # Some tests could retain environ from last test and produce fake
  94. # results without this patch
  95. # HACK https://github.com/odoo/odoo/issues/24183
  96. # TODO Remove in v12
  97. try:
  98. del current_thread().environ
  99. except AttributeError:
  100. pass
  101. # Complex password to avoid conflicts with `password_security`
  102. self.good_password = "Admin$%02584"
  103. self.data_demo = {
  104. "login": "demo",
  105. "password": "Demo%&/(908409**",
  106. }
  107. with self.cursor() as cr:
  108. env = self.env(cr)
  109. env["ir.config_parameter"].set_param(
  110. "auth_brute_force.max_by_ip_user", 3)
  111. env["ir.config_parameter"].set_param(
  112. "auth_brute_force.max_by_ip", 4)
  113. # Clean attempts to be able to count in tests
  114. env["res.authentication.attempt"].search([]).unlink()
  115. # Make sure involved users have good passwords
  116. env.user.password = self.good_password
  117. env["res.users"].search([
  118. ("login", "=", self.data_demo["login"]),
  119. ]).password = self.data_demo["password"]
  120. # HACK https://github.com/odoo/odoo/pull/24833
  121. def addons_installed(self, *addons):
  122. """Know if the specified addons are installed."""
  123. found = self.env["ir.module.module"].search([
  124. ("name", "in", addons),
  125. ("state", "not in", ["uninstalled", "uninstallable"]),
  126. ])
  127. return set(addons) - set(found.mapped("name"))
  128. @skip_unless_addons_installed("web")
  129. @mute_logger(*GARBAGE_LOGGERS)
  130. @patch_cursor
  131. def test_web_login_existing(self, *args):
  132. """Remote is banned with real user on web login form."""
  133. data1 = {
  134. "login": "admin",
  135. "password": "1234", # Wrong
  136. }
  137. # Make sure user is logged out
  138. self.url_open("/web/session/logout", timeout=30)
  139. # Fail 3 times
  140. for n in range(3):
  141. response = self.url_open("/web/login", bytes(urlencode(data1)), 30)
  142. # If you fail, you get /web/login again
  143. self.assertTrue(
  144. response.geturl().endswith("/web/login"),
  145. "Unexpected URL %s" % response.geturl(),
  146. )
  147. # Admin banned, demo not
  148. with self.cursor() as cr:
  149. env = self.env(cr)
  150. self.assertFalse(
  151. env["res.authentication.attempt"]._trusted(
  152. "127.0.0.1",
  153. data1["login"],
  154. ),
  155. )
  156. self.assertTrue(
  157. env["res.authentication.attempt"]._trusted(
  158. "127.0.0.1",
  159. "demo",
  160. ),
  161. )
  162. # Now I know the password, but login is rejected too
  163. data1["password"] = self.good_password
  164. response = self.url_open("/web/login", bytes(urlencode(data1)), 30)
  165. self.assertTrue(
  166. response.geturl().endswith("/web/login"),
  167. "Unexpected URL %s" % response.geturl(),
  168. )
  169. # IP has been banned, demo user cannot login
  170. with self.cursor() as cr:
  171. env = self.env(cr)
  172. self.assertFalse(
  173. env["res.authentication.attempt"]._trusted(
  174. "127.0.0.1",
  175. "demo",
  176. ),
  177. )
  178. # Attempts recorded
  179. with self.cursor() as cr:
  180. env = self.env(cr)
  181. failed = env["res.authentication.attempt"].search([
  182. ("result", "=", "failed"),
  183. ("login", "=", data1["login"]),
  184. ("remote", "=", "127.0.0.1"),
  185. ])
  186. self.assertEqual(len(failed), 3)
  187. banned = env["res.authentication.attempt"].search([
  188. ("result", "=", "banned"),
  189. ("remote", "=", "127.0.0.1"),
  190. ])
  191. self.assertEqual(len(banned), 1)
  192. # Unban
  193. banned.action_whitelist_add()
  194. # Try good login, it should work now
  195. response = self.url_open("/web/login", bytes(urlencode(data1)), 30)
  196. self.assertTrue(response.geturl().endswith("/web"))
  197. @skip_unless_addons_installed("web")
  198. @mute_logger(*GARBAGE_LOGGERS)
  199. @patch_cursor
  200. def test_web_login_unexisting(self, *args):
  201. """Remote is banned with fake user on web login form."""
  202. data1 = {
  203. "login": "administrator", # Wrong
  204. "password": self.good_password,
  205. }
  206. # Make sure user is logged out
  207. self.url_open("/web/session/logout", timeout=30)
  208. # Fail 3 times
  209. for n in range(3):
  210. response = self.url_open("/web/login", bytes(urlencode(data1)), 30)
  211. # If you fail, you get /web/login again
  212. self.assertTrue(
  213. response.geturl().endswith("/web/login"),
  214. "Unexpected URL %s" % response.geturl(),
  215. )
  216. # Admin banned, demo not
  217. with self.cursor() as cr:
  218. env = self.env(cr)
  219. self.assertFalse(
  220. env["res.authentication.attempt"]._trusted(
  221. "127.0.0.1",
  222. data1["login"],
  223. ),
  224. )
  225. self.assertTrue(
  226. env["res.authentication.attempt"]._trusted(
  227. "127.0.0.1",
  228. self.data_demo["login"],
  229. ),
  230. )
  231. # Demo user can login
  232. response = self.url_open(
  233. "/web/login",
  234. bytes(urlencode(self.data_demo)),
  235. 30,
  236. )
  237. # If you pass, you get /web
  238. self.assertTrue(
  239. response.geturl().endswith("/web"),
  240. "Unexpected URL %s" % response.geturl(),
  241. )
  242. self.url_open("/web/session/logout", timeout=30)
  243. # Attempts recorded
  244. with self.cursor() as cr:
  245. env = self.env(cr)
  246. failed = env["res.authentication.attempt"].search([
  247. ("result", "=", "failed"),
  248. ("login", "=", data1["login"]),
  249. ("remote", "=", "127.0.0.1"),
  250. ])
  251. self.assertEqual(len(failed), 3)
  252. banned = env["res.authentication.attempt"].search([
  253. ("result", "=", "banned"),
  254. ("login", "=", data1["login"]),
  255. ("remote", "=", "127.0.0.1"),
  256. ])
  257. self.assertEqual(len(banned), 0)
  258. @mute_logger(*GARBAGE_LOGGERS)
  259. @patch_cursor
  260. def test_xmlrpc_login_existing(self, *args):
  261. """Remote is banned with real user on XML-RPC login."""
  262. data1 = {
  263. "login": "admin",
  264. "password": "1234", # Wrong
  265. }
  266. # Fail 3 times
  267. for n in range(3):
  268. self.assertFalse(self.xmlrpc_common.authenticate(
  269. self.env.cr.dbname, data1["login"], data1["password"], {}))
  270. # Admin banned, demo not
  271. with self.cursor() as cr:
  272. env = self.env(cr)
  273. self.assertFalse(
  274. env["res.authentication.attempt"]._trusted(
  275. "127.0.0.1",
  276. data1["login"],
  277. ),
  278. )
  279. self.assertTrue(
  280. env["res.authentication.attempt"]._trusted(
  281. "127.0.0.1",
  282. "demo",
  283. ),
  284. )
  285. # Now I know the password, but login is rejected too
  286. data1["password"] = self.good_password
  287. self.assertFalse(self.xmlrpc_common.authenticate(
  288. self.env.cr.dbname, data1["login"], data1["password"], {}))
  289. # IP has been banned, demo user cannot login
  290. with self.cursor() as cr:
  291. env = self.env(cr)
  292. self.assertFalse(
  293. env["res.authentication.attempt"]._trusted(
  294. "127.0.0.1",
  295. "demo",
  296. ),
  297. )
  298. # Attempts recorded
  299. with self.cursor() as cr:
  300. env = self.env(cr)
  301. failed = env["res.authentication.attempt"].search([
  302. ("result", "=", "failed"),
  303. ("login", "=", data1["login"]),
  304. ("remote", "=", "127.0.0.1"),
  305. ])
  306. self.assertEqual(len(failed), 3)
  307. banned = env["res.authentication.attempt"].search([
  308. ("result", "=", "banned"),
  309. ("remote", "=", "127.0.0.1"),
  310. ])
  311. self.assertEqual(len(banned), 1)
  312. # Unban
  313. banned.action_whitelist_add()
  314. # Try good login, it should work now
  315. self.assertTrue(self.xmlrpc_common.authenticate(
  316. self.env.cr.dbname, data1["login"], data1["password"], {}))
  317. @mute_logger(*GARBAGE_LOGGERS)
  318. @patch_cursor
  319. def test_xmlrpc_login_unexisting(self, *args):
  320. """Remote is banned with fake user on XML-RPC login."""
  321. data1 = {
  322. "login": "administrator", # Wrong
  323. "password": self.good_password,
  324. }
  325. # Fail 3 times
  326. for n in range(3):
  327. self.assertFalse(self.xmlrpc_common.authenticate(
  328. self.env.cr.dbname, data1["login"], data1["password"], {}))
  329. # Admin banned, demo not
  330. with self.cursor() as cr:
  331. env = self.env(cr)
  332. self.assertFalse(
  333. env["res.authentication.attempt"]._trusted(
  334. "127.0.0.1",
  335. data1["login"],
  336. ),
  337. )
  338. self.assertTrue(
  339. env["res.authentication.attempt"]._trusted(
  340. "127.0.0.1",
  341. self.data_demo["login"],
  342. ),
  343. )
  344. # Demo user can login
  345. self.assertTrue(self.xmlrpc_common.authenticate(
  346. self.env.cr.dbname,
  347. self.data_demo["login"],
  348. self.data_demo["password"],
  349. {},
  350. ))
  351. # Attempts recorded
  352. with self.cursor() as cr:
  353. env = self.env(cr)
  354. failed = env["res.authentication.attempt"].search([
  355. ("result", "=", "failed"),
  356. ("login", "=", data1["login"]),
  357. ("remote", "=", "127.0.0.1"),
  358. ])
  359. self.assertEqual(len(failed), 3)
  360. banned = env["res.authentication.attempt"].search([
  361. ("result", "=", "banned"),
  362. ("login", "=", data1["login"]),
  363. ("remote", "=", "127.0.0.1"),
  364. ])
  365. self.assertEqual(len(banned), 0)
  366. @mute_logger(*GARBAGE_LOGGERS)
  367. def test_orm_login_existing(self, *args):
  368. """No bans on ORM login with an existing user."""
  369. data1 = {
  370. "login": "admin",
  371. "password": "1234", # Wrong
  372. }
  373. with self.cursor() as cr:
  374. env = self.env(cr)
  375. # Fail 3 times
  376. for n in range(3):
  377. self.assertFalse(
  378. env["res.users"].authenticate(
  379. cr.dbname, data1["login"], data1["password"], {}))
  380. self.assertEqual(
  381. env["res.authentication.attempt"].search(count=True, args=[]),
  382. 0,
  383. )
  384. self.assertTrue(
  385. env["res.authentication.attempt"]._trusted(
  386. "127.0.0.1",
  387. data1["login"],
  388. ),
  389. )
  390. # Now I know the password, and login works
  391. data1["password"] = self.good_password
  392. self.assertTrue(
  393. env["res.users"].authenticate(
  394. cr.dbname, data1["login"], data1["password"], {}))
  395. @mute_logger(*GARBAGE_LOGGERS)
  396. def test_orm_login_unexisting(self, *args):
  397. """No bans on ORM login with an unexisting user."""
  398. data1 = {
  399. "login": "administrator", # Wrong
  400. "password": self.good_password,
  401. }
  402. with self.cursor() as cr:
  403. env = self.env(cr)
  404. # Fail 3 times
  405. for n in range(3):
  406. self.assertFalse(
  407. env["res.users"].authenticate(
  408. cr.dbname, data1["login"], data1["password"], {}))
  409. self.assertEqual(
  410. env["res.authentication.attempt"].search(count=True, args=[]),
  411. 0,
  412. )
  413. self.assertTrue(
  414. env["res.authentication.attempt"]._trusted(
  415. "127.0.0.1",
  416. data1["login"],
  417. ),
  418. )
  419. # Now I know the user, and login works
  420. data1["login"] = "admin"
  421. self.assertTrue(
  422. env["res.users"].authenticate(
  423. cr.dbname, data1["login"], data1["password"], {}))