django-solomon 0.2.0__py3-none-any.whl → 0.4.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
+ ]
@@ -0,0 +1,43 @@
1
+ from django.db import migrations, models
2
+ from django.db.models.functions import Lower
3
+
4
+
5
+ # Copyright (c) 2023 Carlton Gibson
6
+ # This migration is taken from the fantastic project `django-unique-user-email` created by Carlton Gibson.
7
+ # https://github.com/carltongibson/django-unique-user-email/
8
+
9
+
10
+ class CustomAddConstraint(migrations.AddConstraint):
11
+ """
12
+ Override app_label to target auth.User
13
+ """
14
+
15
+ def state_forwards(self, app_label, state):
16
+ state.add_constraint("auth", self.model_name_lower, self.constraint)
17
+
18
+ def database_forwards(self, app_label, schema_editor, from_state, to_state):
19
+ model = to_state.apps.get_model("auth", self.model_name)
20
+ if self.allow_migrate_model(schema_editor.connection.alias, model):
21
+ schema_editor.add_constraint(model, self.constraint)
22
+
23
+ def database_backwards(self, app_label, schema_editor, from_state, to_state):
24
+ model = to_state.apps.get_model("auth", self.model_name)
25
+ if self.allow_migrate_model(schema_editor.connection.alias, model):
26
+ schema_editor.remove_constraint(model, self.constraint)
27
+
28
+
29
+ class Migration(migrations.Migration):
30
+ dependencies = [
31
+ ("django_solomon", "0002_magiclink_ip_address"),
32
+ ("auth", "0012_alter_user_first_name_max_length"),
33
+ ]
34
+
35
+ operations = [
36
+ CustomAddConstraint(
37
+ model_name="user",
38
+ constraint=models.UniqueConstraint(
39
+ Lower("email"),
40
+ name="unique_user_email"
41
+ ),
42
+ ),
43
+ ]
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,10 +1,11 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: django-solomon
3
- Version: 0.2.0
3
+ Version: 0.4.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/
7
7
  Project-URL: Repository, https://codeberg.org/oliverandrich/django-solomon
8
+ Project-URL: Issue Tracker, https://codeberg.org/oliverandrich/django-solomon/issues
8
9
  Author-email: Oliver Andrich <oliver@andrich.me>
9
10
  License: MIT License
10
11
 
@@ -52,11 +53,10 @@ Description-Content-Type: text/markdown
52
53
  # django-solomon
53
54
 
54
55
  [![PyPI version](https://img.shields.io/pypi/v/django-solomon.svg)](https://pypi.org/project/django-solomon/)
56
+ ![PyPI - License](https://img.shields.io/pypi/l/django-solomon)
55
57
  [![Python versions](https://img.shields.io/pypi/pyversions/django-solomon.svg)](https://pypi.org/project/django-solomon/)
56
58
  [![Django versions](https://img.shields.io/pypi/djversions/django-solomon.svg)](https://pypi.org/project/django-solomon/)
57
59
  [![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
60
  [![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
61
  [![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
62
 
@@ -68,8 +68,11 @@ A Django app for passwordless authentication using magic links.
68
68
  - Configurable link expiration time
69
69
  - Blacklist functionality to block specific email addresses
70
70
  - Support for auto-creating users when they request a magic link
71
+ - IP address tracking and validation for enhanced security
72
+ - Privacy-focused IP anonymization
71
73
  - Customizable templates for emails and pages
72
74
  - Compatible with Django's authentication system
75
+ - Enforces unique email addresses for users (case-insensitive)
73
76
 
74
77
  ## Installation
75
78
 
@@ -134,19 +137,21 @@ DEFAULT_FROM_EMAIL = 'your-email@example.com'
134
137
 
135
138
  django-solomon provides several settings that you can customize in your Django settings file:
136
139
 
137
- | Setting | Default | Description |
138
- |---------------------------------------|-------------------------------------------------|----------------------------------------------------------------------------------------|
139
- | `SOLOMON_LINK_EXPIRATION` | `300` | The expiration time for magic links in seconds |
140
- | `SOLOMON_ONLY_ONE_LINK_ALLOWED` | `True` | If enabled, only one active magic link is allowed per user |
141
- | `SOLOMON_CREATE_USER_IF_NOT_FOUND` | `False` | If enabled, creates a new user when a magic link is requested for a non-existent email |
142
- | `SOLOMON_LOGIN_REDIRECT_URL` | `settings.LOGIN_REDIRECT_URL` | The URL to redirect to after successful authentication |
143
- | `SOLOMON_ALLOW_ADMIN_LOGIN` | `True` | If enabled, allows superusers to log in using magic links |
144
- | `SOLOMON_ALLOW_STAFF_LOGIN` | `True` | If enabled, allows staff users to log in using magic links |
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) |
147
- | `SOLOMON_LOGIN_FORM_TEMPLATE` | `"django_solomon/base/login_form.html"` | The template to use for the login form page |
148
- | `SOLOMON_INVALID_MAGIC_LINK_TEMPLATE` | `"django_solomon/base/invalid_magic_link.html"` | The template to use for the invalid magic link page |
149
- | `SOLOMON_MAGIC_LINK_SENT_TEMPLATE` | `"django_solomon/base/magic_link_sent.html"` | The template to use for the magic link sent confirmation page |
140
+ | Setting | Default | Description |
141
+ |---------------------------------------|-------------------------------------------------|-----------------------------------------------------------------------------------------|
142
+ | `SOLOMON_LINK_EXPIRATION` | `300` | The expiration time for magic links in seconds |
143
+ | `SOLOMON_ONLY_ONE_LINK_ALLOWED` | `True` | If enabled, only one active magic link is allowed per user |
144
+ | `SOLOMON_CREATE_USER_IF_NOT_FOUND` | `False` | If enabled, creates a new user when a magic link is requested for a non-existent email |
145
+ | `SOLOMON_LOGIN_REDIRECT_URL` | `settings.LOGIN_REDIRECT_URL` | The URL to redirect to after successful authentication |
146
+ | `SOLOMON_ALLOW_ADMIN_LOGIN` | `True` | If enabled, allows superusers to log in using magic links |
147
+ | `SOLOMON_ALLOW_STAFF_LOGIN` | `True` | If enabled, allows staff users to log in using magic links |
148
+ | `SOLOMON_MAIL_TEXT_TEMPLATE` | `"django_solomon/email/magic_link.txt"` | The template to use for plain text magic link emails |
149
+ | `SOLOMON_MAIL_MJML_TEMPLATE` | `"django_solomon/email/magic_link.mjml"` | The template to use for HTML magic link emails (MJML format) |
150
+ | `SOLOMON_LOGIN_FORM_TEMPLATE` | `"django_solomon/base/login_form.html"` | The template to use for the login form page |
151
+ | `SOLOMON_INVALID_MAGIC_LINK_TEMPLATE` | `"django_solomon/base/invalid_magic_link.html"` | The template to use for the invalid magic link page |
152
+ | `SOLOMON_MAGIC_LINK_SENT_TEMPLATE` | `"django_solomon/base/magic_link_sent.html"` | The template to use for the magic link sent confirmation page |
153
+ | `SOLOMON_ENFORCE_SAME_IP` | `False` | If enabled, validates that magic links are used from the same IP they were created from |
154
+ | `SOLOMON_ANONYMIZE_IP` | `True` | If enabled, anonymizes IP addresses before storing them (removes last octet for IPv4) |
150
155
 
151
156
  ## Usage
152
157
 
@@ -183,10 +188,15 @@ You can also use django-solomon programmatically in your views:
183
188
  ```python
184
189
  from django.contrib.auth import authenticate, login
185
190
  from django_solomon.models import MagicLink
191
+ from django_solomon.utilities import get_client_ip
186
192
 
187
193
  # Create a magic link for a user
188
194
  magic_link = MagicLink.objects.create_for_user(user)
189
195
 
196
+ # Create a magic link with IP address tracking
197
+ ip_address = get_client_ip(request)
198
+ magic_link = MagicLink.objects.create_for_user(user, ip_address=ip_address)
199
+
190
200
  # Authenticate a user with a token
191
201
  user = authenticate(request=request, token=token)
192
202
  if user:
@@ -195,11 +205,13 @@ if user:
195
205
 
196
206
  ## Documentation
197
207
 
198
- For more detailed information, tutorials, and advanced usage examples, please visit the [official documentation](https://django-solomon.rtfd.io/).
208
+ For more detailed information, tutorials, and advanced usage examples, please visit
209
+ the [official documentation](https://django-solomon.rtfd.io/).
199
210
 
200
211
  ## License
201
212
 
202
- This software is licensed under [MIT license](https://codeberg.org/oliverandrich/django-solomon/src/branch/main/LICENSE).
213
+ This software is licensed
214
+ under [MIT license](https://codeberg.org/oliverandrich/django-solomon/src/branch/main/LICENSE).
203
215
 
204
216
  ## Contributing
205
217
 
@@ -1,26 +1,28 @@
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
18
+ django_solomon/migrations/0003_add_unique_constraint_auth_user_email.py,sha256=9-etXYGGm72G5bmeSdQNpxfQsVl2fTPLyt2sP-CK0G0,1529
17
19
  django_solomon/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
18
20
  django_solomon/templates/django_solomon/base/invalid_magic_link.html,sha256=3U4T2n4uvPvktiOOHYVyNmk0aXFfE4DPrURE1DinWKk,554
19
21
  django_solomon/templates/django_solomon/base/login_form.html,sha256=VYGnrno3c36W9f04XdRqJpjsDfOy0sq5D_cLayPz8Q0,546
20
22
  django_solomon/templates/django_solomon/base/magic_link_sent.html,sha256=GIMxnw3E98TXVkVQkMRTHmX5mz0xUsvgXVj25gO2HPQ,461
21
23
  django_solomon/templates/django_solomon/email/magic_link.mjml,sha256=OnfVGm2yFrOmoQ1syo6-Duq_Qraf3Tv0Vy5oidvt55g,1427
22
24
  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,,
25
+ django_solomon-0.4.0.dist-info/METADATA,sha256=Hi9StTi9V1wK7U1HEeYG1PJiycawWMkrbzQHCns8EX0,10409
26
+ django_solomon-0.4.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
27
+ django_solomon-0.4.0.dist-info/licenses/LICENSE,sha256=tbfFOFdH5mHIfmNGo6KGOC3U8f-hTCLfetcr8A89WwI,1071
28
+ django_solomon-0.4.0.dist-info/RECORD,,