django-solomon 0.2.0__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. {django_solomon-0.2.0 → django_solomon-0.3.0}/CHANGELOG.md +14 -0
  2. {django_solomon-0.2.0 → django_solomon-0.3.0}/PKG-INFO +14 -5
  3. {django_solomon-0.2.0 → django_solomon-0.3.0}/README.md +13 -4
  4. {django_solomon-0.2.0 → django_solomon-0.3.0}/docs/settings.md +28 -0
  5. {django_solomon-0.2.0 → django_solomon-0.3.0}/src/django_solomon/backends.py +16 -0
  6. django_solomon-0.3.0/src/django_solomon/migrations/0002_magiclink_ip_address.py +18 -0
  7. {django_solomon-0.2.0 → django_solomon-0.3.0}/src/django_solomon/models.py +3 -2
  8. django_solomon-0.3.0/src/django_solomon/utilities.py +150 -0
  9. {django_solomon-0.2.0 → django_solomon-0.3.0}/src/django_solomon/views.py +7 -2
  10. {django_solomon-0.2.0 → django_solomon-0.3.0}/tests/test_backends.py +58 -1
  11. {django_solomon-0.2.0 → django_solomon-0.3.0}/tests/test_utilities.py +205 -1
  12. django_solomon-0.2.0/src/django_solomon/utilities.py +0 -81
  13. {django_solomon-0.2.0 → django_solomon-0.3.0}/.forgejo/workflows/release.yml +0 -0
  14. {django_solomon-0.2.0 → django_solomon-0.3.0}/.forgejo/workflows/tests.yml +0 -0
  15. {django_solomon-0.2.0 → django_solomon-0.3.0}/.gitignore +0 -0
  16. {django_solomon-0.2.0 → django_solomon-0.3.0}/.pre-commit-config.yaml +0 -0
  17. {django_solomon-0.2.0 → django_solomon-0.3.0}/.readthedocs.yml +0 -0
  18. {django_solomon-0.2.0 → django_solomon-0.3.0}/LICENSE +0 -0
  19. {django_solomon-0.2.0 → django_solomon-0.3.0}/docs/changelog.md +0 -0
  20. {django_solomon-0.2.0 → django_solomon-0.3.0}/docs/contributing.md +0 -0
  21. {django_solomon-0.2.0 → django_solomon-0.3.0}/docs/index.md +0 -0
  22. {django_solomon-0.2.0 → django_solomon-0.3.0}/docs/installation.md +0 -0
  23. {django_solomon-0.2.0 → django_solomon-0.3.0}/docs/requirements.txt +0 -0
  24. {django_solomon-0.2.0 → django_solomon-0.3.0}/docs/templates.md +0 -0
  25. {django_solomon-0.2.0 → django_solomon-0.3.0}/justfile +0 -0
  26. {django_solomon-0.2.0 → django_solomon-0.3.0}/mkdocs.yml +0 -0
  27. {django_solomon-0.2.0 → django_solomon-0.3.0}/pyproject.toml +0 -0
  28. {django_solomon-0.2.0 → django_solomon-0.3.0}/src/django_solomon/__init__.py +0 -0
  29. {django_solomon-0.2.0 → django_solomon-0.3.0}/src/django_solomon/admin.py +0 -0
  30. {django_solomon-0.2.0 → django_solomon-0.3.0}/src/django_solomon/apps.py +0 -0
  31. {django_solomon-0.2.0 → django_solomon-0.3.0}/src/django_solomon/config.py +0 -0
  32. {django_solomon-0.2.0 → django_solomon-0.3.0}/src/django_solomon/forms.py +0 -0
  33. {django_solomon-0.2.0 → django_solomon-0.3.0}/src/django_solomon/locale/de/LC_MESSAGES/django.po +0 -0
  34. {django_solomon-0.2.0 → django_solomon-0.3.0}/src/django_solomon/locale/en/LC_MESSAGES/django.po +0 -0
  35. {django_solomon-0.2.0 → django_solomon-0.3.0}/src/django_solomon/management/__init__.py +0 -0
  36. {django_solomon-0.2.0 → django_solomon-0.3.0}/src/django_solomon/management/commands/__init__.py +0 -0
  37. {django_solomon-0.2.0 → django_solomon-0.3.0}/src/django_solomon/migrations/0001_initial.py +0 -0
  38. {django_solomon-0.2.0 → django_solomon-0.3.0}/src/django_solomon/migrations/__init__.py +0 -0
  39. {django_solomon-0.2.0 → django_solomon-0.3.0}/src/django_solomon/py.typed +0 -0
  40. {django_solomon-0.2.0 → django_solomon-0.3.0}/src/django_solomon/templates/django_solomon/base/invalid_magic_link.html +0 -0
  41. {django_solomon-0.2.0 → django_solomon-0.3.0}/src/django_solomon/templates/django_solomon/base/login_form.html +0 -0
  42. {django_solomon-0.2.0 → django_solomon-0.3.0}/src/django_solomon/templates/django_solomon/base/magic_link_sent.html +0 -0
  43. {django_solomon-0.2.0 → django_solomon-0.3.0}/src/django_solomon/templates/django_solomon/email/magic_link.mjml +0 -0
  44. {django_solomon-0.2.0 → django_solomon-0.3.0}/src/django_solomon/templates/django_solomon/email/magic_link.txt +0 -0
  45. {django_solomon-0.2.0 → django_solomon-0.3.0}/src/django_solomon/urls.py +0 -0
  46. {django_solomon-0.2.0 → django_solomon-0.3.0}/tests/.gitignore +0 -0
  47. {django_solomon-0.2.0 → django_solomon-0.3.0}/tests/__init__.py +0 -0
  48. {django_solomon-0.2.0 → django_solomon-0.3.0}/tests/conftest.py +0 -0
  49. {django_solomon-0.2.0 → django_solomon-0.3.0}/tests/settings.py +0 -0
  50. {django_solomon-0.2.0 → django_solomon-0.3.0}/tests/templates/base.html +0 -0
  51. {django_solomon-0.2.0 → django_solomon-0.3.0}/tests/test_admin.py +0 -0
  52. {django_solomon-0.2.0 → django_solomon-0.3.0}/tests/test_config.py +0 -0
  53. {django_solomon-0.2.0 → django_solomon-0.3.0}/tests/test_forms.py +0 -0
  54. {django_solomon-0.2.0 → django_solomon-0.3.0}/tests/test_models.py +0 -0
  55. {django_solomon-0.2.0 → django_solomon-0.3.0}/tests/test_urls.py +0 -0
  56. {django_solomon-0.2.0 → django_solomon-0.3.0}/tests/test_views.py +0 -0
  57. {django_solomon-0.2.0 → django_solomon-0.3.0}/tox.ini +0 -0
  58. {django_solomon-0.2.0 → django_solomon-0.3.0}/uv.lock +0 -0
@@ -5,6 +5,20 @@ All notable changes to django-solomon will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.3.0] - 2025-04-20
9
+
10
+ ### Added
11
+
12
+ - IP address tracking for magic links
13
+ - IP validation to enhance security (optional via `SOLOMON_ENFORCE_SAME_IP` setting)
14
+ - Privacy-focused IP anonymization (enabled by default via `SOLOMON_ANONYMIZE_IP` setting)
15
+ - New utility functions for IP handling and anonymization
16
+
17
+ ### Security
18
+
19
+ - Option to validate magic links are used from the same IP address they were created from
20
+ - IP anonymization to protect user privacy (removes last octet for IPv4, last 80 bits for IPv6)
21
+
8
22
  ## [0.2.0] - 2025-04-19
9
23
 
10
24
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: django-solomon
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: A Django app for passwordless authentication using magic links.
5
5
  Project-URL: Home, https://django-solomon.rtfd.io/
6
6
  Project-URL: Documentation, https://django-solomon.rtfd.io/
@@ -55,8 +55,6 @@ Description-Content-Type: text/markdown
55
55
  [![Python versions](https://img.shields.io/pypi/pyversions/django-solomon.svg)](https://pypi.org/project/django-solomon/)
56
56
  [![Django versions](https://img.shields.io/pypi/djversions/django-solomon.svg)](https://pypi.org/project/django-solomon/)
57
57
  [![Documentation Status](https://readthedocs.org/projects/django-solomon/badge/?version=latest)](https://django-solomon.rtfd.io/en/latest/?badge=latest)
58
- [![Downloads](https://static.pepy.tech/badge/django-solomon)](https://pepy.tech/project/django-solomon)
59
- [![Downloads / Month](https://pepy.tech/badge/django-solomon/month)](https://pepy.tech/project/django-solomon)
60
58
  [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
61
59
  [![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv)
62
60
 
@@ -68,6 +66,8 @@ A Django app for passwordless authentication using magic links.
68
66
  - Configurable link expiration time
69
67
  - Blacklist functionality to block specific email addresses
70
68
  - Support for auto-creating users when they request a magic link
69
+ - IP address tracking and validation for enhanced security
70
+ - Privacy-focused IP anonymization
71
71
  - Customizable templates for emails and pages
72
72
  - Compatible with Django's authentication system
73
73
 
@@ -147,6 +147,8 @@ django-solomon provides several settings that you can customize in your Django s
147
147
  | `SOLOMON_LOGIN_FORM_TEMPLATE` | `"django_solomon/base/login_form.html"` | The template to use for the login form page |
148
148
  | `SOLOMON_INVALID_MAGIC_LINK_TEMPLATE` | `"django_solomon/base/invalid_magic_link.html"` | The template to use for the invalid magic link page |
149
149
  | `SOLOMON_MAGIC_LINK_SENT_TEMPLATE` | `"django_solomon/base/magic_link_sent.html"` | The template to use for the magic link sent confirmation page |
150
+ | `SOLOMON_ENFORCE_SAME_IP` | `False` | If enabled, validates that magic links are used from the same IP they were created from |
151
+ | `SOLOMON_ANONYMIZE_IP` | `True` | If enabled, anonymizes IP addresses before storing them (removes last octet for IPv4) |
150
152
 
151
153
  ## Usage
152
154
 
@@ -183,10 +185,15 @@ You can also use django-solomon programmatically in your views:
183
185
  ```python
184
186
  from django.contrib.auth import authenticate, login
185
187
  from django_solomon.models import MagicLink
188
+ from django_solomon.utilities import get_client_ip
186
189
 
187
190
  # Create a magic link for a user
188
191
  magic_link = MagicLink.objects.create_for_user(user)
189
192
 
193
+ # Create a magic link with IP address tracking
194
+ ip_address = get_client_ip(request)
195
+ magic_link = MagicLink.objects.create_for_user(user, ip_address=ip_address)
196
+
190
197
  # Authenticate a user with a token
191
198
  user = authenticate(request=request, token=token)
192
199
  if user:
@@ -195,11 +202,13 @@ if user:
195
202
 
196
203
  ## Documentation
197
204
 
198
- For more detailed information, tutorials, and advanced usage examples, please visit the [official documentation](https://django-solomon.rtfd.io/).
205
+ For more detailed information, tutorials, and advanced usage examples, please visit
206
+ the [official documentation](https://django-solomon.rtfd.io/).
199
207
 
200
208
  ## License
201
209
 
202
- This software is licensed under [MIT license](https://codeberg.org/oliverandrich/django-solomon/src/branch/main/LICENSE).
210
+ This software is licensed
211
+ under [MIT license](https://codeberg.org/oliverandrich/django-solomon/src/branch/main/LICENSE).
203
212
 
204
213
  ## Contributing
205
214
 
@@ -4,8 +4,6 @@
4
4
  [![Python versions](https://img.shields.io/pypi/pyversions/django-solomon.svg)](https://pypi.org/project/django-solomon/)
5
5
  [![Django versions](https://img.shields.io/pypi/djversions/django-solomon.svg)](https://pypi.org/project/django-solomon/)
6
6
  [![Documentation Status](https://readthedocs.org/projects/django-solomon/badge/?version=latest)](https://django-solomon.rtfd.io/en/latest/?badge=latest)
7
- [![Downloads](https://static.pepy.tech/badge/django-solomon)](https://pepy.tech/project/django-solomon)
8
- [![Downloads / Month](https://pepy.tech/badge/django-solomon/month)](https://pepy.tech/project/django-solomon)
9
7
  [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
10
8
  [![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv)
11
9
 
@@ -17,6 +15,8 @@ A Django app for passwordless authentication using magic links.
17
15
  - Configurable link expiration time
18
16
  - Blacklist functionality to block specific email addresses
19
17
  - Support for auto-creating users when they request a magic link
18
+ - IP address tracking and validation for enhanced security
19
+ - Privacy-focused IP anonymization
20
20
  - Customizable templates for emails and pages
21
21
  - Compatible with Django's authentication system
22
22
 
@@ -96,6 +96,8 @@ django-solomon provides several settings that you can customize in your Django s
96
96
  | `SOLOMON_LOGIN_FORM_TEMPLATE` | `"django_solomon/base/login_form.html"` | The template to use for the login form page |
97
97
  | `SOLOMON_INVALID_MAGIC_LINK_TEMPLATE` | `"django_solomon/base/invalid_magic_link.html"` | The template to use for the invalid magic link page |
98
98
  | `SOLOMON_MAGIC_LINK_SENT_TEMPLATE` | `"django_solomon/base/magic_link_sent.html"` | The template to use for the magic link sent confirmation page |
99
+ | `SOLOMON_ENFORCE_SAME_IP` | `False` | If enabled, validates that magic links are used from the same IP they were created from |
100
+ | `SOLOMON_ANONYMIZE_IP` | `True` | If enabled, anonymizes IP addresses before storing them (removes last octet for IPv4) |
99
101
 
100
102
  ## Usage
101
103
 
@@ -132,10 +134,15 @@ You can also use django-solomon programmatically in your views:
132
134
  ```python
133
135
  from django.contrib.auth import authenticate, login
134
136
  from django_solomon.models import MagicLink
137
+ from django_solomon.utilities import get_client_ip
135
138
 
136
139
  # Create a magic link for a user
137
140
  magic_link = MagicLink.objects.create_for_user(user)
138
141
 
142
+ # Create a magic link with IP address tracking
143
+ ip_address = get_client_ip(request)
144
+ magic_link = MagicLink.objects.create_for_user(user, ip_address=ip_address)
145
+
139
146
  # Authenticate a user with a token
140
147
  user = authenticate(request=request, token=token)
141
148
  if user:
@@ -144,11 +151,13 @@ if user:
144
151
 
145
152
  ## Documentation
146
153
 
147
- For more detailed information, tutorials, and advanced usage examples, please visit the [official documentation](https://django-solomon.rtfd.io/).
154
+ For more detailed information, tutorials, and advanced usage examples, please visit
155
+ the [official documentation](https://django-solomon.rtfd.io/).
148
156
 
149
157
  ## License
150
158
 
151
- This software is licensed under [MIT license](https://codeberg.org/oliverandrich/django-solomon/src/branch/main/LICENSE).
159
+ This software is licensed
160
+ under [MIT license](https://codeberg.org/oliverandrich/django-solomon/src/branch/main/LICENSE).
152
161
 
153
162
  ## Contributing
154
163
 
@@ -81,6 +81,32 @@ If enabled, allows staff users to log in using magic links. If disabled, staff u
81
81
  SOLOMON_ALLOW_STAFF_LOGIN = False
82
82
  ```
83
83
 
84
+ ## IP Address Settings
85
+
86
+ django-solomon can track and validate IP addresses for enhanced security while respecting user privacy.
87
+
88
+ ### SOLOMON_ENFORCE_SAME_IP
89
+
90
+ **Default:** `False`
91
+
92
+ If enabled, validates that magic links are used from the same IP address they were created from. This adds an extra layer of security by preventing magic links from being used if intercepted by an attacker on a different network.
93
+
94
+ ```python
95
+ # Enable IP validation for magic links
96
+ SOLOMON_ENFORCE_SAME_IP = True
97
+ ```
98
+
99
+ ### SOLOMON_ANONYMIZE_IP
100
+
101
+ **Default:** `True`
102
+
103
+ If enabled, anonymizes IP addresses before storing them. For IPv4 addresses, the last octet is removed (e.g., 192.168.1.1 becomes 192.168.1.0). For IPv6 addresses, the last 80 bits (last 5 segments) are removed. This enhances user privacy while still allowing for IP validation.
104
+
105
+ ```python
106
+ # Disable IP anonymization (store full IP addresses)
107
+ SOLOMON_ANONYMIZE_IP = False
108
+ ```
109
+
84
110
  ## Template Settings
85
111
 
86
112
  django-solomon uses several templates for rendering emails and pages. You can customize these templates by providing your own versions.
@@ -178,3 +204,5 @@ Here's a summary of all available settings:
178
204
  | `SOLOMON_LOGIN_FORM_TEMPLATE` | `"django_solomon/base/login_form.html"` | The template to use for the login form page |
179
205
  | `SOLOMON_INVALID_MAGIC_LINK_TEMPLATE` | `"django_solomon/base/invalid_magic_link.html"` | The template to use for the invalid magic link page |
180
206
  | `SOLOMON_MAGIC_LINK_SENT_TEMPLATE` | `"django_solomon/base/magic_link_sent.html"` | The template to use for the magic link sent confirmation page |
207
+ | `SOLOMON_ENFORCE_SAME_IP` | `False` | If enabled, validates that magic links are used from the same IP they were created from |
208
+ | `SOLOMON_ANONYMIZE_IP` | `True` | If enabled, anonymizes IP addresses before storing them |
@@ -6,6 +6,7 @@ from django.http import HttpRequest
6
6
  from django.utils.translation import gettext_lazy as _
7
7
 
8
8
  from django_solomon.models import MagicLink, UserType
9
+ from django_solomon.utilities import get_client_ip
9
10
 
10
11
 
11
12
  class MagicLinkBackend(ModelBackend):
@@ -41,6 +42,21 @@ class MagicLinkBackend(ModelBackend):
41
42
  request.magic_link_error = _("Invalid or expired magic link")
42
43
  return None
43
44
 
45
+ # Check if IP validation is enabled
46
+ if getattr(settings, "SOLOMON_ENFORCE_SAME_IP", False):
47
+ # Only check IP if we have a request object
48
+ if request is not None:
49
+ # Get current IP address
50
+ current_ip = get_client_ip(request)
51
+
52
+ # Compare with stored IP address
53
+ if magic_link.ip_address and current_ip != magic_link.ip_address:
54
+ request.magic_link_error = _("IP address mismatch. Please request a new magic link.")
55
+ return None
56
+ else:
57
+ # If IP validation is enabled but we don't have a request object, we can't validate the IP
58
+ return None
59
+
44
60
  # Mark the magic link as used
45
61
  magic_link.use()
46
62
 
@@ -0,0 +1,18 @@
1
+ # Generated by Django 5.2 on 2025-04-20 14:59
2
+
3
+ from django.db import migrations, models
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+
8
+ dependencies = [
9
+ ('django_solomon', '0001_initial'),
10
+ ]
11
+
12
+ operations = [
13
+ migrations.AddField(
14
+ model_name='magiclink',
15
+ name='ip_address',
16
+ field=models.GenericIPAddressField(blank=True, null=True, verbose_name='IP Address'),
17
+ ),
18
+ ]
@@ -42,7 +42,7 @@ class MagicLinkManager(models.Manager["MagicLink"]):
42
42
  criteria like token, usage status, and expiration date.
43
43
  """
44
44
 
45
- def create_for_user(self, user: UserType) -> "MagicLink":
45
+ def create_for_user(self, user: UserType, ip_address: str = None) -> "MagicLink":
46
46
  """
47
47
  Creates a new magic link for a given user. If the setting SOLOMON_ONLY_ONE_LINK_ALLOWED
48
48
  is enabled, marks existing links for the user as used before creating a new one.
@@ -55,7 +55,7 @@ class MagicLinkManager(models.Manager["MagicLink"]):
55
55
  """
56
56
  if getattr(settings, "SOLOMON_ONLY_ONE_LINK_ALLOWED", True):
57
57
  self.filter(user=user).update(used=True)
58
- return self.create(user=user)
58
+ return self.create(user=user, ip_address=ip_address)
59
59
 
60
60
  def get_valid_link(self, token: str) -> "MagicLink":
61
61
  """
@@ -90,6 +90,7 @@ class MagicLink(models.Model):
90
90
  created_at = models.DateTimeField(_("Created at"), auto_now_add=True)
91
91
  expires_at = models.DateTimeField(_("Expires at"))
92
92
  used = models.BooleanField(_("Used"), default=False)
93
+ ip_address = models.GenericIPAddressField(_("IP Address"), null=True, blank=True)
93
94
 
94
95
  objects = MagicLinkManager()
95
96
 
@@ -0,0 +1,150 @@
1
+ import ipaddress
2
+ import logging
3
+ import socket
4
+
5
+ from django.conf import settings
6
+ from django.contrib.auth import get_user_model
7
+ from django.contrib.auth.hashers import make_password
8
+
9
+ from django_solomon.models import UserType
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ def get_user_by_email(email: str) -> UserType | None:
15
+ """
16
+ Get a user by email.
17
+
18
+ This method supports both the standard User model and custom User models
19
+ by checking if the email field exists on the model.
20
+
21
+ Args:
22
+ email: The email to look up.
23
+
24
+ Returns:
25
+ The user if found, None otherwise.
26
+ """
27
+ user_model = get_user_model()
28
+
29
+ # Check if the user model has an email field
30
+ if hasattr(user_model, "EMAIL_FIELD"):
31
+ email_field = user_model.EMAIL_FIELD
32
+ else:
33
+ email_field = "email"
34
+
35
+ # Make sure the email field exists on the model
36
+ if not hasattr(user_model, email_field):
37
+ logger.warning(f"User model {user_model.__name__} does not have field {email_field}, falling back to 'email'")
38
+ email_field = "email"
39
+
40
+ # Check if the fallback field exists
41
+ if not hasattr(user_model, email_field):
42
+ logger.error(f"User model {user_model.__name__} does not have an email field")
43
+ return None
44
+
45
+ # Query for the user
46
+ try:
47
+ query = {email_field: email}
48
+ return user_model.objects.get(**query)
49
+ except user_model.DoesNotExist:
50
+ return None
51
+ except Exception as e:
52
+ logger.error(f"Error getting user by email: {e}")
53
+ return None
54
+
55
+
56
+ def create_user_from_email(email: str) -> UserType:
57
+ """
58
+ Creates a new user with the given email.
59
+
60
+ This function uses the email as both the username and email address
61
+ for the new user.
62
+
63
+ Args:
64
+ email: The email to use for the new user.
65
+
66
+ Returns:
67
+ The newly created user.
68
+ """
69
+ user_model = get_user_model()
70
+
71
+ # Create the user with email as username
72
+ user_kwargs = {
73
+ "username": email,
74
+ "email": email,
75
+ "password": make_password(None),
76
+ }
77
+
78
+ # Check if the user model has an email field
79
+ if hasattr(user_model, "EMAIL_FIELD"):
80
+ email_field = user_model.EMAIL_FIELD
81
+ if email_field != "email":
82
+ user_kwargs[email_field] = email
83
+
84
+ return user_model.objects.create_user(**user_kwargs)
85
+
86
+
87
+ def get_ip_from_hostname(hostname: str) -> str | None:
88
+ try:
89
+ addr_info = socket.getaddrinfo(hostname, None)
90
+ if addr_info:
91
+ return addr_info[0][4][0]
92
+ return None
93
+ except socket.gaierror:
94
+ return None
95
+
96
+
97
+ def anonymize_ip(ip_str: str) -> str:
98
+ """
99
+ Anonymize an IP address by removing the last octet for IPv4 or
100
+ the last 80 bits (last 5 segments) for IPv6.
101
+
102
+ Args:
103
+ ip_str: The IP address to anonymize.
104
+
105
+ Returns:
106
+ The anonymized IP address.
107
+ """
108
+ try:
109
+ ip = ipaddress.ip_address(ip_str)
110
+
111
+ if isinstance(ip, ipaddress.IPv4Address):
112
+ # For IPv4, zero out the last octet
113
+ # Convert to integer, mask with 0xFFFFFF00 to zero last octet, convert back
114
+ masked_ip = ipaddress.IPv4Address(int(ip) & 0xFFFFFF00)
115
+ return str(masked_ip)
116
+ elif isinstance(ip, ipaddress.IPv6Address):
117
+ # For IPv6, zero out the last 80 bits (last 5 segments)
118
+ # Convert to integer, mask with ~(2^80-1) to zero last 80 bits, convert back
119
+ masked_ip = ipaddress.IPv6Address(int(ip) & ~((1 << 80) - 1))
120
+ return str(masked_ip)
121
+ return ip_str
122
+ except ValueError:
123
+ # If it's not a valid IP address, return as is
124
+ return ip_str
125
+
126
+
127
+ def get_client_ip(request) -> str:
128
+ # Check X-Forwarded-For header first
129
+ x_forwarded_for = request.headers.get("x-forwarded-for")
130
+ if x_forwarded_for:
131
+ ip = x_forwarded_for.split(",")[0].strip()
132
+ else:
133
+ ip = request.META.get("REMOTE_ADDR")
134
+
135
+ # Check if the value is a valid IP address
136
+ try:
137
+ ipaddress.ip_address(ip)
138
+ # Anonymize IP if the setting is enabled (default is True)
139
+ if getattr(settings, "SOLOMON_ANONYMIZE_IP", True):
140
+ return anonymize_ip(ip)
141
+ return ip
142
+ except ValueError:
143
+ # Not a valid IP, try to resolve as hostname
144
+ resolved_ip = get_ip_from_hostname(ip)
145
+ if resolved_ip:
146
+ # Anonymize the resolved IP if the setting is enabled
147
+ if getattr(settings, "SOLOMON_ANONYMIZE_IP", True):
148
+ return anonymize_ip(resolved_ip)
149
+ return resolved_ip
150
+ return ip
@@ -19,7 +19,7 @@ from django_solomon.config import (
19
19
  )
20
20
  from django_solomon.forms import MagicLinkForm
21
21
  from django_solomon.models import BlacklistedEmail, MagicLink
22
- from django_solomon.utilities import get_user_by_email, create_user_from_email
22
+ from django_solomon.utilities import get_user_by_email, create_user_from_email, get_client_ip
23
23
 
24
24
  logger = logging.getLogger(__name__)
25
25
  User = get_user_model()
@@ -58,7 +58,12 @@ def send_magic_link(request: HttpRequest) -> HttpResponse:
58
58
 
59
59
  # Send magic link if user exists
60
60
  if user:
61
- magic_link = MagicLink.objects.create_for_user(user)
61
+ # Get the user's IP address
62
+ ip_address = get_client_ip(request)
63
+
64
+ # Create magic link with IP address
65
+ magic_link = MagicLink.objects.create(user=user, ip_address=ip_address)
66
+
62
67
  link_url = request.build_absolute_uri(
63
68
  reverse(
64
69
  "django_solomon:validate_magic_link",
@@ -1,7 +1,9 @@
1
+ from unittest.mock import patch
2
+
1
3
  import pytest
4
+ from django.contrib.auth import get_user_model
2
5
  from django.http import HttpRequest
3
6
  from django.test import override_settings
4
- from django.contrib.auth import get_user_model
5
7
 
6
8
  from django_solomon.backends import MagicLinkBackend
7
9
  from django_solomon.models import MagicLink
@@ -9,6 +11,12 @@ from django_solomon.models import MagicLink
9
11
  User = get_user_model()
10
12
 
11
13
 
14
+ @pytest.fixture
15
+ def magic_link_with_ip(user):
16
+ """Create and return a magic link with IP address for testing."""
17
+ return MagicLink.objects.create_for_user(user, ip_address="192.168.1.1")
18
+
19
+
12
20
  @pytest.mark.django_db
13
21
  class TestMagicLinkBackend:
14
22
  """Tests for the MagicLinkBackend class."""
@@ -165,3 +173,52 @@ class TestMagicLinkBackend:
165
173
  authenticated_user = self.backend.authenticate(token=magic_link.token, request=None)
166
174
  assert authenticated_user is None
167
175
  # No error should be set since there's no request object
176
+
177
+ @override_settings(SOLOMON_ENFORCE_SAME_IP=True)
178
+ def test_authenticate_ip_match(self, magic_link_with_ip):
179
+ """Test authentication with IP validation enabled and matching IP addresses."""
180
+ # Mock the get_client_ip function to return the same IP as in the magic link
181
+ with patch("django_solomon.backends.get_client_ip", return_value="192.168.1.1"):
182
+ # Try to authenticate
183
+ authenticated_user = self.backend.authenticate(request=self.request, token=magic_link_with_ip.token)
184
+ assert authenticated_user == magic_link_with_ip.user
185
+
186
+ @override_settings(SOLOMON_ENFORCE_SAME_IP=True)
187
+ def test_authenticate_ip_mismatch(self, magic_link_with_ip):
188
+ """Test authentication with IP validation enabled and mismatched IP addresses."""
189
+ # Mock the get_client_ip function to return a different IP
190
+ with patch("django_solomon.backends.get_client_ip", return_value="10.0.0.1"):
191
+ # Try to authenticate
192
+ authenticated_user = self.backend.authenticate(request=self.request, token=magic_link_with_ip.token)
193
+ assert authenticated_user is None
194
+ assert hasattr(self.request, "magic_link_error")
195
+ assert self.request.magic_link_error
196
+
197
+ @override_settings(SOLOMON_ENFORCE_SAME_IP=False)
198
+ def test_authenticate_ip_validation_disabled(self, magic_link_with_ip):
199
+ """Test authentication with IP validation disabled."""
200
+ # Mock the get_client_ip function to return a different IP
201
+ with patch("django_solomon.backends.get_client_ip", return_value="10.0.0.1"):
202
+ # Try to authenticate - should work despite IP mismatch because validation is disabled
203
+ authenticated_user = self.backend.authenticate(request=self.request, token=magic_link_with_ip.token)
204
+ assert authenticated_user == magic_link_with_ip.user
205
+
206
+ @override_settings(SOLOMON_ENFORCE_SAME_IP=True)
207
+ def test_authenticate_ip_validation_no_stored_ip(self, magic_link):
208
+ """Test authentication with IP validation enabled but no stored IP in the magic link."""
209
+ # Magic link has no IP address stored
210
+ assert magic_link.ip_address is None
211
+
212
+ # Mock the get_client_ip function to return any IP
213
+ with patch("django_solomon.backends.get_client_ip", return_value="10.0.0.1"):
214
+ # Try to authenticate - should work because there's no IP to compare against
215
+ authenticated_user = self.backend.authenticate(request=self.request, token=magic_link.token)
216
+ assert authenticated_user == magic_link.user
217
+
218
+ @override_settings(SOLOMON_ENFORCE_SAME_IP=True)
219
+ def test_authenticate_ip_validation_no_request(self, magic_link_with_ip):
220
+ """Test authentication with IP validation enabled but no request object."""
221
+ # Try to authenticate without a request object
222
+ authenticated_user = self.backend.authenticate(token=magic_link_with_ip.token, request=None)
223
+ # Should fail because there's no request to get the IP from
224
+ assert authenticated_user is None
@@ -1,9 +1,18 @@
1
+ import socket
1
2
  from unittest.mock import patch, MagicMock
2
3
 
3
4
  import pytest
4
5
  from django.contrib.auth import get_user_model
6
+ from django.http import HttpRequest
7
+ from django.test import override_settings
5
8
 
6
- from django_solomon.utilities import get_user_by_email, create_user_from_email
9
+ from django_solomon.utilities import (
10
+ get_user_by_email,
11
+ create_user_from_email,
12
+ get_ip_from_hostname,
13
+ get_client_ip,
14
+ anonymize_ip,
15
+ )
7
16
 
8
17
 
9
18
  @pytest.mark.django_db
@@ -337,3 +346,198 @@ class TestCreateUserFromEmail:
337
346
  # This ensures the branch where email_field != "email" is covered
338
347
  assert "custom_email" in create_user_kwargs
339
348
  assert create_user_kwargs["custom_email"] == email
349
+
350
+
351
+ class TestGetIpFromHostname:
352
+ """Tests for the get_ip_from_hostname function."""
353
+
354
+ def test_get_ip_from_valid_hostname(self):
355
+ """Test getting an IP from a valid hostname."""
356
+ with patch("socket.getaddrinfo") as mock_getaddrinfo:
357
+ # Mock the return value of getaddrinfo to simulate a successful lookup
358
+ mock_getaddrinfo.return_value = [(2, 1, 6, "", ("93.184.216.34", 0))]
359
+
360
+ ip = get_ip_from_hostname("example.com")
361
+ assert ip == "93.184.216.34"
362
+ mock_getaddrinfo.assert_called_once_with("example.com", None)
363
+
364
+ def test_get_ip_from_ip_address(self):
365
+ """Test getting an IP when the input is already an IP address."""
366
+ with patch("socket.getaddrinfo") as mock_getaddrinfo:
367
+ # Mock the return value of getaddrinfo to return the same IP
368
+ mock_getaddrinfo.return_value = [(2, 1, 6, "", ("192.168.1.1", 0))]
369
+
370
+ ip = get_ip_from_hostname("192.168.1.1")
371
+ assert ip == "192.168.1.1"
372
+ mock_getaddrinfo.assert_called_once_with("192.168.1.1", None)
373
+
374
+ def test_get_ip_from_invalid_hostname(self):
375
+ """Test getting an IP from an invalid hostname."""
376
+ with patch("socket.getaddrinfo") as mock_getaddrinfo:
377
+ # Mock socket.gaierror to simulate a failed lookup
378
+ mock_getaddrinfo.side_effect = socket.gaierror("Name or service not known")
379
+
380
+ ip = get_ip_from_hostname("invalid.hostname.that.does.not.exist")
381
+ assert ip is None
382
+ mock_getaddrinfo.assert_called_once_with("invalid.hostname.that.does.not.exist", None)
383
+
384
+ def test_get_ip_from_empty_result(self):
385
+ """Test getting an IP when getaddrinfo returns an empty list."""
386
+ with patch("socket.getaddrinfo") as mock_getaddrinfo:
387
+ # Mock the return value of getaddrinfo to return an empty list
388
+ mock_getaddrinfo.return_value = []
389
+
390
+ ip = get_ip_from_hostname("empty.result.com")
391
+ assert ip is None
392
+ mock_getaddrinfo.assert_called_once_with("empty.result.com", None)
393
+
394
+
395
+ class TestGetClientIp:
396
+ """Tests for the get_client_ip function."""
397
+
398
+ @override_settings(SOLOMON_ANONYMIZE_IP=True)
399
+ def test_get_client_ip_with_x_forwarded_for_valid_ip(self):
400
+ """Test getting client IP from X-Forwarded-For header with a valid IP."""
401
+ request = HttpRequest()
402
+ request.META = {"HTTP_X_FORWARDED_FOR": "192.168.1.1, 10.0.0.1"}
403
+
404
+ # Mock anonymize_ip to return an anonymized IP without calling the real function
405
+ with patch("django_solomon.utilities.anonymize_ip", return_value="192.168.1.0") as mock_anonymize:
406
+ ip = get_client_ip(request)
407
+ assert ip == "192.168.1.0"
408
+ mock_anonymize.assert_called_once_with("192.168.1.1")
409
+
410
+ @override_settings(SOLOMON_ANONYMIZE_IP=True)
411
+ def test_get_client_ip_with_x_forwarded_for_hostname(self):
412
+ """Test getting client IP from X-Forwarded-For header with a hostname."""
413
+ request = HttpRequest()
414
+ request.META = {"HTTP_X_FORWARDED_FOR": "example.com, 10.0.0.1"}
415
+
416
+ # Mock the ip_address function to raise ValueError for the hostname
417
+ with patch("ipaddress.ip_address", side_effect=ValueError("invalid IP address")):
418
+ # Mock get_ip_from_hostname to return a valid IP
419
+ with patch("django_solomon.utilities.get_ip_from_hostname", return_value="93.184.216.34") as mock_get_ip:
420
+ # Mock anonymize_ip to return an anonymized IP
421
+ with patch("django_solomon.utilities.anonymize_ip", return_value="93.184.216.0") as mock_anonymize:
422
+ ip = get_client_ip(request)
423
+ assert ip == "93.184.216.0"
424
+ mock_get_ip.assert_called_once_with("example.com")
425
+ mock_anonymize.assert_called_once_with("93.184.216.34")
426
+
427
+ @override_settings(SOLOMON_ANONYMIZE_IP=True)
428
+ def test_get_client_ip_with_remote_addr_valid_ip(self):
429
+ """Test getting client IP from REMOTE_ADDR with a valid IP."""
430
+ request = HttpRequest()
431
+ request.META = {"REMOTE_ADDR": "192.168.1.1"}
432
+
433
+ # Mock anonymize_ip to return an anonymized IP
434
+ with patch("django_solomon.utilities.anonymize_ip", return_value="192.168.1.0") as mock_anonymize:
435
+ ip = get_client_ip(request)
436
+ assert ip == "192.168.1.0"
437
+ mock_anonymize.assert_called_once_with("192.168.1.1")
438
+
439
+ @override_settings(SOLOMON_ANONYMIZE_IP=True)
440
+ def test_get_client_ip_with_remote_addr_hostname(self):
441
+ """Test getting client IP from REMOTE_ADDR with a hostname."""
442
+ request = HttpRequest()
443
+ request.META = {"REMOTE_ADDR": "example.com"}
444
+
445
+ # Mock the ip_address function to raise ValueError for the hostname
446
+ with patch("ipaddress.ip_address", side_effect=ValueError("invalid IP address")):
447
+ # Mock get_ip_from_hostname to return a valid IP
448
+ with patch("django_solomon.utilities.get_ip_from_hostname", return_value="93.184.216.34") as mock_get_ip:
449
+ # Mock anonymize_ip to return an anonymized IP
450
+ with patch("django_solomon.utilities.anonymize_ip", return_value="93.184.216.0") as mock_anonymize:
451
+ ip = get_client_ip(request)
452
+ assert ip == "93.184.216.0"
453
+ mock_get_ip.assert_called_once_with("example.com")
454
+ mock_anonymize.assert_called_once_with("93.184.216.34")
455
+
456
+ @override_settings(SOLOMON_ANONYMIZE_IP=True)
457
+ def test_get_client_ip_with_unresolvable_hostname(self):
458
+ """Test getting client IP when hostname cannot be resolved."""
459
+ request = HttpRequest()
460
+ request.META = {"REMOTE_ADDR": "unresolvable.hostname"}
461
+
462
+ # Mock the ip_address function to raise ValueError for the hostname
463
+ with patch("ipaddress.ip_address", side_effect=ValueError("invalid IP address")):
464
+ # Mock get_ip_from_hostname to return None (unresolvable)
465
+ with patch("django_solomon.utilities.get_ip_from_hostname", return_value=None) as mock_get_ip:
466
+ ip = get_client_ip(request)
467
+ assert ip == "unresolvable.hostname"
468
+ mock_get_ip.assert_called_once_with("unresolvable.hostname")
469
+
470
+ @override_settings(SOLOMON_ANONYMIZE_IP=True)
471
+ def test_get_client_ip_with_multiple_ips_in_x_forwarded_for(self):
472
+ """Test getting client IP from X-Forwarded-For with multiple IPs."""
473
+ request = HttpRequest()
474
+ request.META = {"HTTP_X_FORWARDED_FOR": "192.168.1.1, 10.0.0.1, 172.16.0.1"}
475
+
476
+ # Mock anonymize_ip to return an anonymized IP
477
+ with patch("django_solomon.utilities.anonymize_ip", return_value="192.168.1.0") as mock_anonymize:
478
+ ip = get_client_ip(request)
479
+ assert ip == "192.168.1.0"
480
+ mock_anonymize.assert_called_once_with("192.168.1.1")
481
+
482
+ @override_settings(SOLOMON_ANONYMIZE_IP=False)
483
+ def test_get_client_ip_with_anonymization_disabled(self):
484
+ """Test getting client IP with anonymization disabled."""
485
+ request = HttpRequest()
486
+ request.META = {"REMOTE_ADDR": "192.168.1.1"}
487
+
488
+ # Make sure anonymize_ip is not called
489
+ with patch("django_solomon.utilities.anonymize_ip") as mock_anonymize:
490
+ ip = get_client_ip(request)
491
+ assert ip == "192.168.1.1"
492
+ mock_anonymize.assert_not_called()
493
+
494
+ @override_settings(SOLOMON_ANONYMIZE_IP=False)
495
+ def test_get_client_ip_with_hostname_and_anonymization_disabled(self):
496
+ """Test getting client IP from a hostname with anonymization disabled."""
497
+ request = HttpRequest()
498
+ request.META = {"REMOTE_ADDR": "example.com"}
499
+
500
+ # Mock the ip_address function to raise ValueError for the hostname
501
+ with patch("ipaddress.ip_address", side_effect=ValueError("invalid IP address")):
502
+ # Mock get_ip_from_hostname to return a valid IP
503
+ with patch("django_solomon.utilities.get_ip_from_hostname", return_value="93.184.216.34") as mock_get_ip:
504
+ # Make sure anonymize_ip is not called
505
+ with patch("django_solomon.utilities.anonymize_ip") as mock_anonymize:
506
+ ip = get_client_ip(request)
507
+ assert ip == "93.184.216.34"
508
+ mock_get_ip.assert_called_once_with("example.com")
509
+ mock_anonymize.assert_not_called()
510
+
511
+
512
+ class TestAnonymizeIp:
513
+ """Tests for the anonymize_ip function."""
514
+
515
+ def test_anonymize_ipv4(self):
516
+ """Test anonymizing an IPv4 address."""
517
+ # Test with a real IPv4 address
518
+ result = anonymize_ip("192.168.1.1")
519
+ assert result == "192.168.1.0"
520
+
521
+ def test_anonymize_ipv6(self):
522
+ """Test anonymizing an IPv6 address."""
523
+ # Test with a real IPv6 address
524
+ result = anonymize_ip("2001:db8::1:2:3:4:5")
525
+ assert result == "2001:db8::"
526
+
527
+ def test_anonymize_invalid_ip(self):
528
+ """Test anonymizing an invalid IP address."""
529
+ # Test with an invalid IP address
530
+ result = anonymize_ip("not-an-ip")
531
+ assert result == "not-an-ip"
532
+
533
+ def test_anonymize_other_ip_type(self):
534
+ """Test anonymizing an IP that is neither IPv4 nor IPv6."""
535
+ # Mock ip_address to return an object that is neither IPv4 nor IPv6
536
+ with patch("ipaddress.ip_address") as mock_ip_address:
537
+ mock_ip = MagicMock()
538
+ # Make it so the object is not an instance of IPv4Address or IPv6Address
539
+ mock_ip.__class__ = object
540
+ mock_ip_address.return_value = mock_ip
541
+
542
+ result = anonymize_ip("special-ip")
543
+ assert result == "special-ip"
@@ -1,81 +0,0 @@
1
- import logging
2
-
3
- from django.contrib.auth import get_user_model
4
- from django.contrib.auth.hashers import make_password
5
-
6
- from django_solomon.models import UserType
7
-
8
- logger = logging.getLogger(__name__)
9
-
10
-
11
- def get_user_by_email(email: str) -> UserType | None:
12
- """
13
- Get a user by email.
14
-
15
- This method supports both the standard User model and custom User models
16
- by checking if the email field exists on the model.
17
-
18
- Args:
19
- email: The email to look up.
20
-
21
- Returns:
22
- The user if found, None otherwise.
23
- """
24
- user_model = get_user_model()
25
-
26
- # Check if the user model has an email field
27
- if hasattr(user_model, "EMAIL_FIELD"):
28
- email_field = user_model.EMAIL_FIELD
29
- else:
30
- email_field = "email"
31
-
32
- # Make sure the email field exists on the model
33
- if not hasattr(user_model, email_field):
34
- logger.warning(f"User model {user_model.__name__} does not have field {email_field}, falling back to 'email'")
35
- email_field = "email"
36
-
37
- # Check if the fallback field exists
38
- if not hasattr(user_model, email_field):
39
- logger.error(f"User model {user_model.__name__} does not have an email field")
40
- return None
41
-
42
- # Query for the user
43
- try:
44
- query = {email_field: email}
45
- return user_model.objects.get(**query)
46
- except user_model.DoesNotExist:
47
- return None
48
- except Exception as e:
49
- logger.error(f"Error getting user by email: {e}")
50
- return None
51
-
52
-
53
- def create_user_from_email(email: str) -> UserType:
54
- """
55
- Creates a new user with the given email.
56
-
57
- This function uses the email as both the username and email address
58
- for the new user.
59
-
60
- Args:
61
- email: The email to use for the new user.
62
-
63
- Returns:
64
- The newly created user.
65
- """
66
- user_model = get_user_model()
67
-
68
- # Create the user with email as username
69
- user_kwargs = {
70
- "username": email,
71
- "email": email,
72
- "password": make_password(None),
73
- }
74
-
75
- # Check if the user model has an email field
76
- if hasattr(user_model, "EMAIL_FIELD"):
77
- email_field = user_model.EMAIL_FIELD
78
- if email_field != "email":
79
- user_kwargs[email_field] = email
80
-
81
- return user_model.objects.create_user(**user_kwargs)
File without changes
File without changes
File without changes
File without changes