diff --git a/djeveric/__init__.py b/djeveric/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/djeveric/rest_api.py b/djeveric/rest_api.py new file mode 100644 index 0000000..87dc333 --- /dev/null +++ b/djeveric/rest_api.py @@ -0,0 +1,54 @@ +from django.contrib.auth import get_user_model +from django.contrib.auth.tokens import default_token_generator +from django.contrib.contenttypes.models import ContentType +from django.db import models +from django.utils.http import urlsafe_base64_decode +from rest_framework import serializers, status +from rest_framework.response import Response +from rest_framework.views import APIView + +from djeveric.signals import user_confirmed, AlreadyConfirmed + + +class ConfirmationSerializer(serializers.Serializer): + uid = serializers.CharField() + token = serializers.CharField() + type_id = serializers.CharField() + obj_id = serializers.CharField() + + +class ConfirmationView(APIView): + def post(self, request, format=None): + serializer = ConfirmationSerializer(data=request.data) + if serializer.is_valid(): + user = self.get_user(serializer.validated_data["uid"]) + token = serializer.validated_data["token"] + if user is not None and default_token_generator.check_token(user, token): + obj = self.get_obj(serializer.validated_data["type_id"], serializer.validated_data["obj_id"]) + if obj is not None: + try: + user_confirmed.send(sender=self.__class__, user=user, instance=obj, key=None) + except AlreadyConfirmed as e: + return Response({"message": str(e)}, status=status.HTTP_400_BAD_REQUEST) + return Response(serializer.data, status=status.HTTP_204_NO_CONTENT) + + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def get_obj(self, type_id, obj_id): + try: + # urlsafe_base64_decode() decodes to bytestring + type_pk = urlsafe_base64_decode(type_id).decode() + obj_pk = urlsafe_base64_decode(obj_id).decode() + obj = ContentType.objects.get_for_id(type_pk).get_object_for_this_type(pk=obj_pk) + except (TypeError, ValueError, OverflowError, models.Model.DoesNotExist): + obj = None + return obj + + def get_user(self, uid): + try: + # urlsafe_base64_decode() decodes to bytestring + uid = urlsafe_base64_decode(uid).decode() + user = get_user_model().objects.get(pk=uid) + except (TypeError, ValueError, OverflowError, models.Model.DoesNotExist): + user = None + return user diff --git a/djeveric/signals.py b/djeveric/signals.py new file mode 100644 index 0000000..86b2012 --- /dev/null +++ b/djeveric/signals.py @@ -0,0 +1,34 @@ +from django.contrib.auth.tokens import default_token_generator +from django.contrib.contenttypes.models import ContentType +from django.contrib.sites.models import Site +from django.core.mail import send_mail +from django.dispatch import Signal +from django.utils.encoding import force_bytes +from django.utils.http import urlsafe_base64_encode + +user_confirmed = Signal(providing_args=["user", "instance", "key"]) + + +class AlreadyConfirmed(Exception): + """The given resource has already been confirmed.""" + pass + + +def encode_pk(obj): + return urlsafe_base64_encode(force_bytes(obj.pk)) + + +def request_confirmation(user, instance, key=None): + site = Site.objects.first() + uid = encode_pk(user) + token = default_token_generator.make_token(user) + obj_type = ContentType.objects.get_for_model(instance) + type_id = encode_pk(obj_type) + obj_id = encode_pk(instance) + confirmation_url = f"https://{site.domain}/confirm/{uid}/{token}/{type_id}/{obj_id}" + send_mail( + f"{site.name}: Bestätigung der Anfrage", + f"Bitte bestätige, dass du deine E-Mail-Adresse auf der Seite {site.name} ({site.domain}) eingegeben hast. " + f"Kopiere dazu folgende URL in deinen Webbrowser:\n\n{confirmation_url}", + None, [user.email] + ) diff --git a/userausfall/migrations/0006_auto_20210521_0805.py b/userausfall/migrations/0006_auto_20210521_0805.py new file mode 100644 index 0000000..0a80fb1 --- /dev/null +++ b/userausfall/migrations/0006_auto_20210521_0805.py @@ -0,0 +1,37 @@ +# Generated by Django 2.2.20 on 2021-05-21 08:05 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('userausfall', '0005_delete_accountrequest'), + ] + + operations = [ + migrations.RemoveField( + model_name='user', + name='first_name', + ), + migrations.RemoveField( + model_name='user', + name='is_active', + ), + migrations.RemoveField( + model_name='user', + name='last_name', + ), + migrations.AddField( + model_name='user', + name='confidant_unconfirmed', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='unconfirmed_confidants', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='user', + name='confidant', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='confidants', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/userausfall/models.py b/userausfall/models.py index 1b85750..d20c385 100644 --- a/userausfall/models.py +++ b/userausfall/models.py @@ -11,11 +11,13 @@ from userausfall import ldap class MissingUserAttribute(Exception): """The user object is missing a required attribute.""" + pass class PasswordMismatch(Exception): """The given password does not match the user's password.""" + pass @@ -34,18 +36,18 @@ class UserManager(BaseUserManager): return user def create_user(self, email, password=None, **extra_fields): - extra_fields.setdefault('is_staff', False) - extra_fields.setdefault('is_superuser', False) + extra_fields.setdefault("is_staff", False) + extra_fields.setdefault("is_superuser", False) return self._create_user(email, password, **extra_fields) def create_superuser(self, email, password, **extra_fields): - extra_fields.setdefault('is_staff', True) - extra_fields.setdefault('is_superuser', True) + extra_fields.setdefault("is_staff", True) + extra_fields.setdefault("is_superuser", True) - if extra_fields.get('is_staff') is not True: - raise ValueError('Superuser must have is_staff=True.') - if extra_fields.get('is_superuser') is not True: - raise ValueError('Superuser must have is_superuser=True.') + if extra_fields.get("is_staff") is not True: + raise ValueError("Superuser must have is_staff=True.") + if extra_fields.get("is_superuser") is not True: + raise ValueError("Superuser must have is_superuser=True.") return self._create_user(email, password, **extra_fields) @@ -54,41 +56,53 @@ class User(AbstractBaseUser, PermissionsMixin): username_validator = UnicodeUsernameValidator() username = models.CharField( - _('username'), + _("username"), max_length=150, - help_text=_('Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.'), + help_text=_( + "Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only." + ), validators=[username_validator], - error_messages={ - 'unique': _("A user with that username already exists."), - }, + error_messages={"unique": _("A user with that username already exists.")}, blank=True, ) - email = models.EmailField(_('email address'), unique=True, blank=True) + email = models.EmailField(_("email address"), unique=True, blank=True) is_staff = models.BooleanField( - _('staff status'), + _("staff status"), default=False, - help_text=_('Designates whether the user can log into this admin site.'), + help_text=_("Designates whether the user can log into this admin site."), + ) + date_joined = models.DateTimeField(_("date joined"), default=timezone.now) + confidant = models.ForeignKey( + "User", on_delete=models.SET_NULL, null=True, related_name="confidants" + ) + confidant_unconfirmed = models.ForeignKey( + "User", + on_delete=models.SET_NULL, + null=True, + related_name="unconfirmed_confidants", ) - date_joined = models.DateTimeField(_('date joined'), default=timezone.now) - confidant = models.ForeignKey("User", on_delete=models.SET_NULL, null=True) objects = UserManager() - EMAIL_FIELD = 'email' - USERNAME_FIELD = 'email' + EMAIL_FIELD = "email" + USERNAME_FIELD = "email" REQUIRED_FIELDS = [] class Meta: - verbose_name = _('user') - verbose_name_plural = _('users') + verbose_name = _("user") + verbose_name_plural = _("users") + + @property + def confidant_email(self): + if self.confidant is not None: + return self.confidant.email + else: + return None def clean(self): super().clean() self.email = self.__class__.objects.normalize_email(self.email) - def get_confidant_email(self): - return "" - def email_user(self, subject, message, from_email=None, **kwargs): """Send an email to this user.""" send_mail(subject, message, from_email, [self.email], **kwargs) @@ -100,5 +114,7 @@ class User(AbstractBaseUser, PermissionsMixin): if not self.confidant: raise MissingUserAttribute("User is missing a confirmed confidant.") if not self.check_password(raw_password): - raise PasswordMismatch("The given password does not match the user's password.") + raise PasswordMismatch( + "The given password does not match the user's password." + ) return ldap.create_account(self.username, raw_password) diff --git a/userausfall/rest_api/serializers.py b/userausfall/rest_api/serializers.py index c4acdbd..c06ecf3 100644 --- a/userausfall/rest_api/serializers.py +++ b/userausfall/rest_api/serializers.py @@ -3,17 +3,19 @@ from rest_framework import serializers from userausfall.models import User +class UserActivationSerializer(serializers.Serializer): + password = serializers.CharField() + + class UserSerializer(serializers.ModelSerializer): - confidant_email = serializers.EmailField(source="get_confidant_email") + confidant_email = serializers.EmailField() class Meta: model = User - fields = ("email", "username", "confidant_email") + fields = ("pk", "email", "username", "confidant_email") - def get_confidant_email(self): - return "" - - def update(self, instance, validated_data): - print(validated_data) - confidant = validated_data.pop("get_confidant_email") + def update(self, instance: User, validated_data): + confidant_email = validated_data.pop("confidant_email") + confidant, _ = User.objects.get_or_create(email=confidant_email) + instance.confidant_unconfirmed = confidant return super().update(instance, validated_data) diff --git a/userausfall/rest_api/urls.py b/userausfall/rest_api/urls.py index 3094cd0..f784bcd 100644 --- a/userausfall/rest_api/urls.py +++ b/userausfall/rest_api/urls.py @@ -1,6 +1,14 @@ +from django.urls import path from rest_framework import routers +from djeveric.rest_api import ConfirmationView from userausfall.rest_api.views import UserViewSet router = routers.DefaultRouter(trailing_slash=True) router.register(r'users', UserViewSet, basename="user") + +urlpatterns = [ + path("confirm", ConfirmationView.as_view()) +] + +urlpatterns += router.urls diff --git a/userausfall/rest_api/views.py b/userausfall/rest_api/views.py index c2ff3d6..bcf1363 100644 --- a/userausfall/rest_api/views.py +++ b/userausfall/rest_api/views.py @@ -1,19 +1,25 @@ -from rest_framework import viewsets +from rest_framework import viewsets, status from rest_framework.decorators import action from rest_framework.response import Response -from userausfall.models import User -from userausfall.rest_api.serializers import UserSerializer +from userausfall.models import User, MissingUserAttribute, PasswordMismatch +from userausfall.rest_api.serializers import UserSerializer, UserActivationSerializer class UserViewSet(viewsets.ModelViewSet): - class Meta: - queryset = User.objects.all() + queryset = User.objects.all() + serializer_class = UserSerializer - @action(detail=False, methods=["PATCH"]) - def me(self, request): - return Response( - UserSerializer( - instance=request.user, context={"request": request} - ).data - ) + @action(detail=True, methods=["post"]) + def activate(self, request, pk=None): + """Create the corresponding LDAP account.""" + user: User = self.get_object() + serializer = UserActivationSerializer(data=request.data) + if serializer.is_valid(): + try: + user.create_ldap_account(serializer.validated_data["password"]) + except (MissingUserAttribute, PasswordMismatch) as e: + return Response({"message": str(e)}, status=status.HTTP_400_BAD_REQUEST) + return Response(status=status.HTTP_204_NO_CONTENT) + else: + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) diff --git a/userausfall/settings.py b/userausfall/settings.py index 3486771..786acf6 100644 --- a/userausfall/settings.py +++ b/userausfall/settings.py @@ -35,6 +35,7 @@ INSTALLED_APPS = [ 'django.contrib.contenttypes', 'django.contrib.sessions', 'django.contrib.messages', + 'django.contrib.sites', 'django.contrib.staticfiles', 'userausfall', 'rest_framework', diff --git a/userausfall/signals.py b/userausfall/signals.py index e69de29..8b09cfe 100644 --- a/userausfall/signals.py +++ b/userausfall/signals.py @@ -0,0 +1,26 @@ +from django.core.exceptions import PermissionDenied +from django.db.models.signals import post_save +from django.dispatch import receiver +from rest_framework.exceptions import PermissionDenied + +from djeveric.signals import request_confirmation, user_confirmed, AlreadyConfirmed +from userausfall.models import User + + +@receiver(post_save, sender=User) +def user_saved(sender, instance: User, **kwargs): + if (instance.confidant_unconfirmed is not None) and (instance.confidant_unconfirmed != instance.confidant): + request_confirmation(instance.confidant_unconfirmed, instance) + + +@receiver(user_confirmed) +def user_confirmed(sender, user: User, instance: User, **kwargs): + if user == instance.confidant_unconfirmed: + if instance.confidant_unconfirmed != instance.confidant: + # confirm the confidant + instance.confidant = instance.confidant_unconfirmed + instance.save() + else: + raise AlreadyConfirmed("The confidant has already been confirmed.") + else: + raise PermissionDenied() diff --git a/userausfall/urls.py b/userausfall/urls.py index 0175b96..64efbbd 100644 --- a/userausfall/urls.py +++ b/userausfall/urls.py @@ -5,6 +5,6 @@ from userausfall.rest_api import urls as rest_api_urls urlpatterns = [ path('admin/', admin.site.urls), - path('api/', include(rest_api_urls.router.urls)), + path('api/', include("userausfall.rest_api.urls")), path("api-auth/", include("rest_framework.urls")), ]