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"
def ready(self) -> None:
from core.models import Config
Config.system = Config.load_system()
jsonld.set_document_loader(builtin_document_loader)

View file

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

View file

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

View file

@ -108,6 +108,11 @@ STATICFILES_DIRS = [
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
MEDIA_URL = os.environ.get("TAKAHE_MEDIA_URL", "/media/")

View file

@ -16,3 +16,5 @@ CSRF_TRUSTED_ORIGINS = [
"http://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>/unboost/", posts.Boost.as_view(undo=True)),
# Authentication
path("auth/login/", auth.Login.as_view()),
path("auth/logout/", auth.Logout.as_view()),
path("auth/login/", auth.Login.as_view(), name="login"),
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
path("@<handle>/activate/", identity.ActivateIdentity.as_view()),
path("identity/select/", identity.SelectIdentity.as_view()),

View file

@ -23,11 +23,11 @@
<i class="fa-solid fa-gear"></i> Settings
</a>
{% 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
</a>
<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
</a>
{% 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 users.models import Domain, Follow, Identity, InboxMessage, User, UserEvent
from users.models import (
Domain,
Follow,
Identity,
InboxMessage,
PasswordReset,
User,
UserEvent,
)
@admin.register(Domain)
@ -42,6 +50,12 @@ class FollowAdmin(admin.ModelAdmin):
raw_id_fields = ["source", "target"]
@admin.register(PasswordReset)
class PasswordResetAdmin(admin.ModelAdmin):
list_display = ["id", "user", "created"]
raw_id_fields = ["user"]
@admin.register(InboxMessage)
class InboxMessageAdmin(admin.ModelAdmin):
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 .identity import Identity, IdentityStates # noqa
from .inbox_message import InboxMessage, InboxMessageStates # noqa
from .password_reset import PasswordReset # noqa
from .user import User # 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",
"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 = {
@ -73,6 +77,7 @@ class BasicPage(AdminSettingsPage):
"highlight_color",
],
"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.shortcuts import get_object_or_404, render
from django.views.generic import FormView
from users.models import PasswordReset, User
class Login(LoginView):
@ -8,3 +14,78 @@ class Login(LoginView):
class Logout(LogoutView):
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