-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
65b8b41
commit b40c456
Showing
14 changed files
with
541 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
# plain.loginlink | ||
|
||
Link-based authentication for Plain. |
Empty file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
18
plain-loginlink/plain/loginlink/templates/loginlink/failed.html
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
9
plain-loginlink/plain/loginlink/templates/loginlink/sent.html
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 %} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"), | ||
] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |
Oops, something went wrong.