Add signup and password reset

This commit is contained in:
Andrew Godwin 2022-11-17 19:16:34 -07:00
parent 2a3690d1c1
commit 6adfdbabe0
22 changed files with 435 additions and 18 deletions

View File

@ -0,0 +1,28 @@
# Generated by Django 4.1.3 on 2022-11-18 01:40
import functools
from django.db import migrations, models
import core.uploads
class Migration(migrations.Migration):
dependencies = [
("activities", "0008_postattachment"),
]
operations = [
migrations.AlterField(
model_name="postattachment",
name="file",
field=models.FileField(
blank=True,
null=True,
upload_to=functools.partial(
core.uploads.upload_namer, *("attachments",), **{}
),
),
),
]

View File

@ -9,4 +9,7 @@ class CoreConfig(AppConfig):
name = "core" name = "core"
def ready(self) -> None: def ready(self) -> None:
from core.models import Config
Config.system = Config.load_system()
jsonld.set_document_loader(builtin_document_loader) jsonld.set_document_loader(builtin_document_loader)

View File

@ -3,7 +3,7 @@ from core.models import Config
def config_context(request): def config_context(request):
return { return {
"config": Config.load_system(), "config": Config.system,
"config_identity": ( "config_identity": (
Config.load_identity(request.identity) if request.identity else None Config.load_identity(request.identity) if request.identity else None
), ),

View File

@ -0,0 +1,28 @@
# Generated by Django 4.1.3 on 2022-11-18 01:40
import functools
from django.db import migrations, models
import core.uploads
class Migration(migrations.Migration):
dependencies = [
("core", "0001_initial"),
]
operations = [
migrations.AlterField(
model_name="config",
name="image",
field=models.ImageField(
blank=True,
null=True,
upload_to=functools.partial(
core.uploads.upload_namer, *("config",), **{}
),
),
),
]

View File

@ -5,7 +5,6 @@ import pydantic
from django.core.files import File from django.core.files import File
from django.db import models from django.db import models
from django.templatetags.static import static from django.templatetags.static import static
from django.utils.functional import classproperty
from core.uploads import upload_namer from core.uploads import upload_namer
from takahe import __version__ from takahe import __version__
@ -54,11 +53,6 @@ class Config(models.Model):
("key", "user", "identity"), ("key", "user", "identity"),
] ]
@classproperty
def system(cls):
cls.system = cls.load_system()
return cls.system
system: ClassVar["Config.ConfigOptions"] # type: ignore system: ClassVar["Config.ConfigOptions"] # type: ignore
@classmethod @classmethod
@ -160,13 +154,16 @@ class Config(models.Model):
version: str = __version__ version: str = __version__
site_name: str = "takahē" site_name: str = "Takahē"
highlight_color: str = "#449c8c" highlight_color: str = "#449c8c"
site_about: str = "<h2>Welcome!</h2>\n\nThis is a community running Takahē." site_about: str = "<h2>Welcome!</h2>\n\nThis is a community running Takahē."
site_icon: UploadedImage = static("img/icon-128.png") site_icon: UploadedImage = static("img/icon-128.png")
site_banner: UploadedImage = static("img/fjords-banner-600.jpg") site_banner: UploadedImage = static("img/fjords-banner-600.jpg")
allow_signups: bool = False
post_length: int = 500 post_length: int = 500
identity_max_per_user: int = 5
identity_max_age: int = 24 * 60 * 60 identity_max_age: int = 24 * 60 * 60
class UserOptions(pydantic.BaseModel): class UserOptions(pydantic.BaseModel):

View File

@ -136,6 +136,7 @@ header .logo {
font-weight: bold; font-weight: bold;
background: var(--color-highlight); background: var(--color-highlight);
border-radius: 5px 0 0 0; border-radius: 5px 0 0 0;
text-transform: lowercase;
padding: 10px 11px 9px 10px; padding: 10px 11px 9px 10px;
height: 50px; height: 50px;
font-size: 130%; font-size: 130%;
@ -198,6 +199,12 @@ header menu a.identity {
width: 250px; width: 250px;
} }
header menu a.identity i {
display: inline-block;
vertical-align: middle;
padding: 0 7px 2px 0;
}
header menu a img { header menu a img {
display: inline-block; display: inline-block;
vertical-align: middle; vertical-align: middle;
@ -287,7 +294,7 @@ nav a i {
/* Icon menus */ /* Icon menus */
.icon-menu>a { .icon-menu .option {
display: block; display: block;
margin: 0px 0 20px 0; margin: 0px 0 20px 0;
background: var(--color-bg-box); background: var(--color-bg-box);
@ -299,19 +306,28 @@ nav a i {
border-radius: 3px; border-radius: 3px;
} }
.icon-menu>a:hover { .icon-menu .option:hover {
border: 2px solid var(--color-highlight); border: 2px solid var(--color-highlight);
} }
.icon-menu>a img, .icon-menu .option.empty {
.icon-menu>a i { color: var(--color-text-dull);
}
.icon-menu .option.empty:hover {
border: 0;
border: 2px solid rgba(255, 255, 255, 0);
}
.icon-menu .option img,
.icon-menu .option i {
vertical-align: middle; vertical-align: middle;
margin: 0 10px 3px 0; margin: 0 10px 3px 0;
height: 50px; height: 50px;
width: auto; width: auto;
} }
.icon-menu>a i { .icon-menu .option i {
display: inline-block; display: inline-block;
text-align: center; text-align: center;
width: 50px; width: 50px;

View File

@ -108,6 +108,11 @@ STATICFILES_DIRS = [
ALLOWED_HOSTS = ["*"] ALLOWED_HOSTS = ["*"]
MAIN_DOMAIN = os.environ["TAKAHE_MAIN_DOMAIN"]
if "/" in MAIN_DOMAIN:
print("TAKAHE_MAIN_DOMAIN should be just the domain name - no https:// or path")
EMAIL_FROM = os.environ["TAKAHE_EMAIL_FROM"]
# Note that this MUST be a fully qualified URL in production # Note that this MUST be a fully qualified URL in production
MEDIA_URL = os.environ.get("TAKAHE_MEDIA_URL", "/media/") MEDIA_URL = os.environ.get("TAKAHE_MEDIA_URL", "/media/")

View File

@ -16,3 +16,5 @@ CSRF_TRUSTED_ORIGINS = [
"http://127.0.0.1:8000", "http://127.0.0.1:8000",
"https://127.0.0.1:8000", "https://127.0.0.1:8000",
] ]
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"

View File

@ -82,8 +82,10 @@ urlpatterns = [
path("@<handle>/posts/<int:post_id>/boost/", posts.Boost.as_view()), path("@<handle>/posts/<int:post_id>/boost/", posts.Boost.as_view()),
path("@<handle>/posts/<int:post_id>/unboost/", posts.Boost.as_view(undo=True)), path("@<handle>/posts/<int:post_id>/unboost/", posts.Boost.as_view(undo=True)),
# Authentication # Authentication
path("auth/login/", auth.Login.as_view()), path("auth/login/", auth.Login.as_view(), name="login"),
path("auth/logout/", auth.Logout.as_view()), path("auth/logout/", auth.Logout.as_view(), name="logout"),
path("auth/signup/", auth.Signup.as_view(), name="signup"),
path("auth/reset/<token>/", auth.Reset.as_view(), name="password_reset"),
# Identity selection # Identity selection
path("@<handle>/activate/", identity.ActivateIdentity.as_view()), path("@<handle>/activate/", identity.ActivateIdentity.as_view()),
path("identity/select/", identity.SelectIdentity.as_view()), path("identity/select/", identity.SelectIdentity.as_view()),

View File

@ -23,11 +23,11 @@
<i class="fa-solid fa-gear"></i> Settings <i class="fa-solid fa-gear"></i> Settings
</a> </a>
{% else %} {% else %}
<a href="/local/" {% if current_page == "local" %}class="selected"{% endif %}> <a href="{% url "local" %}" {% if current_page == "local" %}class="selected"{% endif %}>
<i class="fa-solid fa-city"></i> Local Posts <i class="fa-solid fa-city"></i> Local Posts
</a> </a>
<h3></h3> <h3></h3>
<a href="/auth/signup/" {% if current_page == "signup" %}class="selected"{% endif %}> <a href="{% url "signup" %}" {% if current_page == "signup" %}class="selected"{% endif %}>
<i class="fa-solid fa-user-plus"></i> Create Account <i class="fa-solid fa-user-plus"></i> Create Account
</a> </a>
{% endif %} {% endif %}

20
templates/auth/reset.html Normal file
View File

@ -0,0 +1,20 @@
{% extends "base.html" %}
{% block title %}Reset Password{% endblock %}
{% block content %}
<form action="." method="POST">
{% csrf_token %}
<fieldset>
<legend>Reset Password</legend>
<p>You are resetting your password for {{ reset.user.email }}.</p>
<p>Please choose your new password below.</p>
{% for field in form %}
{% include "forms/_field.html" %}
{% endfor %}
</fieldset>
<div class="buttons">
<button>Reset</button>
</div>
</form>
{% endblock %}

View File

@ -0,0 +1,14 @@
{% extends "base.html" %}
{% block title %}Password Reset{% endblock %}
{% block content %}
<form>
<fieldset>
<legend>Password Reset</legend>
<p>
Your password for <tt>{{ email }}</tt> has been reset!
</p>
</fieldset>
</form>
{% endblock %}

View File

@ -0,0 +1,18 @@
{% extends "base.html" %}
{% block title %}Create Account{% endblock %}
{% block content %}
<form action="." method="POST">
{% csrf_token %}
<fieldset>
<legend>Create An Account</legend>
{% for field in form %}
{% include "forms/_field.html" %}
{% endfor %}
</fieldset>
<div class="buttons">
<button>Create</button>
</div>
</form>
{% endblock %}

View File

@ -0,0 +1,15 @@
{% extends "base.html" %}
{% block title %}Email Sent{% endblock %}
{% block content %}
<form>
<fieldset>
<legend>Email Sent</legend>
<p>
An email has been sent to <tt>{{ email }}</tt> - please follow
the link inside to finish creating your account.
</p>
</fieldset>
</form>
{% endblock %}

View File

@ -0,0 +1,8 @@
Your email address was used to create a new account at {{config.site_name}} (https://{{settings.MAIN_DOMAIN}}).
To confirm your new account, go to this link:
https://{{settings.MAIN_DOMAIN}}/auth/reset/{{reset.token}}/
If this was not you, then please ignore this message - your email will not be
used to make an account if this link is not visited.

View File

@ -0,0 +1,8 @@
A password reset was requested for your account ({{reset.user.email}}) at {{Config.system.site_name}} (https://{{settings.MAIN_DOMAIN}}).
To reset your password, go to this link:
https://{{settings.MAIN_DOMAIN}}/auth/reset/{{reset.token}}/
If this was not you, then please ignore this message - your password will not be
reset if this link is not visited.

View File

@ -1,6 +1,14 @@
from django.contrib import admin from django.contrib import admin
from users.models import Domain, Follow, Identity, InboxMessage, User, UserEvent from users.models import (
Domain,
Follow,
Identity,
InboxMessage,
PasswordReset,
User,
UserEvent,
)
@admin.register(Domain) @admin.register(Domain)
@ -42,6 +50,12 @@ class FollowAdmin(admin.ModelAdmin):
raw_id_fields = ["source", "target"] raw_id_fields = ["source", "target"]
@admin.register(PasswordReset)
class PasswordResetAdmin(admin.ModelAdmin):
list_display = ["id", "user", "created"]
raw_id_fields = ["user"]
@admin.register(InboxMessage) @admin.register(InboxMessage)
class InboxMessageAdmin(admin.ModelAdmin): class InboxMessageAdmin(admin.ModelAdmin):
list_display = ["id", "state", "state_attempted", "message_type", "message_actor"] list_display = ["id", "state", "state_attempted", "message_type", "message_actor"]

View File

@ -0,0 +1,60 @@
# Generated by Django 4.1.3 on 2022-11-18 01:40
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
import stator.models
import users.models.password_reset
class Migration(migrations.Migration):
dependencies = [
("users", "0003_user_last_seen_alter_identity_domain"),
]
operations = [
migrations.CreateModel(
name="PasswordReset",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("state_ready", models.BooleanField(default=True)),
("state_changed", models.DateTimeField(auto_now_add=True)),
("state_attempted", models.DateTimeField(blank=True, null=True)),
("state_locked_until", models.DateTimeField(blank=True, null=True)),
(
"state",
stator.models.StateField(
choices=[("new", "new"), ("sent", "sent")],
default="new",
graph=users.models.password_reset.PasswordResetStates,
max_length=100,
),
),
("token", models.CharField(max_length=500, unique=True)),
("new_account", models.BooleanField()),
("created", models.DateTimeField(auto_now_add=True)),
("updated", models.DateTimeField(auto_now=True)),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="password_resets",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"abstract": False,
},
),
]

View File

@ -3,5 +3,6 @@ from .domain import Domain # noqa
from .follow import Follow, FollowStates # noqa from .follow import Follow, FollowStates # noqa
from .identity import Identity, IdentityStates # noqa from .identity import Identity, IdentityStates # noqa
from .inbox_message import InboxMessage, InboxMessageStates # noqa from .inbox_message import InboxMessage, InboxMessageStates # noqa
from .password_reset import PasswordReset # noqa
from .user import User # noqa from .user import User # noqa
from .user_event import UserEvent # noqa from .user_event import UserEvent # noqa

View File

@ -0,0 +1,92 @@
import random
import string
from asgiref.sync import sync_to_async
from django.conf import settings
from django.core.mail import send_mail
from django.db import models
from django.template.loader import render_to_string
from core.models import Config
from stator.models import State, StateField, StateGraph, StatorModel
class PasswordResetStates(StateGraph):
new = State(try_interval=3)
sent = State()
new.transitions_to(sent)
@classmethod
async def handle_new(cls, instance: "PasswordReset"):
"""
Sends the password reset email.
"""
reset = await instance.afetch_full()
if reset.new_account:
await sync_to_async(send_mail)(
subject=f"{Config.system.site_name}: Confirm new account",
message=render_to_string(
"emails/new_account.txt",
{
"reset": reset,
"config": Config.system,
"settings": settings,
},
),
from_email=settings.EMAIL_FROM,
recipient_list=[reset.user.email],
)
else:
await sync_to_async(send_mail)(
subject=f"{Config.system.site_name}: Reset password",
message=render_to_string(
"emails/password_reset.txt",
{
"reset": reset,
"config": Config.system,
"settings": settings,
},
),
from_email=settings.EMAIL_FROM,
recipient_list=[reset.user.email],
)
return cls.sent
class PasswordReset(StatorModel):
"""
A password reset for a user (this is also how we create accounts)
"""
state = StateField(PasswordResetStates)
user = models.ForeignKey(
"users.user",
on_delete=models.CASCADE,
related_name="password_resets",
)
token = models.CharField(max_length=500, unique=True)
new_account = models.BooleanField()
created = models.DateTimeField(auto_now_add=True)
updated = models.DateTimeField(auto_now=True)
@classmethod
def create_for_user(cls, user):
return cls.objects.create(
user=user,
token="".join(random.choice(string.ascii_lowercase) for i in range(42)),
new_account=not user.password,
)
### Async helpers ###
async def afetch_full(self):
"""
Returns a version of the object with all relations pre-loaded
"""
return await PasswordReset.objects.select_related(
"user",
).aget(pk=self.pk)

View File

@ -62,6 +62,10 @@ class BasicPage(AdminSettingsPage):
"title": "Site Banner", "title": "Site Banner",
"help_text": "Must be at least 650px wide. 3:1 ratio of width:height recommended.", "help_text": "Must be at least 650px wide. 3:1 ratio of width:height recommended.",
}, },
"identity_max_per_user": {
"title": "Maximum Identities Per User",
"help_text": "Non-admins will be blocked from creating more than this",
},
} }
layout = { layout = {
@ -73,6 +77,7 @@ class BasicPage(AdminSettingsPage):
"highlight_color", "highlight_color",
], ],
"Posts": ["post_length"], "Posts": ["post_length"],
"Identities": ["identity_max_per_user"],
} }

View File

@ -1,4 +1,10 @@
from django import forms
from django.contrib.auth.password_validation import validate_password
from django.contrib.auth.views import LoginView, LogoutView from django.contrib.auth.views import LoginView, LogoutView
from django.shortcuts import get_object_or_404, render
from django.views.generic import FormView
from users.models import PasswordReset, User
class Login(LoginView): class Login(LoginView):
@ -8,3 +14,78 @@ class Login(LoginView):
class Logout(LogoutView): class Logout(LogoutView):
pass pass
class Signup(FormView):
template_name = "auth/signup.html"
class form_class(forms.Form):
email = forms.EmailField(
help_text="We will send a link to this email to set your password and create your account",
)
def clean_email(self):
email = self.cleaned_data.get("email").lower()
if not email:
return
if User.objects.filter(email=email).exists():
raise forms.ValidationError("This email already has an account")
return email
def form_valid(self, form):
user = User.objects.create(email=form.cleaned_data["email"])
PasswordReset.create_for_user(user)
return render(
self.request,
"auth/signup_success.html",
{"email": user.email},
)
class Reset(FormView):
template_name = "auth/reset.html"
class form_class(forms.Form):
password = forms.CharField(
widget=forms.PasswordInput,
help_text="Must be at least 8 characters, and contain both letters and numbers.",
)
repeat_password = forms.CharField(
widget=forms.PasswordInput,
)
def clean_password(self):
password = self.cleaned_data["password"]
validate_password(password)
return password
def clean_repeat_password(self):
if self.cleaned_data.get("password") != self.cleaned_data.get(
"repeat_password"
):
raise forms.ValidationError("Passwords do not match")
return self.cleaned_data.get("repeat_password")
def dispatch(self, request, token):
self.reset = get_object_or_404(PasswordReset, token=token)
return super().dispatch(request)
def form_valid(self, form):
self.reset.user.set_password(form.cleaned_data["password"])
self.reset.user.save()
self.reset.delete()
return render(
self.request,
"auth/reset_success.html",
{"email": self.reset.user.email},
)
def get_context_data(self, *args, **kwargs):
context = super().get_context_data(*args, **kwargs)
context["reset"] = self.reset
return context