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.

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