django-solomon 0.2.0__py3-none-any.whl → 0.3.0__py3-none-any.whl

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.
@@ -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
+ ]
django_solomon/models.py CHANGED
@@ -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
 
@@ -1,5 +1,8 @@
1
+ import ipaddress
1
2
  import logging
3
+ import socket
2
4
 
5
+ from django.conf import settings
3
6
  from django.contrib.auth import get_user_model
4
7
  from django.contrib.auth.hashers import make_password
5
8
 
@@ -79,3 +82,69 @@ def create_user_from_email(email: str) -> UserType:
79
82
  user_kwargs[email_field] = email
80
83
 
81
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
django_solomon/views.py CHANGED
@@ -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,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
 
@@ -1,26 +1,27 @@
1
1
  django_solomon/__init__.py,sha256=4GdjeQVyChzdc7pZ1jrpknjcnu9Do_0nSh42UZNICKo,80
2
2
  django_solomon/admin.py,sha256=hwesCOQgUDDHPh5qq97AObfSyMgIOKsdzoa69naMPx4,1428
3
3
  django_solomon/apps.py,sha256=glfjdSSzrxuwJ2FrzuF2C4oFNqikMqhSHHmca2qq5Vg,159
4
- django_solomon/backends.py,sha256=A_TTNaduRmZOZF-R0C1yhs9QpXejBCRCz7wDcp_BupY,2403
4
+ django_solomon/backends.py,sha256=3RN4dsCehg5urp9sfASERFda1VR9nislNvHrxUc7rXM,3171
5
5
  django_solomon/config.py,sha256=JEl3cY0BnfC9ja0ZVeLmfIwUStbUAO6vRhGZrrig5Yw,787
6
6
  django_solomon/forms.py,sha256=ymDsZ-KWyJfo12vGbZCxIhIvVket93xYR4MgDif4fh0,312
7
- django_solomon/models.py,sha256=NRsN-F7Vnfx-l5cJgjAJqPhbhRIeIctcRh4GeuysUZA,4771
7
+ django_solomon/models.py,sha256=G-74LDemMJMVOXN94tSz7E9XwspCNkzmHbhEP3Kl80U,4904
8
8
  django_solomon/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
9
  django_solomon/urls.py,sha256=tT-I-p6dA2rD1IvH3pAcJRkPXYW0oYmYWXZAoiLJzGY,471
10
- django_solomon/utilities.py,sha256=Xna8jlr6E5YYxKx7Pz_xD6LwIm3ZfZGdyYfJpIoOD-g,2275
11
- django_solomon/views.py,sha256=qPwpSXhKyLg9zXJVmzC4SJqpTwqVFLbwQdq9qWy2Im0,5553
10
+ django_solomon/utilities.py,sha256=I6LiBwAwZaa8h-iH_6RLP9fkJkK9mEb_24LPzLLuwFA,4559
11
+ django_solomon/views.py,sha256=gqsj1SVd2bbQvla3xpZYVHYCUxhu3fr_5I-LY-Tvm2g,5737
12
12
  django_solomon/locale/de/LC_MESSAGES/django.po,sha256=6lo72lsDV1bKBVowiRsQoaVKH5Mbkf34z78sG1LEYq4,747
13
13
  django_solomon/locale/en/LC_MESSAGES/django.po,sha256=6MjGCWQn-1AjOnP_gXtLa0Oad4qfNAn5tXdhnw_wEcg,732
14
14
  django_solomon/management/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
15
15
  django_solomon/management/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
16
16
  django_solomon/migrations/0001_initial.py,sha256=i-UhR1KQ4p7WmbDg9xUQfMmDo8yYdIigvHNsefLHJEc,1981
17
+ django_solomon/migrations/0002_magiclink_ip_address.py,sha256=hs3MISYV7IHEqF-vELCmFtzhhvK8GEHkZu8RMc4X1YU,432
17
18
  django_solomon/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
18
19
  django_solomon/templates/django_solomon/base/invalid_magic_link.html,sha256=3U4T2n4uvPvktiOOHYVyNmk0aXFfE4DPrURE1DinWKk,554
19
20
  django_solomon/templates/django_solomon/base/login_form.html,sha256=VYGnrno3c36W9f04XdRqJpjsDfOy0sq5D_cLayPz8Q0,546
20
21
  django_solomon/templates/django_solomon/base/magic_link_sent.html,sha256=GIMxnw3E98TXVkVQkMRTHmX5mz0xUsvgXVj25gO2HPQ,461
21
22
  django_solomon/templates/django_solomon/email/magic_link.mjml,sha256=OnfVGm2yFrOmoQ1syo6-Duq_Qraf3Tv0Vy5oidvt55g,1427
22
23
  django_solomon/templates/django_solomon/email/magic_link.txt,sha256=yl01eie3U2HkFEJvB1Qlm8Z6FPuop5yubDXFY5V507Q,502
23
- django_solomon-0.2.0.dist-info/METADATA,sha256=4kHgMkW0gBRFooZSa4IJPImHNsnP9mavcaLuzGEdlSA,9730
24
- django_solomon-0.2.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
25
- django_solomon-0.2.0.dist-info/licenses/LICENSE,sha256=tbfFOFdH5mHIfmNGo6KGOC3U8f-hTCLfetcr8A89WwI,1071
26
- django_solomon-0.2.0.dist-info/RECORD,,
24
+ django_solomon-0.3.0.dist-info/METADATA,sha256=rwHenkror8FMXrpkGQa1ynliMu_JYyyr2tjxDbUY7S4,10184
25
+ django_solomon-0.3.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
26
+ django_solomon-0.3.0.dist-info/licenses/LICENSE,sha256=tbfFOFdH5mHIfmNGo6KGOC3U8f-hTCLfetcr8A89WwI,1071
27
+ django_solomon-0.3.0.dist-info/RECORD,,