django-solomon 0.1.2__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.
@@ -0,0 +1,3 @@
1
+ from importlib.metadata import version
2
+
3
+ __version__ = version("django_solomon")
@@ -0,0 +1,45 @@
1
+ from django.contrib import admin
2
+ from django.utils.translation import gettext_lazy as _
3
+
4
+ from django_solomon.models import BlacklistedEmail, MagicLink
5
+
6
+
7
+ @admin.register(BlacklistedEmail)
8
+ class BlacklistedEmailAdmin(admin.ModelAdmin):
9
+ """Admin interface for BlacklistedEmail model."""
10
+
11
+ list_display = ("email", "reason", "created_at")
12
+ list_filter = ("created_at",)
13
+ search_fields = ("email", "reason")
14
+ date_hierarchy = "created_at"
15
+
16
+ fieldsets = (
17
+ (None, {"fields": ("email", "reason")}),
18
+ (_("Dates"), {"fields": ("created_at",)}),
19
+ )
20
+ readonly_fields = ("created_at",)
21
+
22
+
23
+ @admin.register(MagicLink)
24
+ class MagicLinkAdmin(admin.ModelAdmin):
25
+ """Admin interface for MagicLink model."""
26
+
27
+ list_display = ("user", "created_at", "expires_at", "used", "is_valid")
28
+ list_filter = ("used", "created_at", "expires_at")
29
+ search_fields = ("user__username", "user__email", "token")
30
+ readonly_fields = ("id", "token", "created_at", "is_valid")
31
+ date_hierarchy = "created_at"
32
+
33
+ fieldsets = (
34
+ (None, {"fields": ("id", "user", "token")}),
35
+ (_("Status"), {"fields": ("used", "is_valid")}),
36
+ (_("Dates"), {"fields": ("created_at", "expires_at")}),
37
+ )
38
+
39
+ @admin.display(
40
+ description=_("Valid"),
41
+ boolean=True,
42
+ )
43
+ def is_valid(self, obj: MagicLink) -> bool:
44
+ """Display if the magic link is valid."""
45
+ return obj.is_valid
django_solomon/apps.py ADDED
@@ -0,0 +1,6 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class DjangoSolomonConfig(AppConfig):
5
+ default_auto_field = "django.db.models.BigAutoField"
6
+ name = "django_solomon"
@@ -0,0 +1,61 @@
1
+ from typing import Any
2
+
3
+ from django.conf import settings
4
+ from django.contrib.auth.backends import ModelBackend
5
+ from django.http import HttpRequest
6
+ from django.utils.translation import gettext_lazy as _
7
+
8
+ from django_solomon.models import MagicLink, UserType
9
+
10
+
11
+ class MagicLinkBackend(ModelBackend):
12
+ def authenticate(
13
+ self,
14
+ request: HttpRequest | None = None,
15
+ token: str | None = None,
16
+ **kwargs: Any, # noqa: ARG002
17
+ ) -> UserType | None:
18
+ """
19
+ Authenticates a user based on the provided magic link token. This method validates
20
+ the token, ensures the token is not expired, and checks if the user associated
21
+ with the token is allowed to login based on their role and the application's
22
+ settings.
23
+
24
+ Args:
25
+ request (HttpRequest | None): The HTTP request object. Defaults to None.
26
+ It is used to store error messages in case the authentication fails.
27
+ token (str | None): A unique string token from the magic link used for
28
+ authentication. If None, authentication cannot proceed.
29
+
30
+ Returns:
31
+ Any: Returns the authenticated user object if the token is valid and
32
+ the user is allowed to log in. Returns None if the token is invalid,
33
+ expired, or if the user is not permitted to log in.
34
+ """
35
+ if not token:
36
+ return None
37
+
38
+ magic_link = MagicLink.objects.get_valid_link(token)
39
+ if not magic_link:
40
+ if request:
41
+ request.magic_link_error = _("Invalid or expired magic link")
42
+ return None
43
+
44
+ # Mark the magic link as used
45
+ magic_link.use()
46
+
47
+ user = magic_link.user
48
+
49
+ # Check if user is admin and if they are allowed to login
50
+ if user.is_superuser and not getattr(settings, "SOLOMON_ALLOW_ADMIN_LOGIN", True):
51
+ if request:
52
+ request.magic_link_error = _("Admin users are not allowed to login via magic links")
53
+ return None
54
+
55
+ # Check if user is staff (but not admin) and if they are allowed to login
56
+ if user.is_staff and not user.is_superuser and not getattr(settings, "SOLOMON_ALLOW_STAFF_LOGIN", True):
57
+ if request:
58
+ request.magic_link_error = _("Staff users are not allowed to login via magic links")
59
+ return None
60
+
61
+ return user
@@ -0,0 +1,23 @@
1
+ from django.conf import settings
2
+
3
+
4
+ def get_email_text_template():
5
+ return getattr(settings, "SOLOMON_MAIL_TEXT_TEMPLATE", None) or "django_solomon/email/magic_link.txt"
6
+
7
+
8
+ def get_email_mjml_template():
9
+ return getattr(settings, "SOLOMON_MAIL_MJML_TEMPLATE", None) or "django_solomon/email/magic_link.mjml"
10
+
11
+
12
+ def get_login_form_template():
13
+ return getattr(settings, "SOLOMON_LOGIN_FORM_TEMPLATE", None) or "django_solomon/base/login_form.html"
14
+
15
+
16
+ def get_invalid_magic_link_template():
17
+ return (
18
+ getattr(settings, "SOLOMON_INVALID_MAGIC_LINK_TEMPLATE", None) or "django_solomon/base/invalid_magic_link.html"
19
+ )
20
+
21
+
22
+ def get_magic_link_sent_template():
23
+ return getattr(settings, "SOLOMON_MAGIC_LINK_SENT_TEMPLATE", None) or "django_solomon/base/magic_link_sent.html"
@@ -0,0 +1,12 @@
1
+ from django import forms
2
+ from django.utils.translation import gettext_lazy as _
3
+
4
+
5
+ class MagicLinkForm(forms.Form):
6
+ """Form for requesting a magic link."""
7
+
8
+ email = forms.EmailField(
9
+ label=_("Email"),
10
+ max_length=254,
11
+ widget=forms.EmailInput(attrs={"autocomplete": "email"}),
12
+ )
@@ -0,0 +1,27 @@
1
+ # SOME DESCRIPTIVE TITLE.
2
+ # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
3
+ # This file is distributed under the same license as the PACKAGE package.
4
+ # FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
5
+ #
6
+ #, fuzzy
7
+ msgid ""
8
+ msgstr ""
9
+ "Project-Id-Version: PACKAGE VERSION\n"
10
+ "Report-Msgid-Bugs-To: \n"
11
+ "POT-Creation-Date: 2023-03-04 23:44+0100\n"
12
+ "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
13
+ "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
14
+ "Language-Team: LANGUAGE <LL@li.org>\n"
15
+ "Language: \n"
16
+ "MIME-Version: 1.0\n"
17
+ "Content-Type: text/plain; charset=UTF-8\n"
18
+ "Content-Transfer-Encoding: 8bit\n"
19
+ "Plural-Forms: nplurals=2; plural=(n != 1);\n"
20
+
21
+ #: config/settings.py:150
22
+ msgid "English"
23
+ msgstr "Englisch"
24
+
25
+ #: config/settings.py:151
26
+ msgid "German"
27
+ msgstr "Deutsch"
@@ -0,0 +1,27 @@
1
+ # SOME DESCRIPTIVE TITLE.
2
+ # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
3
+ # This file is distributed under the same license as the PACKAGE package.
4
+ # FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
5
+ #
6
+ #, fuzzy
7
+ msgid ""
8
+ msgstr ""
9
+ "Project-Id-Version: PACKAGE VERSION\n"
10
+ "Report-Msgid-Bugs-To: \n"
11
+ "POT-Creation-Date: 2023-03-04 23:44+0100\n"
12
+ "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
13
+ "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
14
+ "Language-Team: LANGUAGE <LL@li.org>\n"
15
+ "Language: \n"
16
+ "MIME-Version: 1.0\n"
17
+ "Content-Type: text/plain; charset=UTF-8\n"
18
+ "Content-Transfer-Encoding: 8bit\n"
19
+ "Plural-Forms: nplurals=2; plural=(n != 1);\n"
20
+
21
+ #: config/settings.py:150
22
+ msgid "English"
23
+ msgstr ""
24
+
25
+ #: config/settings.py:151
26
+ msgid "German"
27
+ msgstr ""
File without changes
File without changes
@@ -0,0 +1,47 @@
1
+ # Generated by Django 5.2 on 2025-04-18 13:46
2
+
3
+ import django.db.models.deletion
4
+ from django.conf import settings
5
+ from django.db import migrations, models
6
+
7
+
8
+ class Migration(migrations.Migration):
9
+
10
+ initial = True
11
+
12
+ dependencies = [
13
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
14
+ ]
15
+
16
+ operations = [
17
+ migrations.CreateModel(
18
+ name='BlacklistedEmail',
19
+ fields=[
20
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
21
+ ('email', models.EmailField(max_length=255, unique=True, verbose_name='Email')),
22
+ ('reason', models.TextField(blank=True, verbose_name='Reason')),
23
+ ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
24
+ ],
25
+ options={
26
+ 'verbose_name': 'Blacklisted Email',
27
+ 'verbose_name_plural': 'Blacklisted Emails',
28
+ 'ordering': ['-created_at'],
29
+ },
30
+ ),
31
+ migrations.CreateModel(
32
+ name='MagicLink',
33
+ fields=[
34
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
35
+ ('token', models.CharField(max_length=100, unique=True, verbose_name='Token')),
36
+ ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
37
+ ('expires_at', models.DateTimeField(verbose_name='Expires at')),
38
+ ('used', models.BooleanField(default=False, verbose_name='Used')),
39
+ ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='magic_links', to=settings.AUTH_USER_MODEL, verbose_name='User')),
40
+ ],
41
+ options={
42
+ 'verbose_name': 'Magic Link',
43
+ 'verbose_name_plural': 'Magic Links',
44
+ 'ordering': ['-created_at'],
45
+ },
46
+ ),
47
+ ]
File without changes
@@ -0,0 +1,133 @@
1
+ from datetime import timedelta
2
+ from typing import Any, TypeVar
3
+
4
+ from django.conf import settings
5
+ from django.contrib.auth import get_user_model
6
+ from django.contrib.auth.base_user import AbstractBaseUser
7
+ from django.contrib.auth.tokens import default_token_generator
8
+ from django.db import models
9
+ from django.utils import timezone
10
+ from django.utils.translation import gettext_lazy as _
11
+
12
+ User = get_user_model()
13
+ UserType = TypeVar("UserType", bound=AbstractBaseUser)
14
+
15
+
16
+ class BlacklistedEmail(models.Model):
17
+ """
18
+ Model to store blacklisted email addresses.
19
+
20
+ These emails will be blocked from using the magic login feature.
21
+ """
22
+
23
+ email = models.EmailField(_("Email"), max_length=255, unique=True)
24
+ reason = models.TextField(_("Reason"), blank=True)
25
+ created_at = models.DateTimeField(_("Created at"), auto_now_add=True)
26
+
27
+ class Meta:
28
+ verbose_name = _("Blacklisted Email")
29
+ verbose_name_plural = _("Blacklisted Emails")
30
+ ordering = ["-created_at"]
31
+
32
+ def __str__(self) -> str:
33
+ return self.email
34
+
35
+
36
+ class MagicLinkManager(models.Manager["MagicLink"]):
37
+ """
38
+ Manages the creation and retrieval of MagicLink instances.
39
+
40
+ This manager provides utility methods to handle magic links, such as creating a
41
+ new magic link for a user and retrieving valid magic links based on specific
42
+ criteria like token, usage status, and expiration date.
43
+ """
44
+
45
+ def create_for_user(self, user: UserType) -> "MagicLink":
46
+ """
47
+ Creates a new magic link for a given user. If the setting SOLOMON_ONLY_ONE_LINK_ALLOWED
48
+ is enabled, marks existing links for the user as used before creating a new one.
49
+
50
+ Args:
51
+ user (UserType): The user for whom the magic link should be created.
52
+
53
+ Returns:
54
+ MagicLink: The newly created magic link instance.
55
+ """
56
+ if getattr(settings, "SOLOMON_ONLY_ONE_LINK_ALLOWED", True):
57
+ self.filter(user=user).update(used=True)
58
+ return self.create(user=user)
59
+
60
+ def get_valid_link(self, token: str) -> "MagicLink":
61
+ """
62
+ Returns the first valid magic link that matches the given token.
63
+
64
+ A valid magic link is identified by a unique token, marked as unused, and
65
+ has an expiration date greater than or equal to the current time. The
66
+ method searches for a link meeting these conditions and returns the first
67
+ result if found.
68
+
69
+ Args:
70
+ token (str): The unique token used to identify the magic link.
71
+
72
+ Returns:
73
+ MagicLink: An instance of the valid MagicLink, or None if no valid link
74
+ is found.
75
+ """
76
+ return self.filter(token=token, used=False, expires_at__gte=timezone.now()).first()
77
+
78
+
79
+ class MagicLink(models.Model):
80
+ """
81
+ Model to store magic links for authentication.
82
+
83
+ This model supports both the standard User model and custom User models.
84
+ """
85
+
86
+ user = models.ForeignKey(
87
+ settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="magic_links", verbose_name=_("User")
88
+ )
89
+ token = models.CharField(_("Token"), max_length=100, unique=True)
90
+ created_at = models.DateTimeField(_("Created at"), auto_now_add=True)
91
+ expires_at = models.DateTimeField(_("Expires at"))
92
+ used = models.BooleanField(_("Used"), default=False)
93
+
94
+ objects = MagicLinkManager()
95
+
96
+ class Meta:
97
+ verbose_name = _("Magic Link")
98
+ verbose_name_plural = _("Magic Links")
99
+ ordering = ["-created_at"]
100
+
101
+ def __str__(self) -> str:
102
+ return f"Magic Link for {self.user}"
103
+
104
+ def save(self, *args: Any, **kwargs: Any) -> None:
105
+ """
106
+ Saves the instance of the object, ensuring that a token and expiration
107
+ time are generated and set if not already provided. The token is created
108
+ using a default token generator, and the expiration time is calculated
109
+ based on a predefined duration from the settings.
110
+ """
111
+ if not self.pk or not self.token:
112
+ self.token = default_token_generator.make_token(self.user)
113
+
114
+ if not self.expires_at:
115
+ expiration_time = getattr(settings, "SOLOMON_LINK_EXPIRATION", 300) # seconds
116
+ self.expires_at = timezone.now() + timedelta(seconds=expiration_time)
117
+
118
+ super().save(*args, **kwargs)
119
+
120
+ @property
121
+ def is_expired(self) -> bool:
122
+ """Check if the magic link has expired."""
123
+ return self.expires_at < timezone.now()
124
+
125
+ @property
126
+ def is_valid(self) -> bool:
127
+ """Check if the magic link is valid (not used and not expired)."""
128
+ return not self.used and not self.is_expired
129
+
130
+ def use(self) -> None:
131
+ """Mark the magic link as used."""
132
+ self.used = True
133
+ self.save(update_fields=["used"])
File without changes
@@ -0,0 +1,16 @@
1
+ {% extends "base.html" %}
2
+ {% load i18n %}
3
+
4
+ {% block title %}{% translate "Invalid Magic Link" %}{% endblock %}
5
+
6
+ {% block content %}
7
+ <div class="container">
8
+ <h1>{% translate "Invalid Magic Link" %}</h1>
9
+ <p>{% translate "The magic link you clicked is invalid or has expired." %}</p>
10
+ <p>{% translate "Please request a new magic link to log in." %}</p>
11
+
12
+ <a href="{% url 'django_solomon:login' %}" class="btn btn-primary">
13
+ {% translate "Request New Magic Link" %}
14
+ </a>
15
+ </div>
16
+ {% endblock content %}
@@ -0,0 +1,17 @@
1
+ {% extends "base.html" %}
2
+ {% load i18n %}
3
+
4
+ {% block title %}{% translate "Magic Link Login" %}{% endblock %}
5
+
6
+ {% block content %}
7
+ <div class="container">
8
+ <h1>{% translate "Magic Link Login" %}</h1>
9
+ <p>{% translate "Enter your email address to receive a magic link for logging in." %}</p>
10
+
11
+ <form method="post">
12
+ {% csrf_token %}
13
+ {{ form.as_p }}
14
+ <button type="submit" class="btn btn-primary">{% translate "Send Magic Link" %}</button>
15
+ </form>
16
+ </div>
17
+ {% endblock content %}
@@ -0,0 +1,12 @@
1
+ {% extends "base.html" %}
2
+ {% load i18n %}
3
+
4
+ {% block title %}{% translate "Magic Link Sent" %}{% endblock %}
5
+
6
+ {% block content %}
7
+ <div class="container">
8
+ <h1>{% translate "Magic Link Sent" %}</h1>
9
+ <p>{% translate "If an account exists with the email address you provided, we've sent a magic link to that address." %}</p>
10
+ <p>{% translate "Please check your email and click the link to log in." %}</p>
11
+ </div>
12
+ {% endblock content %}
@@ -0,0 +1,42 @@
1
+ {% load i18n %}
2
+ <mjml>
3
+ <mj-body>
4
+ <mj-section>
5
+ <mj-column>
6
+ <mj-text>
7
+ <p>{% translate "Hello," %}</p>
8
+
9
+ <p>
10
+ {% blocktranslate %}
11
+ You requested a magic link to log in to your account. Please click the link below
12
+ to log in:
13
+ {% endblocktranslate %}
14
+ </p>
15
+ </mj-text>
16
+
17
+ <mj-button href="{{ magic_link_url }}">{% translate "Click here to log in" %}</mj-button>
18
+
19
+ <mj-text>
20
+ <p>
21
+ {% blocktranslate with expiry_time=expiry_time %}
22
+ This link will expire in {{ expiry_time }} minutes. If you did not
23
+ request this link, you can safely ignore this email.
24
+ {% endblocktranslate %}
25
+ </p>
26
+
27
+ <p>
28
+ {% translate "Thank you," %}
29
+ <br>
30
+ {% translate "The Django Solomon Team" %}
31
+ </p>
32
+
33
+ <p>
34
+ ---
35
+ <br>
36
+ {% translate "This email was sent by Django Solomon." %}
37
+ </p>
38
+ </mj-text>
39
+ </mj-column>
40
+ </mj-section>
41
+ </mj-body>
42
+ </mjml>
@@ -0,0 +1,14 @@
1
+ {% load i18n %}
2
+ {% translate "Hello," %}
3
+
4
+ {% translate "You requested a magic link to log in to your account. Please click the link below to log in:" %}
5
+
6
+ {{ magic_link_url }}
7
+
8
+ {% blocktranslate with expiry_time=expiry_time %}This link will expire in {{ expiry_time }} minutes. If you did not request this link, you can safely ignore this email.{% endblocktranslate %}
9
+
10
+ {% translate "Thank you," %}
11
+ {% translate "The Django Solomon Team" %}
12
+
13
+ ---
14
+ {% translate "This email was sent by Django Solomon." %}
django_solomon/urls.py ADDED
@@ -0,0 +1,23 @@
1
+ from django.urls import path
2
+
3
+ from django_solomon.views import validate_magic_link, magic_link_sent, send_magic_link
4
+
5
+ app_name = "django_solomon"
6
+
7
+ urlpatterns = [
8
+ path(
9
+ "magic-link/",
10
+ send_magic_link,
11
+ name="login",
12
+ ),
13
+ path(
14
+ "magic-link/sent/",
15
+ magic_link_sent,
16
+ name="magic_link_sent",
17
+ ),
18
+ path(
19
+ "magic-link/<str:token>/",
20
+ validate_magic_link,
21
+ name="validate_magic_link",
22
+ ),
23
+ ]
@@ -0,0 +1,81 @@
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)
@@ -0,0 +1,141 @@
1
+ import logging
2
+
3
+ from django.conf import settings
4
+ from django.contrib.auth import get_user_model, authenticate, login
5
+ from django.core.mail import send_mail
6
+ from django.http import HttpRequest, HttpResponse
7
+ from django.shortcuts import redirect, render
8
+ from django.template.loader import render_to_string
9
+ from django.urls import reverse
10
+ from django.utils.translation import gettext_lazy as _
11
+ from mjml import mjml2html
12
+
13
+ from django_solomon.config import (
14
+ get_login_form_template,
15
+ get_invalid_magic_link_template,
16
+ get_magic_link_sent_template,
17
+ get_email_text_template,
18
+ get_email_mjml_template,
19
+ )
20
+ from django_solomon.forms import MagicLinkForm
21
+ from django_solomon.models import BlacklistedEmail, MagicLink
22
+ from django_solomon.utilities import get_user_by_email, create_user_from_email
23
+
24
+ logger = logging.getLogger(__name__)
25
+ User = get_user_model()
26
+
27
+
28
+ def send_magic_link(request: HttpRequest) -> HttpResponse:
29
+ """
30
+ Handles the process of sending a magic link to a user via email. A magic link acts as a
31
+ temporary, secure token allowing the user to authenticate without a password. The endpoint
32
+ validates incoming requests, checks for blacklisted emails, retrieves a user based on the
33
+ submitted email, and sends a magic link if the user exists. Supports POST and GET methods.
34
+
35
+ Args:
36
+ request (HttpRequest): The HTTP request object that includes metadata about the request.
37
+
38
+ Returns:
39
+ HttpResponse: A redirect response to the "magic_link_sent" page or renders the form template
40
+ for sending the magic link.
41
+ """
42
+ if request.method == "POST":
43
+ form = MagicLinkForm(request.POST)
44
+ if form.is_valid():
45
+ email = form.cleaned_data["email"]
46
+
47
+ # Check if the email is blacklisted
48
+ if BlacklistedEmail.objects.filter(email=email).exists():
49
+ logger.info(f"Blocked magic link request for blacklisted email: {email}")
50
+ return redirect("django_solomon:magic_link_sent")
51
+
52
+ # Get user by email
53
+ user = get_user_by_email(email)
54
+
55
+ # Signup unknown users if enabled in settings
56
+ if not user and getattr(settings, "SOLOMON_CREATE_USER_IF_NOT_FOUND", False):
57
+ user = create_user_from_email(email)
58
+
59
+ # Send magic link if user exists
60
+ if user:
61
+ magic_link = MagicLink.objects.create_for_user(user)
62
+ link_url = request.build_absolute_uri(
63
+ reverse(
64
+ "django_solomon:validate_magic_link",
65
+ kwargs={"token": magic_link.token},
66
+ )
67
+ )
68
+ # Calculate expiry time in minutes
69
+ expiry_time = getattr(settings, "SOLOMON_LINK_EXPIRATION", 300) // 60 # Convert seconds to minutes
70
+
71
+ context = {
72
+ "magic_link_url": link_url,
73
+ "expiry_time": expiry_time,
74
+ }
75
+
76
+ text_message = render_to_string(get_email_text_template(), context).strip()
77
+ html_message = mjml2html(render_to_string(get_email_mjml_template(), context))
78
+
79
+ send_mail(
80
+ _("Your Magic Link"),
81
+ text_message,
82
+ getattr(settings, "DEFAULT_FROM_EMAIL", "noreply@example.com"),
83
+ [email],
84
+ fail_silently=False,
85
+ html_message=html_message,
86
+ )
87
+
88
+ return redirect("django_solomon:magic_link_sent")
89
+ else:
90
+ form = MagicLinkForm()
91
+ return render(request, get_login_form_template(), {"form": form})
92
+
93
+
94
+ def magic_link_sent(request: HttpRequest) -> HttpResponse:
95
+ """
96
+ Renders a page that informs the user a magic link has been sent to their email.
97
+
98
+ This view function processes an incoming HTTP request and renders the HTML page
99
+ to indicate that the magic link email was successfully dispatched.
100
+
101
+ Args:
102
+ request: An HttpRequest object representing the user's request.
103
+
104
+ Returns:
105
+ An HttpResponse object with the rendered "magic_link_sent.html" template.
106
+ """
107
+ return render(request, get_magic_link_sent_template())
108
+
109
+
110
+ def validate_magic_link(request: HttpRequest, token: str) -> HttpResponse:
111
+ """
112
+ Validates a magic link for user authentication in a Django application. If the token is valid, the
113
+ user is authenticated and logged in. If the token is invalid or expired, an error message is
114
+ displayed. The function redirects authenticated users to a predetermined success URL.
115
+
116
+ Arguments:
117
+ request: The HTTP request object associated with the current request.
118
+ token: A string representing the token provided in the magic link.
119
+
120
+ Returns:
121
+ HttpResponse: An HTTP response redirecting the user to the success URL upon successful
122
+ authentication, or rendering an error page if authentication fails.
123
+ """
124
+ user = authenticate(request=request, token=token)
125
+
126
+ if not user:
127
+ # Get the error message from the request object, or use a default message
128
+ error_message = getattr(request, "magic_link_error", _("Invalid or expired magic link"))
129
+
130
+ return render(
131
+ request,
132
+ get_invalid_magic_link_template(),
133
+ {"error": error_message},
134
+ )
135
+
136
+ # Log the user in
137
+ login(request, user)
138
+
139
+ # Redirect to the success URL
140
+ success_url = getattr(settings, "SOLOMON_LOGIN_REDIRECT_URL", settings.LOGIN_REDIRECT_URL)
141
+ return redirect(success_url)
@@ -0,0 +1,198 @@
1
+ Metadata-Version: 2.4
2
+ Name: django-solomon
3
+ Version: 0.1.2
4
+ Project-URL: Home, https://django-solomon.rtfd.io/
5
+ Project-URL: Documentation, https://django-solomon.rtfd.io/
6
+ Project-URL: Repository, https://codeberg.org/oliverandrich/django-solomon
7
+ Author-email: Oliver Andrich <oliver@andrich.me>
8
+ License: MIT License
9
+
10
+ Copyright (c) 2025 Oliver Andrich
11
+
12
+ Permission is hereby granted, free of charge, to any person obtaining a copy
13
+ of this software and associated documentation files (the "Software"), to deal
14
+ in the Software without restriction, including without limitation the rights
15
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
16
+ copies of the Software, and to permit persons to whom the Software is
17
+ furnished to do so, subject to the following conditions:
18
+
19
+ The above copyright notice and this permission notice shall be included in all
20
+ copies or substantial portions of the Software.
21
+
22
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
23
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
24
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
25
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
26
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
27
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
28
+ SOFTWARE.
29
+ License-File: LICENSE
30
+ Keywords: django
31
+ Classifier: Development Status :: 3 - Alpha
32
+ Classifier: Environment :: Web Environment
33
+ Classifier: Framework :: Django :: 4.2
34
+ Classifier: Framework :: Django :: 5.0
35
+ Classifier: Framework :: Django :: 5.1
36
+ Classifier: Framework :: Django :: 5.2
37
+ Classifier: Intended Audience :: Developers
38
+ Classifier: License :: OSI Approved :: MIT License
39
+ Classifier: Operating System :: OS Independent
40
+ Classifier: Programming Language :: Python :: 3.10
41
+ Classifier: Programming Language :: Python :: 3.11
42
+ Classifier: Programming Language :: Python :: 3.12
43
+ Classifier: Programming Language :: Python :: 3.13
44
+ Classifier: Topic :: Software Development :: Libraries
45
+ Classifier: Topic :: Utilities
46
+ Requires-Python: >=3.10
47
+ Requires-Dist: django>=4.2
48
+ Requires-Dist: mjml-python>=1.3.5
49
+ Description-Content-Type: text/markdown
50
+
51
+ # django-solomon
52
+
53
+ [![PyPI version](https://img.shields.io/pypi/v/django-solomon.svg)](https://pypi.org/project/django-solomon/)
54
+ [![Python versions](https://img.shields.io/pypi/pyversions/django-solomon.svg)](https://pypi.org/project/django-solomon/)
55
+ [![Django versions](https://img.shields.io/pypi/djversions/django-solomon.svg)](https://pypi.org/project/django-solomon/)
56
+ [![Documentation Status](https://readthedocs.org/projects/django-solomon/badge/?version=latest)](https://django-solomon.rtfd.io/en/latest/?badge=latest)
57
+
58
+ A Django app for passwordless authentication using magic links.
59
+
60
+ ## Features
61
+
62
+ - Passwordless authentication using magic links sent via email
63
+ - Configurable link expiration time
64
+ - Blacklist functionality to block specific email addresses
65
+ - Support for auto-creating users when they request a magic link
66
+ - Customizable templates for emails and pages
67
+ - Compatible with Django's authentication system
68
+
69
+ ## Installation
70
+
71
+ ```bash
72
+ pip install django-solomon
73
+ ```
74
+
75
+ ## Configuration
76
+
77
+ 1. Add `django_solomon` to your `INSTALLED_APPS` in your Django settings:
78
+
79
+ ```python
80
+ INSTALLED_APPS = [
81
+ # ...
82
+ 'django_solomon',
83
+ # ...
84
+ ]
85
+ ```
86
+
87
+ 2. Add the authentication backend to your settings:
88
+
89
+ ```python
90
+ AUTHENTICATION_BACKENDS = [
91
+ 'django_solomon.backends.MagicLinkBackend',
92
+ 'django.contrib.auth.backends.ModelBackend', # Keep the default backend
93
+ ]
94
+ ```
95
+
96
+ 3. Include the django-solomon URLs in your project's `urls.py`:
97
+
98
+ ```python
99
+ from django.urls import include, path
100
+
101
+ urlpatterns = [
102
+ # ...
103
+ path('auth/', include('django_solomon.urls')),
104
+ # ...
105
+ ]
106
+ ```
107
+
108
+ 4. Set the login URL in your settings to use django-solomon's login view:
109
+
110
+ ```python
111
+ LOGIN_URL = 'django_solomon:login'
112
+ ```
113
+
114
+ This ensures that when users need to authenticate, they'll be redirected to the magic link login page.
115
+
116
+ 5. Configure your email settings to ensure emails can be sent:
117
+
118
+ ```python
119
+ EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
120
+ EMAIL_HOST = 'smtp.example.com'
121
+ EMAIL_PORT = 587
122
+ EMAIL_USE_TLS = True
123
+ EMAIL_HOST_USER = 'your-email@example.com'
124
+ EMAIL_HOST_PASSWORD = 'your-password'
125
+ DEFAULT_FROM_EMAIL = 'your-email@example.com'
126
+ ```
127
+
128
+ ## Settings
129
+
130
+ django-solomon provides several settings that you can customize in your Django settings file:
131
+
132
+ | Setting | Default | Description |
133
+ |---------------------------------------|-------------------------------------------------|----------------------------------------------------------------------------------------|
134
+ | `SOLOMON_LINK_EXPIRATION` | `300` | The expiration time for magic links in seconds |
135
+ | `SOLOMON_ONLY_ONE_LINK_ALLOWED` | `True` | If enabled, only one active magic link is allowed per user |
136
+ | `SOLOMON_CREATE_USER_IF_NOT_FOUND` | `False` | If enabled, creates a new user when a magic link is requested for a non-existent email |
137
+ | `SOLOMON_LOGIN_REDIRECT_URL` | `settings.LOGIN_REDIRECT_URL` | The URL to redirect to after successful authentication |
138
+ | `SOLOMON_ALLOW_ADMIN_LOGIN` | `True` | If enabled, allows superusers to log in using magic links |
139
+ | `SOLOMON_ALLOW_STAFF_LOGIN` | `True` | If enabled, allows staff users to log in using magic links |
140
+ | `SOLOMON_MAIL_TEXT_TEMPLATE` | `"django_solomon/email/magic_link.txt"` | The template to use for plain text magic link emails |
141
+ | `SOLOMON_MAIL_MJML_TEMPLATE` | `"django_solomon/email/magic_link.mjml"` | The template to use for HTML magic link emails (MJML format) |
142
+ | `SOLOMON_LOGIN_FORM_TEMPLATE` | `"django_solomon/base/login_form.html"` | The template to use for the login form page |
143
+ | `SOLOMON_INVALID_MAGIC_LINK_TEMPLATE` | `"django_solomon/base/invalid_magic_link.html"` | The template to use for the invalid magic link page |
144
+ | `SOLOMON_MAGIC_LINK_SENT_TEMPLATE` | `"django_solomon/base/magic_link_sent.html"` | The template to use for the magic link sent confirmation page |
145
+
146
+ ## Usage
147
+
148
+ ### Basic Usage
149
+
150
+ 1. Direct users to the magic link request page at `/auth/magic-link/`
151
+ 2. Users enter their email address
152
+ 3. A magic link is sent to their email
153
+ 4. Users click the link in their email
154
+ 5. They are authenticated and redirected to the success URL
155
+
156
+ ### Template Customization
157
+
158
+ You can override the default templates by creating your own versions in your project:
159
+
160
+ - `django_solomon/login_form.html` - The form to request a magic link
161
+ - `django_solomon/magic_link_sent.html` - The confirmation page after a magic link is sent
162
+ - `django_solomon/invalid_magic_link.html` - The error page for invalid magic links
163
+ - `django_solomon/email/magic_link.txt` - The plain text email template for the magic link
164
+ - `django_solomon/email/magic_link.mjml` - The HTML email template for the magic link (in MJML format)
165
+
166
+ Alternatively, you can specify custom templates using the settings variables:
167
+
168
+ - `SOLOMON_LOGIN_FORM_TEMPLATE` - Custom template for the login form
169
+ - `SOLOMON_MAGIC_LINK_SENT_TEMPLATE` - Custom template for the confirmation page
170
+ - `SOLOMON_INVALID_MAGIC_LINK_TEMPLATE` - Custom template for the error page
171
+ - `SOLOMON_MAIL_TEXT_TEMPLATE` - Custom template for the plain text email
172
+ - `SOLOMON_MAIL_MJML_TEMPLATE` - Custom template for the HTML email (MJML format)
173
+
174
+ ### Programmatic Usage
175
+
176
+ You can also use django-solomon programmatically in your views:
177
+
178
+ ```python
179
+ from django.contrib.auth import authenticate, login
180
+ from django_solomon.models import MagicLink
181
+
182
+ # Create a magic link for a user
183
+ magic_link = MagicLink.objects.create_for_user(user)
184
+
185
+ # Authenticate a user with a token
186
+ user = authenticate(request=request, token=token)
187
+ if user:
188
+ login(request, user)
189
+ ```
190
+
191
+ ## License
192
+
193
+ This software is licensed under [MIT license](./LICENSE).
194
+
195
+ ## Contributing
196
+
197
+ Contributions are welcome! Please feel free to submit a Pull Request
198
+ on [Codeberg](https://codeberg.org/oliverandrich/django-solomon).
@@ -0,0 +1,26 @@
1
+ django_solomon/__init__.py,sha256=4GdjeQVyChzdc7pZ1jrpknjcnu9Do_0nSh42UZNICKo,80
2
+ django_solomon/admin.py,sha256=hwesCOQgUDDHPh5qq97AObfSyMgIOKsdzoa69naMPx4,1428
3
+ django_solomon/apps.py,sha256=glfjdSSzrxuwJ2FrzuF2C4oFNqikMqhSHHmca2qq5Vg,159
4
+ django_solomon/backends.py,sha256=A_TTNaduRmZOZF-R0C1yhs9QpXejBCRCz7wDcp_BupY,2403
5
+ django_solomon/config.py,sha256=JEl3cY0BnfC9ja0ZVeLmfIwUStbUAO6vRhGZrrig5Yw,787
6
+ django_solomon/forms.py,sha256=ymDsZ-KWyJfo12vGbZCxIhIvVket93xYR4MgDif4fh0,312
7
+ django_solomon/models.py,sha256=NRsN-F7Vnfx-l5cJgjAJqPhbhRIeIctcRh4GeuysUZA,4771
8
+ django_solomon/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
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
12
+ django_solomon/locale/de/LC_MESSAGES/django.po,sha256=6lo72lsDV1bKBVowiRsQoaVKH5Mbkf34z78sG1LEYq4,747
13
+ django_solomon/locale/en/LC_MESSAGES/django.po,sha256=6MjGCWQn-1AjOnP_gXtLa0Oad4qfNAn5tXdhnw_wEcg,732
14
+ django_solomon/management/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
15
+ django_solomon/management/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
16
+ django_solomon/migrations/0001_initial.py,sha256=i-UhR1KQ4p7WmbDg9xUQfMmDo8yYdIigvHNsefLHJEc,1981
17
+ django_solomon/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
18
+ django_solomon/templates/django_solomon/base/invalid_magic_link.html,sha256=3U4T2n4uvPvktiOOHYVyNmk0aXFfE4DPrURE1DinWKk,554
19
+ django_solomon/templates/django_solomon/base/login_form.html,sha256=VYGnrno3c36W9f04XdRqJpjsDfOy0sq5D_cLayPz8Q0,546
20
+ django_solomon/templates/django_solomon/base/magic_link_sent.html,sha256=GIMxnw3E98TXVkVQkMRTHmX5mz0xUsvgXVj25gO2HPQ,461
21
+ django_solomon/templates/django_solomon/email/magic_link.mjml,sha256=OnfVGm2yFrOmoQ1syo6-Duq_Qraf3Tv0Vy5oidvt55g,1427
22
+ django_solomon/templates/django_solomon/email/magic_link.txt,sha256=yl01eie3U2HkFEJvB1Qlm8Z6FPuop5yubDXFY5V507Q,502
23
+ django_solomon-0.1.2.dist-info/METADATA,sha256=C-MpFkZUrPXXrKgQJO1Fs1WW5rIXXUXGcfw1RpeGEBU,8902
24
+ django_solomon-0.1.2.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
25
+ django_solomon-0.1.2.dist-info/licenses/LICENSE,sha256=tbfFOFdH5mHIfmNGo6KGOC3U8f-hTCLfetcr8A89WwI,1071
26
+ django_solomon-0.1.2.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Oliver Andrich
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.