Skip to content

Commit

Permalink
Add plain-loginlink
Browse files Browse the repository at this point in the history
  • Loading branch information
davegaeddert committed Sep 26, 2024
1 parent 65b8b41 commit b40c456
Show file tree
Hide file tree
Showing 14 changed files with 541 additions and 0 deletions.
30 changes: 30 additions & 0 deletions plain-loginlink/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
## Plain is released under the BSD 3-Clause License

BSD 3-Clause License

Copyright (c) 2023, Dropseed, LLC

Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:

1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.

2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.

3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
5 changes: 5 additions & 0 deletions plain-loginlink/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<!-- This file is compiled from plain-loginlink/plain/loginlink/README.md. Do not edit this file directly. -->

# plain.loginlink

Link-based authentication for Plain.
3 changes: 3 additions & 0 deletions plain-loginlink/plain/loginlink/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# plain.loginlink

Link-based authentication for Plain.
Empty file.
39 changes: 39 additions & 0 deletions plain-loginlink/plain/loginlink/forms.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
from plain import forms
from plain.auth import get_user_model
from plain.mail import TemplateEmail

from .links import generate_link_url


class LoginLinkForm(forms.Form):
email = forms.EmailField()

def maybe_send_link(self, request, expires_in=60 * 60):
user_model = get_user_model()
email = self.cleaned_data["email"]
try:
user = user_model.objects.get(email__iexact=email)
except user_model.DoesNotExist:
user = None

if user:
url = generate_link_url(
request=request, user=user, email=email, expires_in=expires_in
)
email = self.get_email_template(
email=email, context={"user": user, "url": url}
)
return email.send()

def get_email_template(self, *, email, context):
"""
Create the TemplateEmail object
Override this if you want to change the subject, template, etc.
"""
return TemplateEmail(
template="loginlink",
subject="Your link to log in",
to=[email],
context=context,
)
48 changes: 48 additions & 0 deletions plain-loginlink/plain/loginlink/links.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
from plain.auth import get_user_model
from plain.signing import BadSignature, SignatureExpired
from plain.urls import reverse

from . import signing


class LoginLinkExpired(Exception):
pass


class LoginLinkInvalid(Exception):
pass


class LoginLinkChanged(Exception):
pass


def generate_link_url(*, request, user, email, expires_in):
"""
Generate a login link using both the user's PK
and email address, so links break if the user email changes or is assigned to another user.
"""
token = signing.dumps({"user_pk": user.pk, "email": email}, expires_in=expires_in)

return request.build_absolute_uri(reverse("loginlink:login", args=[token]))


def get_link_token_user(token):
"""
Validate a link token and get the user from it.
"""
try:
signed_data = signing.loads(token)
except SignatureExpired:
raise LoginLinkExpired()
except BadSignature:
raise LoginLinkInvalid()

user_model = get_user_model()
user_pk = signed_data["user_pk"]
email = signed_data["email"]

try:
return user_model.objects.get(pk=user_pk, email__iexact=email)
except user_model.DoesNotExist:
raise LoginLinkChanged()
121 changes: 121 additions & 0 deletions plain-loginlink/plain/loginlink/signing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import time
import zlib

from plain.signing import (
JSONSerializer,
SignatureExpired,
Signer,
b62_decode,
b62_encode,
b64_decode,
b64_encode,
)


class ExpiringSigner(Signer):
"""A signer with an embedded expiration (vs max age unsign)"""

def sign(self, value, expires_in):
timestamp = b62_encode(int(time.time() + expires_in))
value = f"{value}{self.sep}{timestamp}"
return super().sign(value)

def unsign(self, value):
"""
Retrieve original value and check the expiration hasn't passed.
"""
result = super().unsign(value)
value, timestamp = result.rsplit(self.sep, 1)
timestamp = b62_decode(timestamp)
if timestamp < time.time():
raise SignatureExpired("Signature expired")
return value

def sign_object(
self, obj, serializer=JSONSerializer, compress=False, expires_in=None
):
"""
Return URL-safe, hmac signed base64 compressed JSON string.
If compress is True (not the default), check if compressing using zlib
can save some space. Prepend a '.' to signify compression. This is
included in the signature, to protect against zip bombs.
The serializer is expected to return a bytestring.
"""
data = serializer().dumps(obj)
# Flag for if it's been compressed or not.
is_compressed = False

if compress:
# Avoid zlib dependency unless compress is being used.
compressed = zlib.compress(data)
if len(compressed) < (len(data) - 1):
data = compressed
is_compressed = True
base64d = b64_encode(data).decode()
if is_compressed:
base64d = "." + base64d
return self.sign(base64d, expires_in)

def unsign_object(self, signed_obj, serializer=JSONSerializer):
# Signer.unsign() returns str but base64 and zlib compression operate
# on bytes.
base64d = self.unsign(signed_obj).encode()
decompress = base64d[:1] == b"."
if decompress:
# It's compressed; uncompress it first.
base64d = base64d[1:]
data = b64_decode(base64d)
if decompress:
data = zlib.decompress(data)
return serializer().loads(data)


def dumps(
obj,
key=None,
salt="plain.loginlink",
serializer=JSONSerializer,
compress=False,
expires_in=None,
):
"""
Return URL-safe, hmac signed base64 compressed JSON string. If key is
None, use settings.SECRET_KEY instead. The hmac algorithm is the default
Signer algorithm.
If compress is True (not the default), check if compressing using zlib can
save some space. Prepend a '.' to signify compression. This is included
in the signature, to protect against zip bombs.
Salt can be used to namespace the hash, so that a signed string is
only valid for a given namespace. Leaving this at the default
value or re-using a salt value across different parts of your
application without good cause is a security risk.
The serializer is expected to return a bytestring.
"""
return ExpiringSigner(key=key, salt=salt).sign_object(
obj, serializer=serializer, compress=compress, expires_in=expires_in
)


def loads(
s,
key=None,
salt="plain.loginlink",
serializer=JSONSerializer,
fallback_keys=None,
):
"""
Reverse of dumps(), raise BadSignature if signature fails.
The serializer is expected to accept a bytestring.
"""
return ExpiringSigner(
key=key, salt=salt, fallback_keys=fallback_keys
).unsign_object(
s,
serializer=serializer,
)
18 changes: 18 additions & 0 deletions plain-loginlink/plain/loginlink/templates/loginlink/failed.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{% extends "base.html" %}

{% block content %}
<div class="m-auto text-center">
{% if error == "expired" %}
<h1 class="text-4xl">Link Expired</h1>
{% elif error == "invalid" %}
<h1 class="text-4xl">Link Invalid</h1>
{% elif error == "changed" %}
<h1 class="text-4xl">Link Changed</h1>
{% else %}
<h1 class="text-4xl">Link Error</h1>
{% endif %}

<a href="{{ login_url }}" class="inline-block mt-4 text-center text-blue-600 hover:underline">Request a new link →</a>

</div>
{% endblock %}
9 changes: 9 additions & 0 deletions plain-loginlink/plain/loginlink/templates/loginlink/sent.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{% extends "base.html" %}

{% block content %}
<div class="m-auto text-center">
<h1 class="text-4xl">Check your email</h1>
<p class="mt-4">If your email address was found, then we emailed you a link to log in.</p>
<p class="mt-4">If you don't see it, check your spam folder.</p>
</div>
{% endblock %}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<p>Here is your link to log in: <a href="{{ url }}">{{ url }}</a></p>
12 changes: 12 additions & 0 deletions plain-loginlink/plain/loginlink/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
from plain.urls import path

from . import views

default_namespace = "loginlink"


urlpatterns = [
path("sent/", views.LoginLinkSentView.as_view(), name="sent"),
path("failed/", views.LoginLinkFailedView.as_view(), name="failed"),
path("login/<str:token>/", views.LoginLinkLoginView.as_view(), name="login"),
]
68 changes: 68 additions & 0 deletions plain-loginlink/plain/loginlink/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
from plain.auth import login, logout
from plain.exceptions import ValidationError
from plain.http import ResponseRedirect
from plain.urls import reverse, reverse_lazy
from plain.views import FormView, View, TemplateView
from plain.runtime import settings

from .forms import LoginLinkForm
from .links import (
LoginLinkChanged,
LoginLinkExpired,
LoginLinkInvalid,
get_link_token_user,
)


class LoginLinkFormView(FormView):
form_class = LoginLinkForm
success_url = reverse_lazy("loginlink:sent")

def get(self):
# Redirect if the user is already logged in
if self.request.user:
return ResponseRedirect(self.success_url)

return super().get()

def form_valid(self, form):
form.maybe_send_link(self.request)
return super().form_valid(form)


class LoginLinkSentView(TemplateView):
template_name = "loginlink/sent.html"


class LoginLinkFailedView(TemplateView):
template_name = "loginlink/failed.html"

def get_template_context(self):
context = super().get_template_context()
context["error"] = self.request.GET.get("error")
context["login_url"] = reverse(settings.AUTH_LOGIN_URL)
return context


class LoginLinkLoginView(View):
success_url = "/"

def get(self):
# If they're logged in, log them out and process the link again
if self.request.user:
logout(self.request)

token = self.url_kwargs["token"]

try:
user = get_link_token_user(token)
except LoginLinkExpired:
return ResponseRedirect(reverse("loginlink:failed") + "?error=expired")
except LoginLinkInvalid:
return ResponseRedirect(reverse("loginlink:failed") + "?error=invalid")
except LoginLinkChanged:
return ResponseRedirect(reverse("loginlink:failed") + "?error=changed")

login(self.request, user)

return ResponseRedirect(self.success_url)
Loading

0 comments on commit b40c456

Please sign in to comment.