summaryrefslogtreecommitdiffstats
path: root/users
diff options
context:
space:
mode:
Diffstat (limited to 'users')
-rw-r--r--users/__init__.py0
-rw-r--r--users/admin.py18
-rw-r--r--users/apps.py6
-rw-r--r--users/decorators.py39
-rw-r--r--users/migrations/0001_initial.py134
-rw-r--r--users/migrations/__init__.py0
-rw-r--r--users/models/__init__.py3
-rw-r--r--users/models/identity.py79
-rw-r--r--users/models/user.py58
-rw-r--r--users/models/user_event.py22
-rw-r--r--users/shortcuts.py18
-rw-r--r--users/views/__init__.py1
-rw-r--r--users/views/auth.py15
-rw-r--r--users/views/identity.py132
14 files changed, 525 insertions, 0 deletions
diff --git a/users/__init__.py b/users/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/users/__init__.py
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
--- /dev/null
+++ b/users/migrations/__init__.py
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}",
+ },
+ ],
+ }
+ )