diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8fba131..5b5e272 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -9,13 +9,16 @@ jobs: fail-fast: false max-parallel: 5 matrix: - python-version: ['3.7', '3.8', '3.9', '3.10', 'pypy-3.8'] + python-version: ['3.7', '3.8', '3.9', '3.10', '3.11', 'pypy-3.8'] + redis-version: [5, 6, 7] steps: - uses: actions/checkout@v2 - name: Start Redis - uses: supercharge/redis-github-action@1.1.0 + uses: supercharge/redis-github-action@1.5.0 + with: + redis-version: ${{ matrix.redis-version }} - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v2 diff --git a/README.rst b/README.rst index 64bb320..81e5919 100644 --- a/README.rst +++ b/README.rst @@ -110,7 +110,7 @@ Requirements * Python: 3.7, 3.8, 3.9, 3.10, PyPy * Django: 3.x, 4.x -* Redis +* Redis: 5.x, 6.x, 7.x Installation diff --git a/defender/connection.py b/defender/connection.py index 2afdcf1..7a2b2f1 100644 --- a/defender/connection.py +++ b/defender/connection.py @@ -34,6 +34,7 @@ def get_redis_connection(): else: # pragma: no cover redis_config = parse_redis_url( config.DEFENDER_REDIS_URL, config.DEFENDER_REDIS_PASSWORD_QUOTE) + return redis.StrictRedis( host=redis_config.get("HOST"), port=redis_config.get("PORT"), @@ -50,7 +51,6 @@ def parse_redis_url(url, password_quote=None): # create config with some sane defaults redis_config = { "DB": 0, - "USERNAME": "default", "PASSWORD": None, "HOST": "localhost", "PORT": 6379, @@ -60,25 +60,26 @@ def parse_redis_url(url, password_quote=None): if not url: return redis_config - url = urlparse.urlparse(url) + purl = urlparse.urlparse(url) + # Remove query strings. - path = url.path[1:] + path = purl.path[1:] path = path.split("?", 2)[0] if path: redis_config.update({"DB": int(path)}) - if url.password: - password = url.password + if purl.password: + password = purl.password if password_quote: password = urlparse.unquote(password) redis_config.update({"PASSWORD": password}) - if url.hostname: - redis_config.update({"HOST": url.hostname}) - if url.username: - redis_config.update({"USERNAME": url.username}) - if url.port: - redis_config.update({"PORT": int(url.port)}) - if url.scheme in ["https", "rediss"]: + if purl.hostname: + redis_config.update({"HOST": purl.hostname}) + if purl.username: + redis_config.update({"USERNAME": purl.username}) + if purl.port: + redis_config.update({"PORT": int(purl.port)}) + if purl.scheme in ["https", "rediss"]: redis_config.update({"SSL": True}) return redis_config diff --git a/defender/tests.py b/defender/tests.py index 17e9004..8791875 100644 --- a/defender/tests.py +++ b/defender/tests.py @@ -10,9 +10,12 @@ from django.contrib.sessions.backends.db import SessionStore from django.db.models import Q from django.http import HttpRequest, HttpResponse from django.test.client import RequestFactory +from django.test.testcases import TestCase from redis.client import Redis from django.urls import reverse +import redis + from defender.data import get_approx_account_lockouts_from_login_attempts from . import utils @@ -483,6 +486,7 @@ class AccessAttemptTest(DefenderTestCase): self.assertEqual(conf.get("DB"), 2) self.assertEqual(conf.get("PASSWORD"), "password") self.assertEqual(conf.get("PORT"), 1234) + self.assertEqual(conf.get("USERNAME"), "user") # full non local conf = parse_redis_url( @@ -491,6 +495,7 @@ class AccessAttemptTest(DefenderTestCase): self.assertEqual(conf.get("DB"), 2) self.assertEqual(conf.get("PASSWORD"), "pass") self.assertEqual(conf.get("PORT"), 1234) + self.assertEqual(conf.get("USERNAME"), "user") # no user name conf = parse_redis_url("redis://password@localhost2:1234/2", False) @@ -990,7 +995,7 @@ class AccessAttemptTest(DefenderTestCase): def test_approx_account_lockout_count_default_case_invalid_args_pt1(self): with self.assertRaises(Exception): get_approx_account_lockouts_from_login_attempts(ip_address="127.0.0.1") - + @patch("defender.config.DISABLE_USERNAME_LOCKOUT", True) def test_approx_account_lockout_count_default_case_invalid_args_pt2(self): with self.assertRaises(Exception): @@ -1179,3 +1184,68 @@ class TestUtils(DefenderTestCase): self.assertEqual(utils.remove_prefix( "defender:blocked:username:johndoe", "blocked:username:"), "defender:blocked:username:johndoe") + + +class TestRedisConnection(TestCase): + """ Test the redis connection parsing """ + REDIS_URL_PLAIN = "redis://localhost:6379/0" + REDIS_URL_PASS = "redis://:mypass@localhost:6379/0" + REDIS_URL_NAME_PASS = "redis://myname:mypass2@localhost:6379/0" + + @patch("defender.config.DEFENDER_REDIS_URL", REDIS_URL_PLAIN) + @patch("defender.config.MOCK_REDIS", False) + def test_get_redis_connection(self): + """ get redis connection plain """ + redis_client = get_redis_connection() + self.assertIsInstance(redis_client, Redis) + redis_client.set('test', 0) + result = int(redis_client.get('test')) + self.assertEqual(result, 0) + redis_client.delete('test') + + @patch("defender.config.DEFENDER_REDIS_URL", REDIS_URL_PASS) + @patch("defender.config.MOCK_REDIS", False) + def test_get_redis_connection_with_password(self): + """ get redis connection with password """ + + connection = redis.Redis() + connection.config_set('requirepass', 'mypass') + + redis_client = get_redis_connection() + self.assertIsInstance(redis_client, Redis) + redis_client.set('test2', 0) + result = int(redis_client.get('test2')) + self.assertEqual(result, 0) + redis_client.delete('test2') + # clean up + redis_client.config_set('requirepass', '') + + @patch("defender.config.DEFENDER_REDIS_URL", REDIS_URL_NAME_PASS) + @patch("defender.config.MOCK_REDIS", False) + def test_get_redis_connection_with_acl(self): + """ get redis connection with password and name ACL """ + connection = redis.Redis() + + if connection.info().get('redis_version') < '6': + # redis versions before 6 don't have acl, so skip. + return + + connection.acl_setuser( + 'myname', + enabled=True, + passwords=["+" + "mypass2", ], + keys="*", + commands=["+@all", ]) + + try: + redis_client = get_redis_connection() + self.assertIsInstance(redis_client, Redis) + redis_client.set('test3', 0) + result = int(redis_client.get('test3')) + self.assertEqual(result, 0) + redis_client.delete('test3') + except Exception as e: + raise e + + # clean up + connection.acl_deluser('myname')