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.

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