From b40c45641fdd76c2ebd67c6631c2ccf492359e60 Mon Sep 17 00:00:00 2001 From: Dave Gaeddert Date: Thu, 26 Sep 2024 14:34:01 -0500 Subject: [PATCH] Add plain-loginlink --- plain-loginlink/LICENSE | 30 ++++ plain-loginlink/README.md | 5 + plain-loginlink/plain/loginlink/README.md | 3 + plain-loginlink/plain/loginlink/__init__.py | 0 plain-loginlink/plain/loginlink/forms.py | 39 +++++ plain-loginlink/plain/loginlink/links.py | 48 +++++ plain-loginlink/plain/loginlink/signing.py | 121 +++++++++++++ .../loginlink/templates/loginlink/failed.html | 18 ++ .../loginlink/templates/loginlink/sent.html | 9 + .../loginlink/templates/mail/loginlink.html | 1 + plain-loginlink/plain/loginlink/urls.py | 12 ++ plain-loginlink/plain/loginlink/views.py | 68 ++++++++ plain-loginlink/poetry.lock | 165 ++++++++++++++++++ plain-loginlink/pyproject.toml | 22 +++ 14 files changed, 541 insertions(+) create mode 100644 plain-loginlink/LICENSE create mode 100644 plain-loginlink/README.md create mode 100644 plain-loginlink/plain/loginlink/README.md create mode 100644 plain-loginlink/plain/loginlink/__init__.py create mode 100644 plain-loginlink/plain/loginlink/forms.py create mode 100644 plain-loginlink/plain/loginlink/links.py create mode 100644 plain-loginlink/plain/loginlink/signing.py create mode 100644 plain-loginlink/plain/loginlink/templates/loginlink/failed.html create mode 100644 plain-loginlink/plain/loginlink/templates/loginlink/sent.html create mode 100644 plain-loginlink/plain/loginlink/templates/mail/loginlink.html create mode 100644 plain-loginlink/plain/loginlink/urls.py create mode 100644 plain-loginlink/plain/loginlink/views.py create mode 100644 plain-loginlink/poetry.lock create mode 100644 plain-loginlink/pyproject.toml diff --git a/plain-loginlink/LICENSE b/plain-loginlink/LICENSE new file mode 100644 index 0000000000..d69fc16ac3 --- /dev/null +++ b/plain-loginlink/LICENSE @@ -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. diff --git a/plain-loginlink/README.md b/plain-loginlink/README.md new file mode 100644 index 0000000000..bb1b50e1ef --- /dev/null +++ b/plain-loginlink/README.md @@ -0,0 +1,5 @@ + + +# plain.loginlink + +Link-based authentication for Plain. diff --git a/plain-loginlink/plain/loginlink/README.md b/plain-loginlink/plain/loginlink/README.md new file mode 100644 index 0000000000..1a7b31bf48 --- /dev/null +++ b/plain-loginlink/plain/loginlink/README.md @@ -0,0 +1,3 @@ +# plain.loginlink + +Link-based authentication for Plain. diff --git a/plain-loginlink/plain/loginlink/__init__.py b/plain-loginlink/plain/loginlink/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/plain-loginlink/plain/loginlink/forms.py b/plain-loginlink/plain/loginlink/forms.py new file mode 100644 index 0000000000..ad885cbb27 --- /dev/null +++ b/plain-loginlink/plain/loginlink/forms.py @@ -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, + ) diff --git a/plain-loginlink/plain/loginlink/links.py b/plain-loginlink/plain/loginlink/links.py new file mode 100644 index 0000000000..18cfbe6f56 --- /dev/null +++ b/plain-loginlink/plain/loginlink/links.py @@ -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() diff --git a/plain-loginlink/plain/loginlink/signing.py b/plain-loginlink/plain/loginlink/signing.py new file mode 100644 index 0000000000..9575366aa8 --- /dev/null +++ b/plain-loginlink/plain/loginlink/signing.py @@ -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, + ) diff --git a/plain-loginlink/plain/loginlink/templates/loginlink/failed.html b/plain-loginlink/plain/loginlink/templates/loginlink/failed.html new file mode 100644 index 0000000000..9457037338 --- /dev/null +++ b/plain-loginlink/plain/loginlink/templates/loginlink/failed.html @@ -0,0 +1,18 @@ +{% extends "base.html" %} + +{% block content %} +
+ {% if error == "expired" %} +

Link Expired

+ {% elif error == "invalid" %} +

Link Invalid

+ {% elif error == "changed" %} +

Link Changed

+ {% else %} +

Link Error

+ {% endif %} + + Request a new link → + +
+{% endblock %} diff --git a/plain-loginlink/plain/loginlink/templates/loginlink/sent.html b/plain-loginlink/plain/loginlink/templates/loginlink/sent.html new file mode 100644 index 0000000000..0ad4abca20 --- /dev/null +++ b/plain-loginlink/plain/loginlink/templates/loginlink/sent.html @@ -0,0 +1,9 @@ +{% extends "base.html" %} + +{% block content %} +
+

Check your email

+

If your email address was found, then we emailed you a link to log in.

+

If you don't see it, check your spam folder.

+
+{% endblock %} diff --git a/plain-loginlink/plain/loginlink/templates/mail/loginlink.html b/plain-loginlink/plain/loginlink/templates/mail/loginlink.html new file mode 100644 index 0000000000..056cfec2f2 --- /dev/null +++ b/plain-loginlink/plain/loginlink/templates/mail/loginlink.html @@ -0,0 +1 @@ +

Here is your link to log in: {{ url }}

diff --git a/plain-loginlink/plain/loginlink/urls.py b/plain-loginlink/plain/loginlink/urls.py new file mode 100644 index 0000000000..c5de44ed48 --- /dev/null +++ b/plain-loginlink/plain/loginlink/urls.py @@ -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//", views.LoginLinkLoginView.as_view(), name="login"), +] diff --git a/plain-loginlink/plain/loginlink/views.py b/plain-loginlink/plain/loginlink/views.py new file mode 100644 index 0000000000..35866c1c1a --- /dev/null +++ b/plain-loginlink/plain/loginlink/views.py @@ -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) diff --git a/plain-loginlink/poetry.lock b/plain-loginlink/poetry.lock new file mode 100644 index 0000000000..c441304bb8 --- /dev/null +++ b/plain-loginlink/poetry.lock @@ -0,0 +1,165 @@ +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. + +[[package]] +name = "click" +version = "8.1.7" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.7" +files = [ + {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, + {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "jinja2" +version = "3.1.4" +description = "A very fast and expressive template engine." +optional = false +python-versions = ">=3.7" +files = [ + {file = "jinja2-3.1.4-py3-none-any.whl", hash = "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d"}, + {file = "jinja2-3.1.4.tar.gz", hash = "sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369"}, +] + +[package.dependencies] +MarkupSafe = ">=2.0" + +[package.extras] +i18n = ["Babel (>=2.7)"] + +[[package]] +name = "markupsafe" +version = "2.1.5" +description = "Safely add untrusted strings to HTML/XML markup." +optional = false +python-versions = ">=3.7" +files = [ + {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a17a92de5231666cfbe003f0e4b9b3a7ae3afb1ec2845aadc2bacc93ff85febc"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:72b6be590cc35924b02c78ef34b467da4ba07e4e0f0454a2c5907f473fc50ce5"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61659ba32cf2cf1481e575d0462554625196a1f2fc06a1c777d3f48e8865d46"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2174c595a0d73a3080ca3257b40096db99799265e1c27cc5a610743acd86d62f"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae2ad8ae6ebee9d2d94b17fb62763125f3f374c25618198f40cbb8b525411900"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:075202fa5b72c86ad32dc7d0b56024ebdbcf2048c0ba09f1cde31bfdd57bcfff"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:598e3276b64aff0e7b3451b72e94fa3c238d452e7ddcd893c3ab324717456bad"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fce659a462a1be54d2ffcacea5e3ba2d74daa74f30f5f143fe0c58636e355fdd"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-win32.whl", hash = "sha256:d9fad5155d72433c921b782e58892377c44bd6252b5af2f67f16b194987338a4"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-win_amd64.whl", hash = "sha256:bf50cd79a75d181c9181df03572cdce0fbb75cc353bc350712073108cba98de5"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:629ddd2ca402ae6dbedfceeba9c46d5f7b2a61d9749597d4307f943ef198fc1f"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5b7b716f97b52c5a14bffdf688f971b2d5ef4029127f1ad7a513973cfd818df2"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ec585f69cec0aa07d945b20805be741395e28ac1627333b1c5b0105962ffced"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b91c037585eba9095565a3556f611e3cbfaa42ca1e865f7b8015fe5c7336d5a5"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7502934a33b54030eaf1194c21c692a534196063db72176b0c4028e140f8f32c"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0e397ac966fdf721b2c528cf028494e86172b4feba51d65f81ffd65c63798f3f"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c061bb86a71b42465156a3ee7bd58c8c2ceacdbeb95d05a99893e08b8467359a"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3a57fdd7ce31c7ff06cdfbf31dafa96cc533c21e443d57f5b1ecc6cdc668ec7f"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-win32.whl", hash = "sha256:397081c1a0bfb5124355710fe79478cdbeb39626492b15d399526ae53422b906"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-win_amd64.whl", hash = "sha256:2b7c57a4dfc4f16f7142221afe5ba4e093e09e728ca65c51f5620c9aaeb9a617"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:8dec4936e9c3100156f8a2dc89c4b88d5c435175ff03413b443469c7c8c5f4d1"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:3c6b973f22eb18a789b1460b4b91bf04ae3f0c4234a0a6aa6b0a92f6f7b951d4"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac07bad82163452a6884fe8fa0963fb98c2346ba78d779ec06bd7a6262132aee"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5dfb42c4604dddc8e4305050aa6deb084540643ed5804d7455b5df8fe16f5e5"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea3d8a3d18833cf4304cd2fc9cbb1efe188ca9b5efef2bdac7adc20594a0e46b"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d050b3361367a06d752db6ead6e7edeb0009be66bc3bae0ee9d97fb326badc2a"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bec0a414d016ac1a18862a519e54b2fd0fc8bbfd6890376898a6c0891dd82e9f"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:58c98fee265677f63a4385256a6d7683ab1832f3ddd1e66fe948d5880c21a169"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-win32.whl", hash = "sha256:8590b4ae07a35970728874632fed7bd57b26b0102df2d2b233b6d9d82f6c62ad"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-win_amd64.whl", hash = "sha256:823b65d8706e32ad2df51ed89496147a42a2a6e01c13cfb6ffb8b1e92bc910bb"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c8b29db45f8fe46ad280a7294f5c3ec36dbac9491f2d1c17345be8e69cc5928f"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec6a563cff360b50eed26f13adc43e61bc0c04d94b8be985e6fb24b81f6dcfdf"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a549b9c31bec33820e885335b451286e2969a2d9e24879f83fe904a5ce59d70a"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4f11aa001c540f62c6166c7726f71f7573b52c68c31f014c25cc7901deea0b52"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:7b2e5a267c855eea6b4283940daa6e88a285f5f2a67f2220203786dfa59b37e9"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:2d2d793e36e230fd32babe143b04cec8a8b3eb8a3122d2aceb4a371e6b09b8df"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:ce409136744f6521e39fd8e2a24c53fa18ad67aa5bc7c2cf83645cce5b5c4e50"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-win32.whl", hash = "sha256:4096e9de5c6fdf43fb4f04c26fb114f61ef0bf2e5604b6ee3019d51b69e8c371"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-win_amd64.whl", hash = "sha256:4275d846e41ecefa46e2015117a9f491e57a71ddd59bbead77e904dc02b1bed2"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:656f7526c69fac7f600bd1f400991cc282b417d17539a1b228617081106feb4a"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:97cafb1f3cbcd3fd2b6fbfb99ae11cdb14deea0736fc2b0952ee177f2b813a46"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f3fbcb7ef1f16e48246f704ab79d79da8a46891e2da03f8783a5b6fa41a9532"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa9db3f79de01457b03d4f01b34cf91bc0048eb2c3846ff26f66687c2f6d16ab"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffee1f21e5ef0d712f9033568f8344d5da8cc2869dbd08d87c84656e6a2d2f68"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:5dedb4db619ba5a2787a94d877bc8ffc0566f92a01c0ef214865e54ecc9ee5e0"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:30b600cf0a7ac9234b2638fbc0fb6158ba5bdcdf46aeb631ead21248b9affbc4"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8dd717634f5a044f860435c1d8c16a270ddf0ef8588d4887037c5028b859b0c3"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-win32.whl", hash = "sha256:daa4ee5a243f0f20d528d939d06670a298dd39b1ad5f8a72a4275124a7819eff"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-win_amd64.whl", hash = "sha256:619bc166c4f2de5caa5a633b8b7326fbe98e0ccbfacabd87268a2b15ff73a029"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7a68b554d356a91cce1236aa7682dc01df0edba8d043fd1ce607c49dd3c1edcf"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:db0b55e0f3cc0be60c1f19efdde9a637c32740486004f20d1cff53c3c0ece4d2"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e53af139f8579a6d5f7b76549125f0d94d7e630761a2111bc431fd820e163b8"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17b950fccb810b3293638215058e432159d2b71005c74371d784862b7e4683f3"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c31f53cdae6ecfa91a77820e8b151dba54ab528ba65dfd235c80b086d68a465"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:bff1b4290a66b490a2f4719358c0cdcd9bafb6b8f061e45c7a2460866bf50c2e"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:bc1667f8b83f48511b94671e0e441401371dfd0f0a795c7daa4a3cd1dde55bea"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5049256f536511ee3f7e1b3f87d1d1209d327e818e6ae1365e8653d7e3abb6a6"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-win32.whl", hash = "sha256:00e046b6dd71aa03a41079792f8473dc494d564611a8f89bbbd7cb93295ebdcf"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-win_amd64.whl", hash = "sha256:fa173ec60341d6bb97a89f5ea19c85c5643c1e7dedebc22f5181eb73573142c5"}, + {file = "MarkupSafe-2.1.5.tar.gz", hash = "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b"}, +] + +[[package]] +name = "plain" +version = "0.5.0" +description = "A web framework for building products with Python." +optional = false +python-versions = "^3.11" +files = [] +develop = true + +[package.dependencies] +click = ">=8.0.0" +jinja2 = "^3.1.2" +python-dotenv = "^1.0.0" + +[package.source] +type = "directory" +url = "../plain" + +[[package]] +name = "plain-mail" +version = "0.3.0" +description = "Email sending for Plain." +optional = false +python-versions = "^3.11" +files = [] +develop = true + +[package.dependencies] +plain = "<1.0.0" + +[package.source] +type = "directory" +url = "../plain-mail" + +[[package]] +name = "python-dotenv" +version = "1.0.1" +description = "Read key-value pairs from a .env file and set them as environment variables" +optional = false +python-versions = ">=3.8" +files = [ + {file = "python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca"}, + {file = "python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a"}, +] + +[package.extras] +cli = ["click (>=5.0)"] + +[metadata] +lock-version = "2.0" +python-versions = "^3.11" +content-hash = "7cf99e8af0fd3d91c491d988269c55c0d0d2ca0c790a9fab991f53d0cd625e02" diff --git a/plain-loginlink/pyproject.toml b/plain-loginlink/pyproject.toml new file mode 100644 index 0000000000..dca6c7f835 --- /dev/null +++ b/plain-loginlink/pyproject.toml @@ -0,0 +1,22 @@ +[tool.poetry] +name = "plain.loginlink" +packages = [ + { include = "plain" }, +] +version = "0.1.0" +description = "Emailed link-based login for Plain." +authors = ["Dave Gaeddert "] +# readme = "README.md" + +[tool.poetry.dependencies] +python = "^3.11" +plain = "<1.0.0" +plain-mail = "<1.0.0" + +[tool.poetry.group.dev.dependencies] +plain = { path = "../plain", develop = true } +plain-mail = { path = "../plain-mail", develop = true } + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api"