From d77dcf62b4005a0f36ef2fa7ba6d3651d2ef38d7 Mon Sep 17 00:00:00 2001 From: Andrew Godwin Date: Sat, 5 Nov 2022 14:17:27 -0600 Subject: Initial commit (users and statuses) --- users/__init__.py | 0 users/admin.py | 18 ++++++ users/apps.py | 6 ++ users/decorators.py | 39 ++++++++++++ users/migrations/0001_initial.py | 134 +++++++++++++++++++++++++++++++++++++++ users/migrations/__init__.py | 0 users/models/__init__.py | 3 + users/models/identity.py | 79 +++++++++++++++++++++++ users/models/user.py | 58 +++++++++++++++++ users/models/user_event.py | 22 +++++++ users/shortcuts.py | 18 ++++++ users/views/__init__.py | 1 + users/views/auth.py | 15 +++++ users/views/identity.py | 132 ++++++++++++++++++++++++++++++++++++++ 14 files changed, 525 insertions(+) create mode 100644 users/__init__.py create mode 100644 users/admin.py create mode 100644 users/apps.py create mode 100644 users/decorators.py create mode 100644 users/migrations/0001_initial.py create mode 100644 users/migrations/__init__.py create mode 100644 users/models/__init__.py create mode 100644 users/models/identity.py create mode 100644 users/models/user.py create mode 100644 users/models/user_event.py create mode 100644 users/shortcuts.py create mode 100644 users/views/__init__.py create mode 100644 users/views/auth.py create mode 100644 users/views/identity.py (limited to 'users') diff --git a/users/__init__.py b/users/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/users/admin.py b/users/admin.py new file mode 100644 index 0000000..6ae97b9 --- /dev/null +++ b/users/admin.py @@ -0,0 +1,18 @@ +from django.contrib import admin + +from users.models import Identity, User, UserEvent + + +@admin.register(User) +class UserAdmin(admin.ModelAdmin): + pass + + +@admin.register(UserEvent) +class UserEventAdmin(admin.ModelAdmin): + pass + + +@admin.register(Identity) +class IdentityAdmin(admin.ModelAdmin): + pass diff --git a/users/apps.py b/users/apps.py new file mode 100644 index 0000000..88f7b17 --- /dev/null +++ b/users/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class UsersConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "users" diff --git a/users/decorators.py b/users/decorators.py new file mode 100644 index 0000000..77d633a --- /dev/null +++ b/users/decorators.py @@ -0,0 +1,39 @@ +from functools import wraps + +from django.contrib.auth.views import redirect_to_login +from django.http import HttpResponseRedirect + +from users.models import Identity + + +def identity_required(function): + """ + Decorator for views that ensures an active identity is selected. + """ + + @wraps(function) + def inner(request, *args, **kwargs): + # They do have to be logged in + if not request.user.is_authenticated: + return redirect_to_login(next=request.get_full_path()) + # Try to retrieve their active identity + identity_id = request.session.get("identity_id") + if not identity_id: + identity = None + else: + try: + identity = Identity.objects.get(id=identity_id) + except Identity.DoesNotExist: + identity = None + # If there's no active one, try to auto-select one + if identity is None: + possible_identities = list(request.user.identities.all()) + if len(possible_identities) != 1: + # OK, send them to the identity selection page to select/create one + return HttpResponseRedirect("/identity/select/") + identity = possible_identities[0] + request.identity = identity + request.session["identity_id"] = identity.pk + return function(request, *args, **kwargs) + + return inner diff --git a/users/migrations/0001_initial.py b/users/migrations/0001_initial.py new file mode 100644 index 0000000..e258d1b --- /dev/null +++ b/users/migrations/0001_initial.py @@ -0,0 +1,134 @@ +# Generated by Django 4.1.3 on 2022-11-05 19:15 + +import functools + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + +import users.models.identity + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="User", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("password", models.CharField(max_length=128, verbose_name="password")), + ( + "last_login", + models.DateTimeField( + blank=True, null=True, verbose_name="last login" + ), + ), + ("email", models.EmailField(max_length=254, unique=True)), + ("admin", models.BooleanField(default=False)), + ("moderator", models.BooleanField(default=False)), + ("banned", models.BooleanField(default=False)), + ("deleted", models.BooleanField(default=False)), + ("created", models.DateTimeField(auto_now_add=True)), + ("updated", models.DateTimeField(auto_now=True)), + ], + options={ + "abstract": False, + }, + ), + migrations.CreateModel( + name="UserEvent", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("date", models.DateTimeField(auto_now_add=True)), + ( + "type", + models.CharField( + choices=[ + ("created", "Created"), + ("reset_password", "Reset Password"), + ("banned", "Banned"), + ], + max_length=100, + ), + ), + ("data", models.JSONField(blank=True, null=True)), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="events", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + migrations.CreateModel( + name="Identity", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("handle", models.CharField(max_length=500, unique=True)), + ("name", models.CharField(blank=True, max_length=500, null=True)), + ("bio", models.TextField(blank=True, null=True)), + ( + "profile_image", + models.ImageField( + upload_to=functools.partial( + users.models.identity.upload_namer, + *("profile_images",), + **{}, + ) + ), + ), + ( + "background_image", + models.ImageField( + upload_to=functools.partial( + users.models.identity.upload_namer, + *("background_images",), + **{}, + ) + ), + ), + ("local", models.BooleanField()), + ("private_key", models.BinaryField(blank=True, null=True)), + ("public_key", models.BinaryField(blank=True, null=True)), + ("created", models.DateTimeField(auto_now_add=True)), + ("updated", models.DateTimeField(auto_now=True)), + ("deleted", models.DateTimeField(blank=True, null=True)), + ( + "users", + models.ManyToManyField( + related_name="identities", to=settings.AUTH_USER_MODEL + ), + ), + ], + ), + ] diff --git a/users/migrations/__init__.py b/users/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/users/models/__init__.py b/users/models/__init__.py new file mode 100644 index 0000000..7032a81 --- /dev/null +++ b/users/models/__init__.py @@ -0,0 +1,3 @@ +from .identity import Identity # noqa +from .user import User # noqa +from .user_event import UserEvent # noqa diff --git a/users/models/identity.py b/users/models/identity.py new file mode 100644 index 0000000..495b4a4 --- /dev/null +++ b/users/models/identity.py @@ -0,0 +1,79 @@ +import base64 +import uuid +from functools import partial + +import urlman +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from django.conf import settings +from django.db import models +from django.utils import timezone + + +def upload_namer(prefix, instance, filename): + """ + Names uploaded images etc. + """ + now = timezone.now() + filename = base64.b32encode(uuid.uuid4().bytes).decode("ascii") + return f"{prefix}/{now.year}/{now.month}/{now.day}/{filename}" + + +class Identity(models.Model): + """ + Represents both local and remote Fediverse identities (actors) + """ + + # The handle includes the domain! + handle = models.CharField(max_length=500, unique=True) + name = models.CharField(max_length=500, blank=True, null=True) + bio = models.TextField(blank=True, null=True) + + profile_image = models.ImageField(upload_to=partial(upload_namer, "profile_images")) + background_image = models.ImageField( + upload_to=partial(upload_namer, "background_images") + ) + + local = models.BooleanField() + users = models.ManyToManyField("users.User", related_name="identities") + private_key = models.TextField(null=True, blank=True) + public_key = models.TextField(null=True, blank=True) + + created = models.DateTimeField(auto_now_add=True) + updated = models.DateTimeField(auto_now=True) + deleted = models.DateTimeField(null=True, blank=True) + + @property + def short_handle(self): + if self.handle.endswith("@" + settings.DEFAULT_DOMAIN): + return self.handle.split("@", 1)[0] + return self.handle + + @property + def domain(self): + return self.handle.split("@", 1)[1] + + def generate_keypair(self): + private_key = rsa.generate_private_key( + public_exponent=65537, + key_size=2048, + ) + self.private_key = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + self.public_key = private_key.public_key().public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + self.save() + + def __str__(self): + return self.name or self.handle + + class urls(urlman.Urls): + view = "/@{self.short_handle}/" + actor = "{view}actor/" + inbox = "{actor}inbox/" + activate = "{view}activate/" diff --git a/users/models/user.py b/users/models/user.py new file mode 100644 index 0000000..de51380 --- /dev/null +++ b/users/models/user.py @@ -0,0 +1,58 @@ +from typing import List + +from django.contrib.auth.models import AbstractBaseUser, BaseUserManager +from django.db import models + + +class UserManager(BaseUserManager): + """ + Custom user manager that understands emails + """ + + def create_user(self, email, password=None): + user = self.create(email=email) + if password: + user.set_password(password) + user.save() + return user + + def create_superuser(self, email, password=None): + user = self.create(email=email, admin=True) + if password: + user.set_password(password) + user.save() + return user + + +class User(AbstractBaseUser): + """ + Custom user model that only needs an email + """ + + email = models.EmailField(unique=True) + + admin = models.BooleanField(default=False) + moderator = models.BooleanField(default=False) + banned = models.BooleanField(default=False) + deleted = models.BooleanField(default=False) + + created = models.DateTimeField(auto_now_add=True) + updated = models.DateTimeField(auto_now=True) + + USERNAME_FIELD = "email" + EMAIL_FIELD = "email" + REQUIRED_FIELDS: List[str] = [] + + objects = UserManager() + + @property + def is_active(self): + return not (self.deleted or self.banned) + + @property + def is_superuser(self): + return self.admin + + @property + def is_staff(self): + return self.admin diff --git a/users/models/user_event.py b/users/models/user_event.py new file mode 100644 index 0000000..858f334 --- /dev/null +++ b/users/models/user_event.py @@ -0,0 +1,22 @@ +from django.db import models + + +class UserEvent(models.Model): + """ + Tracks major events that happen to users + """ + + class EventType(models.TextChoices): + created = "created" + reset_password = "reset_password" + banned = "banned" + + user = models.ForeignKey( + "users.User", + on_delete=models.CASCADE, + related_name="events", + ) + + date = models.DateTimeField(auto_now_add=True) + type = models.CharField(max_length=100, choices=EventType.choices) + data = models.JSONField(blank=True, null=True) diff --git a/users/shortcuts.py b/users/shortcuts.py new file mode 100644 index 0000000..0e00404 --- /dev/null +++ b/users/shortcuts.py @@ -0,0 +1,18 @@ +from django.conf import settings +from django.shortcuts import get_object_or_404 + +from users.models import Identity + + +def by_handle_or_404(request, handle, local=True): + """ + Retrieves an Identity by its long or short handle. + Domain-sensitive, so it will understand short handles on alternate domains. + """ + # TODO: Domain sensitivity + if "@" not in handle: + handle += "@" + settings.DEFAULT_DOMAIN + if local: + return get_object_or_404(Identity.objects.filter(local=True), handle=handle) + else: + return get_object_or_404(Identity, handle=handle) diff --git a/users/views/__init__.py b/users/views/__init__.py new file mode 100644 index 0000000..1e88b4e --- /dev/null +++ b/users/views/__init__.py @@ -0,0 +1 @@ +from .auth import * # noqa diff --git a/users/views/auth.py b/users/views/auth.py new file mode 100644 index 0000000..f9e6ce1 --- /dev/null +++ b/users/views/auth.py @@ -0,0 +1,15 @@ +from django.contrib.auth.forms import AuthenticationForm +from django.contrib.auth.views import LoginView, LogoutView + +from core.forms import FormHelper + + +class Login(LoginView): + class form_class(AuthenticationForm): + helper = FormHelper(submit_text="Login") + + template_name = "auth/login.html" + + +class Logout(LogoutView): + pass diff --git a/users/views/identity.py b/users/views/identity.py new file mode 100644 index 0000000..63b7fb8 --- /dev/null +++ b/users/views/identity.py @@ -0,0 +1,132 @@ +from django import forms +from django.conf import settings +from django.contrib.auth.decorators import login_required +from django.http import Http404, JsonResponse +from django.shortcuts import redirect +from django.utils.decorators import method_decorator +from django.views.generic import FormView, TemplateView, View + +from core.forms import FormHelper +from users.models import Identity +from users.shortcuts import by_handle_or_404 + + +class ViewIdentity(TemplateView): + + template_name = "identity/view.html" + + def get_context_data(self, handle): + identity = by_handle_or_404(self.request, handle, local=False) + statuses = identity.statuses.all()[:100] + return { + "identity": identity, + "statuses": statuses, + } + + +@method_decorator(login_required, name="dispatch") +class SelectIdentity(TemplateView): + + template_name = "identity/select.html" + + def get_context_data(self): + return { + "identities": Identity.objects.filter(users__pk=self.request.user.pk), + } + + +@method_decorator(login_required, name="dispatch") +class CreateIdentity(FormView): + + template_name = "identity/create.html" + + class form_class(forms.Form): + handle = forms.CharField() + name = forms.CharField() + + helper = FormHelper(submit_text="Create") + + def clean_handle(self): + # Remove any leading @ + value = self.cleaned_data["handle"].lstrip("@") + # Don't allow custom domains here quite yet + if "@" in value: + raise forms.ValidationError( + "You are not allowed an @ sign in your handle" + ) + # Ensure there is a domain on the end + if "@" not in value: + value += "@" + settings.DEFAULT_DOMAIN + # Check for existing users + if Identity.objects.filter(handle=value).exists(): + raise forms.ValidationError("This handle is already taken") + return value + + def form_valid(self, form): + new_identity = Identity.objects.create( + handle=form.cleaned_data["handle"], + name=form.cleaned_data["name"], + local=True, + ) + new_identity.users.add(self.request.user) + new_identity.generate_keypair() + return redirect(new_identity.urls.view) + + +class Actor(View): + """ + Returns the AP Actor object + """ + + def get(self, request, handle): + identity = by_handle_or_404(self.request, handle) + return JsonResponse( + { + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + ], + "id": f"https://{settings.DEFAULT_DOMAIN}{identity.urls.actor}", + "type": "Person", + "preferredUsername": "alice", + "inbox": f"https://{settings.DEFAULT_DOMAIN}{identity.urls.inbox}", + "publicKey": { + "id": f"https://{settings.DEFAULT_DOMAIN}{identity.urls.actor}#main-key", + "owner": f"https://{settings.DEFAULT_DOMAIN}{identity.urls.actor}", + "publicKeyPem": identity.public_key, + }, + } + ) + + +class Webfinger(View): + """ + Services webfinger requests + """ + + def get(self, request): + resource = request.GET.get("resource") + if not resource.startswith("acct:"): + raise Http404("Not an account resource") + handle = resource[5:] + identity = by_handle_or_404(request, handle) + return JsonResponse( + { + "subject": f"acct:{identity.handle}", + "aliases": [ + f"https://{settings.DEFAULT_DOMAIN}/@{identity.short_handle}", + ], + "links": [ + { + "rel": "http://webfinger.net/rel/profile-page", + "type": "text/html", + "href": f"https://{settings.DEFAULT_DOMAIN}{identity.urls.view}", + }, + { + "rel": "self", + "type": "application/activity+json", + "href": f"https://{settings.DEFAULT_DOMAIN}{identity.urls.actor}", + }, + ], + } + ) -- cgit v1.2.3