django-solomon 0.2.0__tar.gz → 0.4.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.
- {django_solomon-0.2.0 → django_solomon-0.4.0}/CHANGELOG.md +20 -0
- {django_solomon-0.2.0 → django_solomon-0.4.0}/PKG-INFO +30 -18
- {django_solomon-0.2.0 → django_solomon-0.4.0}/README.md +28 -17
- {django_solomon-0.2.0 → django_solomon-0.4.0}/docs/settings.md +28 -0
- {django_solomon-0.2.0 → django_solomon-0.4.0}/pyproject.toml +1 -0
- {django_solomon-0.2.0 → django_solomon-0.4.0}/src/django_solomon/backends.py +16 -0
- django_solomon-0.4.0/src/django_solomon/migrations/0002_magiclink_ip_address.py +18 -0
- django_solomon-0.4.0/src/django_solomon/migrations/0003_add_unique_constraint_auth_user_email.py +43 -0
- {django_solomon-0.2.0 → django_solomon-0.4.0}/src/django_solomon/models.py +3 -2
- django_solomon-0.4.0/src/django_solomon/utilities.py +150 -0
- {django_solomon-0.2.0 → django_solomon-0.4.0}/src/django_solomon/views.py +7 -2
- {django_solomon-0.2.0 → django_solomon-0.4.0}/tests/test_backends.py +58 -1
- {django_solomon-0.2.0 → django_solomon-0.4.0}/tests/test_models.py +49 -2
- {django_solomon-0.2.0 → django_solomon-0.4.0}/tests/test_utilities.py +205 -1
- django_solomon-0.2.0/src/django_solomon/utilities.py +0 -81
- {django_solomon-0.2.0 → django_solomon-0.4.0}/.forgejo/workflows/release.yml +0 -0
- {django_solomon-0.2.0 → django_solomon-0.4.0}/.forgejo/workflows/tests.yml +0 -0
- {django_solomon-0.2.0 → django_solomon-0.4.0}/.gitignore +0 -0
- {django_solomon-0.2.0 → django_solomon-0.4.0}/.pre-commit-config.yaml +0 -0
- {django_solomon-0.2.0 → django_solomon-0.4.0}/.readthedocs.yml +0 -0
- {django_solomon-0.2.0 → django_solomon-0.4.0}/LICENSE +0 -0
- {django_solomon-0.2.0 → django_solomon-0.4.0}/docs/changelog.md +0 -0
- {django_solomon-0.2.0 → django_solomon-0.4.0}/docs/contributing.md +0 -0
- {django_solomon-0.2.0 → django_solomon-0.4.0}/docs/index.md +0 -0
- {django_solomon-0.2.0 → django_solomon-0.4.0}/docs/installation.md +0 -0
- {django_solomon-0.2.0 → django_solomon-0.4.0}/docs/requirements.txt +0 -0
- {django_solomon-0.2.0 → django_solomon-0.4.0}/docs/templates.md +0 -0
- {django_solomon-0.2.0 → django_solomon-0.4.0}/justfile +0 -0
- {django_solomon-0.2.0 → django_solomon-0.4.0}/mkdocs.yml +0 -0
- {django_solomon-0.2.0 → django_solomon-0.4.0}/src/django_solomon/__init__.py +0 -0
- {django_solomon-0.2.0 → django_solomon-0.4.0}/src/django_solomon/admin.py +0 -0
- {django_solomon-0.2.0 → django_solomon-0.4.0}/src/django_solomon/apps.py +0 -0
- {django_solomon-0.2.0 → django_solomon-0.4.0}/src/django_solomon/config.py +0 -0
- {django_solomon-0.2.0 → django_solomon-0.4.0}/src/django_solomon/forms.py +0 -0
- {django_solomon-0.2.0 → django_solomon-0.4.0}/src/django_solomon/locale/de/LC_MESSAGES/django.po +0 -0
- {django_solomon-0.2.0 → django_solomon-0.4.0}/src/django_solomon/locale/en/LC_MESSAGES/django.po +0 -0
- {django_solomon-0.2.0 → django_solomon-0.4.0}/src/django_solomon/management/__init__.py +0 -0
- {django_solomon-0.2.0 → django_solomon-0.4.0}/src/django_solomon/management/commands/__init__.py +0 -0
- {django_solomon-0.2.0 → django_solomon-0.4.0}/src/django_solomon/migrations/0001_initial.py +0 -0
- {django_solomon-0.2.0 → django_solomon-0.4.0}/src/django_solomon/migrations/__init__.py +0 -0
- {django_solomon-0.2.0 → django_solomon-0.4.0}/src/django_solomon/py.typed +0 -0
- {django_solomon-0.2.0 → django_solomon-0.4.0}/src/django_solomon/templates/django_solomon/base/invalid_magic_link.html +0 -0
- {django_solomon-0.2.0 → django_solomon-0.4.0}/src/django_solomon/templates/django_solomon/base/login_form.html +0 -0
- {django_solomon-0.2.0 → django_solomon-0.4.0}/src/django_solomon/templates/django_solomon/base/magic_link_sent.html +0 -0
- {django_solomon-0.2.0 → django_solomon-0.4.0}/src/django_solomon/templates/django_solomon/email/magic_link.mjml +0 -0
- {django_solomon-0.2.0 → django_solomon-0.4.0}/src/django_solomon/templates/django_solomon/email/magic_link.txt +0 -0
- {django_solomon-0.2.0 → django_solomon-0.4.0}/src/django_solomon/urls.py +0 -0
- {django_solomon-0.2.0 → django_solomon-0.4.0}/tests/.gitignore +0 -0
- {django_solomon-0.2.0 → django_solomon-0.4.0}/tests/__init__.py +0 -0
- {django_solomon-0.2.0 → django_solomon-0.4.0}/tests/conftest.py +0 -0
- {django_solomon-0.2.0 → django_solomon-0.4.0}/tests/settings.py +0 -0
- {django_solomon-0.2.0 → django_solomon-0.4.0}/tests/templates/base.html +0 -0
- {django_solomon-0.2.0 → django_solomon-0.4.0}/tests/test_admin.py +0 -0
- {django_solomon-0.2.0 → django_solomon-0.4.0}/tests/test_config.py +0 -0
- {django_solomon-0.2.0 → django_solomon-0.4.0}/tests/test_forms.py +0 -0
- {django_solomon-0.2.0 → django_solomon-0.4.0}/tests/test_urls.py +0 -0
- {django_solomon-0.2.0 → django_solomon-0.4.0}/tests/test_views.py +0 -0
- {django_solomon-0.2.0 → django_solomon-0.4.0}/tox.ini +0 -0
- {django_solomon-0.2.0 → django_solomon-0.4.0}/uv.lock +0 -0
@@ -5,6 +5,26 @@ 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.4.0] - Unreleased
|
9
|
+
|
10
|
+
### Added
|
11
|
+
|
12
|
+
- Unique constraint on auth.User email field (case-insensitive)
|
13
|
+
|
14
|
+
## [0.3.0] - 2025-04-20
|
15
|
+
|
16
|
+
### Added
|
17
|
+
|
18
|
+
- IP address tracking for magic links
|
19
|
+
- IP validation to enhance security (optional via `SOLOMON_ENFORCE_SAME_IP` setting)
|
20
|
+
- Privacy-focused IP anonymization (enabled by default via `SOLOMON_ANONYMIZE_IP` setting)
|
21
|
+
- New utility functions for IP handling and anonymization
|
22
|
+
|
23
|
+
### Security
|
24
|
+
|
25
|
+
- Option to validate magic links are used from the same IP address they were created from
|
26
|
+
- IP anonymization to protect user privacy (removes last octet for IPv4, last 80 bits for IPv6)
|
27
|
+
|
8
28
|
## [0.2.0] - 2025-04-19
|
9
29
|
|
10
30
|
### Added
|
@@ -1,10 +1,11 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: django-solomon
|
3
|
-
Version: 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
|
[](https://pypi.org/project/django-solomon/)
|
56
|
+

|
55
57
|
[](https://pypi.org/project/django-solomon/)
|
56
58
|
[](https://pypi.org/project/django-solomon/)
|
57
59
|
[](https://django-solomon.rtfd.io/en/latest/?badge=latest)
|
58
|
-
[](https://pepy.tech/project/django-solomon)
|
59
|
-
[](https://pepy.tech/project/django-solomon)
|
60
60
|
[](https://github.com/astral-sh/ruff)
|
61
61
|
[](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
|
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
|
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,11 +1,10 @@
|
|
1
1
|
# django-solomon
|
2
2
|
|
3
3
|
[](https://pypi.org/project/django-solomon/)
|
4
|
+

|
4
5
|
[](https://pypi.org/project/django-solomon/)
|
5
6
|
[](https://pypi.org/project/django-solomon/)
|
6
7
|
[](https://django-solomon.rtfd.io/en/latest/?badge=latest)
|
7
|
-
[](https://pepy.tech/project/django-solomon)
|
8
|
-
[](https://pepy.tech/project/django-solomon)
|
9
8
|
[](https://github.com/astral-sh/ruff)
|
10
9
|
[](https://github.com/astral-sh/uv)
|
11
10
|
|
@@ -17,8 +16,11 @@ A Django app for passwordless authentication using magic links.
|
|
17
16
|
- Configurable link expiration time
|
18
17
|
- Blacklist functionality to block specific email addresses
|
19
18
|
- Support for auto-creating users when they request a magic link
|
19
|
+
- IP address tracking and validation for enhanced security
|
20
|
+
- Privacy-focused IP anonymization
|
20
21
|
- Customizable templates for emails and pages
|
21
22
|
- Compatible with Django's authentication system
|
23
|
+
- Enforces unique email addresses for users (case-insensitive)
|
22
24
|
|
23
25
|
## Installation
|
24
26
|
|
@@ -83,19 +85,21 @@ DEFAULT_FROM_EMAIL = 'your-email@example.com'
|
|
83
85
|
|
84
86
|
django-solomon provides several settings that you can customize in your Django settings file:
|
85
87
|
|
86
|
-
| Setting | Default | Description
|
87
|
-
|
88
|
-
| `SOLOMON_LINK_EXPIRATION` | `300` | The expiration time for magic links in seconds
|
89
|
-
| `SOLOMON_ONLY_ONE_LINK_ALLOWED` | `True` | If enabled, only one active magic link is allowed per user
|
90
|
-
| `SOLOMON_CREATE_USER_IF_NOT_FOUND` | `False` | If enabled, creates a new user when a magic link is requested for a non-existent email
|
91
|
-
| `SOLOMON_LOGIN_REDIRECT_URL` | `settings.LOGIN_REDIRECT_URL` | The URL to redirect to after successful authentication
|
92
|
-
| `SOLOMON_ALLOW_ADMIN_LOGIN` | `True` | If enabled, allows superusers to log in using magic links
|
93
|
-
| `SOLOMON_ALLOW_STAFF_LOGIN` | `True` | If enabled, allows staff users to log in using magic links
|
94
|
-
| `SOLOMON_MAIL_TEXT_TEMPLATE` | `"django_solomon/email/magic_link.txt"` | The template to use for plain text magic link emails
|
95
|
-
| `SOLOMON_MAIL_MJML_TEMPLATE` | `"django_solomon/email/magic_link.mjml"` | The template to use for HTML magic link emails (MJML format)
|
96
|
-
| `SOLOMON_LOGIN_FORM_TEMPLATE` | `"django_solomon/base/login_form.html"` | The template to use for the login form page
|
97
|
-
| `SOLOMON_INVALID_MAGIC_LINK_TEMPLATE` | `"django_solomon/base/invalid_magic_link.html"` | The template to use for the invalid magic link page
|
98
|
-
| `SOLOMON_MAGIC_LINK_SENT_TEMPLATE` | `"django_solomon/base/magic_link_sent.html"` | The template to use for the magic link sent confirmation page
|
88
|
+
| Setting | Default | Description |
|
89
|
+
|---------------------------------------|-------------------------------------------------|-----------------------------------------------------------------------------------------|
|
90
|
+
| `SOLOMON_LINK_EXPIRATION` | `300` | The expiration time for magic links in seconds |
|
91
|
+
| `SOLOMON_ONLY_ONE_LINK_ALLOWED` | `True` | If enabled, only one active magic link is allowed per user |
|
92
|
+
| `SOLOMON_CREATE_USER_IF_NOT_FOUND` | `False` | If enabled, creates a new user when a magic link is requested for a non-existent email |
|
93
|
+
| `SOLOMON_LOGIN_REDIRECT_URL` | `settings.LOGIN_REDIRECT_URL` | The URL to redirect to after successful authentication |
|
94
|
+
| `SOLOMON_ALLOW_ADMIN_LOGIN` | `True` | If enabled, allows superusers to log in using magic links |
|
95
|
+
| `SOLOMON_ALLOW_STAFF_LOGIN` | `True` | If enabled, allows staff users to log in using magic links |
|
96
|
+
| `SOLOMON_MAIL_TEXT_TEMPLATE` | `"django_solomon/email/magic_link.txt"` | The template to use for plain text magic link emails |
|
97
|
+
| `SOLOMON_MAIL_MJML_TEMPLATE` | `"django_solomon/email/magic_link.mjml"` | The template to use for HTML magic link emails (MJML format) |
|
98
|
+
| `SOLOMON_LOGIN_FORM_TEMPLATE` | `"django_solomon/base/login_form.html"` | The template to use for the login form page |
|
99
|
+
| `SOLOMON_INVALID_MAGIC_LINK_TEMPLATE` | `"django_solomon/base/invalid_magic_link.html"` | The template to use for the invalid magic link page |
|
100
|
+
| `SOLOMON_MAGIC_LINK_SENT_TEMPLATE` | `"django_solomon/base/magic_link_sent.html"` | The template to use for the magic link sent confirmation page |
|
101
|
+
| `SOLOMON_ENFORCE_SAME_IP` | `False` | If enabled, validates that magic links are used from the same IP they were created from |
|
102
|
+
| `SOLOMON_ANONYMIZE_IP` | `True` | If enabled, anonymizes IP addresses before storing them (removes last octet for IPv4) |
|
99
103
|
|
100
104
|
## Usage
|
101
105
|
|
@@ -132,10 +136,15 @@ You can also use django-solomon programmatically in your views:
|
|
132
136
|
```python
|
133
137
|
from django.contrib.auth import authenticate, login
|
134
138
|
from django_solomon.models import MagicLink
|
139
|
+
from django_solomon.utilities import get_client_ip
|
135
140
|
|
136
141
|
# Create a magic link for a user
|
137
142
|
magic_link = MagicLink.objects.create_for_user(user)
|
138
143
|
|
144
|
+
# Create a magic link with IP address tracking
|
145
|
+
ip_address = get_client_ip(request)
|
146
|
+
magic_link = MagicLink.objects.create_for_user(user, ip_address=ip_address)
|
147
|
+
|
139
148
|
# Authenticate a user with a token
|
140
149
|
user = authenticate(request=request, token=token)
|
141
150
|
if user:
|
@@ -144,11 +153,13 @@ if user:
|
|
144
153
|
|
145
154
|
## Documentation
|
146
155
|
|
147
|
-
For more detailed information, tutorials, and advanced usage examples, please visit
|
156
|
+
For more detailed information, tutorials, and advanced usage examples, please visit
|
157
|
+
the [official documentation](https://django-solomon.rtfd.io/).
|
148
158
|
|
149
159
|
## License
|
150
160
|
|
151
|
-
This software is licensed
|
161
|
+
This software is licensed
|
162
|
+
under [MIT license](https://codeberg.org/oliverandrich/django-solomon/src/branch/main/LICENSE).
|
152
163
|
|
153
164
|
## Contributing
|
154
165
|
|
@@ -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 |
|
@@ -33,6 +33,7 @@ dependencies = [
|
|
33
33
|
Home = "https://django-solomon.rtfd.io/"
|
34
34
|
Documentation = "https://django-solomon.rtfd.io/"
|
35
35
|
Repository = "https://codeberg.org/oliverandrich/django-solomon"
|
36
|
+
"Issue Tracker" = "https://codeberg.org/oliverandrich/django-solomon/issues"
|
36
37
|
|
37
38
|
[dependency-groups]
|
38
39
|
dev = [
|
@@ -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-0.4.0/src/django_solomon/migrations/0003_add_unique_constraint_auth_user_email.py
ADDED
@@ -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
|
+
]
|
@@ -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
|
-
|
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,8 +1,10 @@
|
|
1
|
+
from datetime import timedelta
|
2
|
+
|
1
3
|
import pytest
|
4
|
+
from django.contrib.auth import get_user_model
|
2
5
|
from django.db import IntegrityError
|
3
|
-
from django.utils import timezone
|
4
6
|
from django.test import override_settings
|
5
|
-
from
|
7
|
+
from django.utils import timezone
|
6
8
|
|
7
9
|
from django_solomon.models import BlacklistedEmail, MagicLink
|
8
10
|
|
@@ -214,3 +216,48 @@ class TestMagicLink:
|
|
214
216
|
link.refresh_from_db()
|
215
217
|
assert link.token == "custom-token"
|
216
218
|
assert abs((link.expires_at - expiration).total_seconds()) < 1 # Allow for small time differences
|
219
|
+
|
220
|
+
|
221
|
+
@pytest.mark.django_db
|
222
|
+
class TestUserEmailUniqueConstraint:
|
223
|
+
"""Tests for the unique constraint on auth.User email field."""
|
224
|
+
|
225
|
+
def test_user_email_unique_constraint(self, user):
|
226
|
+
"""Test that the email field on auth.User has a unique constraint."""
|
227
|
+
User = get_user_model()
|
228
|
+
|
229
|
+
# The user fixture already creates a user with email "test@example.com"
|
230
|
+
# Attempting to create another user with the same email should raise an IntegrityError
|
231
|
+
with pytest.raises(IntegrityError):
|
232
|
+
User.objects.create_user(
|
233
|
+
username="another_user",
|
234
|
+
email="test@example.com", # Same email as the fixture user
|
235
|
+
password="password456",
|
236
|
+
)
|
237
|
+
|
238
|
+
def test_user_email_unique_constraint_case_insensitive(self, user):
|
239
|
+
"""Test that the email uniqueness is case-insensitive."""
|
240
|
+
User = get_user_model()
|
241
|
+
|
242
|
+
# The user fixture already creates a user with email "test@example.com"
|
243
|
+
# Attempting to create another user with the same email in different case should fail
|
244
|
+
# because the constraint is case-insensitive
|
245
|
+
with pytest.raises(IntegrityError):
|
246
|
+
User.objects.create_user(
|
247
|
+
username="another_user",
|
248
|
+
email="TEST@example.com", # Same email as the fixture user but with different case
|
249
|
+
password="password456",
|
250
|
+
)
|
251
|
+
|
252
|
+
def test_different_emails_allowed(self, user):
|
253
|
+
"""Test that users with different emails can be created."""
|
254
|
+
User = get_user_model()
|
255
|
+
|
256
|
+
# Create a user with a different email
|
257
|
+
new_user = User.objects.create_user(
|
258
|
+
username="another_user", email="different@example.com", password="password456"
|
259
|
+
)
|
260
|
+
|
261
|
+
# Verify the user was created
|
262
|
+
assert new_user.pk is not None
|
263
|
+
assert new_user.email == "different@example.com"
|
@@ -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
|
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
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
{django_solomon-0.2.0 → django_solomon-0.4.0}/src/django_solomon/locale/de/LC_MESSAGES/django.po
RENAMED
File without changes
|
{django_solomon-0.2.0 → django_solomon-0.4.0}/src/django_solomon/locale/en/LC_MESSAGES/django.po
RENAMED
File without changes
|
File without changes
|
{django_solomon-0.2.0 → django_solomon-0.4.0}/src/django_solomon/management/commands/__init__.py
RENAMED
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|