django-solomon 0.1.3__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.1.3
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,6 +55,8 @@ 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
+ [![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)
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)
58
60
 
59
61
  A Django app for passwordless authentication using magic links.
60
62
 
@@ -64,6 +66,8 @@ A Django app for passwordless authentication using magic links.
64
66
  - Configurable link expiration time
65
67
  - Blacklist functionality to block specific email addresses
66
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
67
71
  - Customizable templates for emails and pages
68
72
  - Compatible with Django's authentication system
69
73
 
@@ -138,11 +142,13 @@ django-solomon provides several settings that you can customize in your Django s
138
142
  | `SOLOMON_LOGIN_REDIRECT_URL` | `settings.LOGIN_REDIRECT_URL` | The URL to redirect to after successful authentication |
139
143
  | `SOLOMON_ALLOW_ADMIN_LOGIN` | `True` | If enabled, allows superusers to log in using magic links |
140
144
  | `SOLOMON_ALLOW_STAFF_LOGIN` | `True` | If enabled, allows staff users to log in using magic links |
141
- | `SOLOMON_MAIL_TEXT_TEMPLATE` | `"django_solomon/email/magic_link.txt"` | The template to use for plain text magic link emails |
142
- | `SOLOMON_MAIL_MJML_TEMPLATE` | `"django_solomon/email/magic_link.mjml"` | The template to use for HTML magic link emails (MJML format) |
145
+ | `SOLOMON_MAIL_TEXT_TEMPLATE` | `"django_solomon/email/magic_link.txt"` | The template to use for plain text magic link emails |
146
+ | `SOLOMON_MAIL_MJML_TEMPLATE` | `"django_solomon/email/magic_link.mjml"` | The template to use for HTML magic link emails (MJML format) |
143
147
  | `SOLOMON_LOGIN_FORM_TEMPLATE` | `"django_solomon/base/login_form.html"` | The template to use for the login form page |
144
148
  | `SOLOMON_INVALID_MAGIC_LINK_TEMPLATE` | `"django_solomon/base/invalid_magic_link.html"` | The template to use for the invalid magic link page |
145
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) |
146
152
 
147
153
  ## Usage
148
154
 
@@ -179,19 +185,30 @@ You can also use django-solomon programmatically in your views:
179
185
  ```python
180
186
  from django.contrib.auth import authenticate, login
181
187
  from django_solomon.models import MagicLink
188
+ from django_solomon.utilities import get_client_ip
182
189
 
183
190
  # Create a magic link for a user
184
191
  magic_link = MagicLink.objects.create_for_user(user)
185
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
+
186
197
  # Authenticate a user with a token
187
198
  user = authenticate(request=request, token=token)
188
199
  if user:
189
200
  login(request, user)
190
201
  ```
191
202
 
203
+ ## Documentation
204
+
205
+ For more detailed information, tutorials, and advanced usage examples, please visit
206
+ the [official documentation](https://django-solomon.rtfd.io/).
207
+
192
208
  ## License
193
209
 
194
- This software is licensed under [MIT license](./LICENSE).
210
+ This software is licensed
211
+ under [MIT license](https://codeberg.org/oliverandrich/django-solomon/src/branch/main/LICENSE).
195
212
 
196
213
  ## Contributing
197
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.1.3.dist-info/METADATA,sha256=n5sipRna8brP5V2CnKcdKzq6ne4nW1DwokUv4HlcVYo,8975
24
- django_solomon-0.1.3.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
25
- django_solomon-0.1.3.dist-info/licenses/LICENSE,sha256=tbfFOFdH5mHIfmNGo6KGOC3U8f-hTCLfetcr8A89WwI,1071
26
- django_solomon-0.1.3.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,,