From 68625c845590baa178a94437d1664c44b8bc9bb1 Mon Sep 17 00:00:00 2001 From: Bianca Rosa Date: Fri, 18 Dec 2020 17:33:24 +0100 Subject: [PATCH] Add backend code for encrypting passworld profiles closes #580 --- containers/backend/.gitignore | 2 + .../api/migrations/0007_lesspassuser_key.py | 18 ++++ .../migrations/0008_encryptedpasswordprofiles.py | 29 ++++++ containers/backend/api/models.py | 38 ++++++-- containers/backend/api/serializers.py | 16 ++++ containers/backend/api/tests/factories.py | 15 ++- containers/backend/api/tests/test_user.py | 25 +++++ .../api/tests/tests_encrypted_password_profiles.py | 106 +++++++++++++++++++++ containers/backend/api/urls.py | 5 + containers/backend/api/views.py | 11 +++ 10 files changed, 251 insertions(+), 14 deletions(-) create mode 100644 containers/backend/api/migrations/0007_lesspassuser_key.py create mode 100644 containers/backend/api/migrations/0008_encryptedpasswordprofiles.py create mode 100644 containers/backend/api/tests/test_user.py create mode 100644 containers/backend/api/tests/tests_encrypted_password_profiles.py diff --git a/containers/backend/.gitignore b/containers/backend/.gitignore index f27a17c..cb782b9 100644 --- a/containers/backend/.gitignore +++ b/containers/backend/.gitignore @@ -1,2 +1,4 @@ www *.sqlite3 +# Ignore virtual env +venv diff --git a/containers/backend/api/migrations/0007_lesspassuser_key.py b/containers/backend/api/migrations/0007_lesspassuser_key.py new file mode 100644 index 0000000..d01d70a --- /dev/null +++ b/containers/backend/api/migrations/0007_lesspassuser_key.py @@ -0,0 +1,18 @@ +# Generated by Django 3.0.7 on 2020-12-18 16:29 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0006_change_default_password_profile'), + ] + + operations = [ + migrations.AddField( + model_name='lesspassuser', + name='key', + field=models.TextField(null=True), + ), + ] diff --git a/containers/backend/api/migrations/0008_encryptedpasswordprofiles.py b/containers/backend/api/migrations/0008_encryptedpasswordprofiles.py new file mode 100644 index 0000000..c34d65f --- /dev/null +++ b/containers/backend/api/migrations/0008_encryptedpasswordprofiles.py @@ -0,0 +1,29 @@ +# Generated by Django 3.0.7 on 2020-12-18 16:29 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0007_lesspassuser_key'), + ] + + operations = [ + migrations.CreateModel( + name='EncryptedPasswordProfile', + fields=[ + ('created', models.DateTimeField(auto_now_add=True, verbose_name='created')), + ('modified', models.DateTimeField(auto_now=True, verbose_name='modified')), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('password_profile', models.TextField()), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='encrypted_password_profiles', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/containers/backend/api/models.py b/containers/backend/api/models.py index 460d75a..7a4351d 100644 --- a/containers/backend/api/models.py +++ b/containers/backend/api/models.py @@ -1,14 +1,12 @@ import uuid from django.db import models -from django.contrib.auth.models import ( - BaseUserManager, AbstractBaseUser -) +from django.contrib.auth.models import BaseUserManager, AbstractBaseUser class LesspassUserManager(BaseUserManager): def create_user(self, email, password=None): if not email: - raise ValueError('Users must have an email address') + raise ValueError("Users must have an email address") user = self.model( email=self.normalize_email(email), @@ -19,20 +17,25 @@ class LesspassUserManager(BaseUserManager): return user def create_superuser(self, email, password): - user = self.create_user(email, password=password, ) + user = self.create_user( + email, + password=password, + ) user.is_admin = True user.save(using=self._db) return user class LessPassUser(AbstractBaseUser): - email = models.EmailField(verbose_name='email address', max_length=255, unique=True) + email = models.EmailField(verbose_name="email address", max_length=255, unique=True) is_active = models.BooleanField(default=True) is_admin = models.BooleanField(default=False) + key = models.TextField(null=True) objects = LesspassUserManager() - USERNAME_FIELD = 'email' + USERNAME_FIELD = "email" + REQUIRED_FIELDS = ["key"] def get_full_name(self): return self.email @@ -62,8 +65,8 @@ class LessPassUser(AbstractBaseUser): class DateMixin(models.Model): - created = models.DateTimeField(auto_now_add=True, verbose_name='created') - modified = models.DateTimeField(auto_now=True, verbose_name='modified') + created = models.DateTimeField(auto_now_add=True, verbose_name="created") + modified = models.DateTimeField(auto_now=True, verbose_name="modified") class Meta: abstract = True @@ -71,7 +74,9 @@ class DateMixin(models.Model): class Password(DateMixin): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - user = models.ForeignKey(LessPassUser, on_delete=models.CASCADE, related_name='passwords') + user = models.ForeignKey( + LessPassUser, on_delete=models.CASCADE, related_name="passwords" + ) login = models.CharField(max_length=255, null=True, blank=True) site = models.CharField(max_length=255, null=True, blank=True) @@ -87,3 +92,16 @@ class Password(DateMixin): def __str__(self): return str(self.id) + + +class EncryptedPasswordProfile(DateMixin): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + user = models.ForeignKey( + LessPassUser, + on_delete=models.CASCADE, + related_name="encrypted_password_profiles", + ) + password_profile = models.TextField() + + def __str__(self): + return str(self.id) diff --git a/containers/backend/api/serializers.py b/containers/backend/api/serializers.py index 571f08d..2aa06c5 100644 --- a/containers/backend/api/serializers.py +++ b/containers/backend/api/serializers.py @@ -27,6 +27,22 @@ class PasswordSerializer(serializers.ModelSerializer): return models.Password.objects.create(user=user, **validated_data) +class EncryptedPasswordProfileSerializer(serializers.ModelSerializer): + class Meta: + model = models.EncryptedPasswordProfile + fields = ( + "id", + "password_profile", + "created", + "modified", + ) + read_only_fields = ("created", "modified") + + def create(self, validated_data): + user = self.context["request"].user + return models.EncryptedPasswordProfile.objects.create(user=user, **validated_data) + + class BackwardCompatibleTokenObtainPairSerializer(TokenObtainPairSerializer): def validate(self, attrs): data = super().validate(attrs) diff --git a/containers/backend/api/tests/factories.py b/containers/backend/api/tests/factories.py index db2a425..530f6af 100644 --- a/containers/backend/api/tests/factories.py +++ b/containers/backend/api/tests/factories.py @@ -7,8 +7,8 @@ class UserFactory(factory.DjangoModelFactory): class Meta: model = models.LessPassUser - email = factory.Sequence(lambda n: 'u{0}@lesspass.com'.format(n)) - password = factory.PostGenerationMethodCall('set_password', 'password') + email = factory.Sequence(lambda n: "u{0}@lesspass.com".format(n)) + password = factory.PostGenerationMethodCall("set_password", "password") is_admin = False @@ -21,5 +21,12 @@ class PasswordFactory(factory.DjangoModelFactory): model = models.Password user = factory.SubFactory(UserFactory) - login = 'admin@oslab.fr' - site = 'lesspass.com' + login = "admin@oslab.fr" + site = "lesspass.com" + + +class EncryptedPasswordProfileFactory(factory.DjangoModelFactory): + class Meta: + model = models.EncryptedPasswordProfile + + user = factory.SubFactory(UserFactory) diff --git a/containers/backend/api/tests/test_user.py b/containers/backend/api/tests/test_user.py new file mode 100644 index 0000000..23d74ae --- /dev/null +++ b/containers/backend/api/tests/test_user.py @@ -0,0 +1,25 @@ +from rest_framework.test import APITestCase, APIClient + +from api import models +from api.tests import factories + + +class UserTestCase(APITestCase): + def setUp(self): + self.user = factories.UserFactory() + self.client = APIClient() + self.client.force_authenticate(user=self.user) + + def test_auth_user_me(self): + request = self.client.get("/api/auth/users/me/") + self.assertEqual(request.status_code, 200) + self.assertIsNone(request.data["key"]) + + def test_update_auth_user_me(self): + self.assertIsNone(models.LessPassUser.objects.first().key) + request = self.client.put("/api/auth/users/me/", {"key": "abc"}) + self.assertEqual(request.status_code, 200) + self.assertEqual("abc", models.LessPassUser.objects.first().key) + request = self.client.patch("/api/auth/users/me/", {"key": "def"}) + self.assertEqual(request.status_code, 200) + self.assertEqual("def", models.LessPassUser.objects.first().key) diff --git a/containers/backend/api/tests/tests_encrypted_password_profiles.py b/containers/backend/api/tests/tests_encrypted_password_profiles.py new file mode 100644 index 0000000..3b65c85 --- /dev/null +++ b/containers/backend/api/tests/tests_encrypted_password_profiles.py @@ -0,0 +1,106 @@ +from rest_framework.test import APITestCase, APIClient + +from api import models +from api.tests import factories + + +class LogoutEncryptedPasswordProfileTestCase(APITestCase): + def test_get_password_profiles_401(self): + response = self.client.get("/api/encrypted_password_profiles/") + self.assertEqual(401, response.status_code) + + +class LoginEncryptedPasswordProfileTestCase(APITestCase): + def setUp(self): + self.user = factories.UserFactory() + self.client = APIClient() + self.client.force_authenticate(user=self.user) + + def test_get_empty_password_profiless(self): + request = self.client.get("/api/encrypted_password_profiles/") + self.assertEqual(0, len(request.data["results"])) + + def test_retrieve_its_own_password_profiles(self): + factories.EncryptedPasswordProfileFactory( + user=self.user, password_profile="encrypted_content" + ) + request = self.client.get("/api/encrypted_password_profiles/") + self.assertEqual(1, len(request.data["results"])) + self.assertEqual( + "encrypted_content", + request.data["results"][0]["password_profile"], + ) + + def test_cant_retrieve_other_password_profiles(self): + not_my_password_profile = factories.EncryptedPasswordProfileFactory( + user=factories.UserFactory() + ) + request = self.client.get( + "/api/encrypted_password_profiles/%s/" % not_my_password_profile.id + ) + self.assertEqual(404, request.status_code) + + def test_delete_its_own_password_profiles(self): + password_profile = factories.EncryptedPasswordProfileFactory(user=self.user) + self.assertEqual(1, models.EncryptedPasswordProfile.objects.all().count()) + request = self.client.delete( + "/api/encrypted_password_profiles/%s/" % password_profile.id + ) + self.assertEqual(204, request.status_code) + self.assertEqual(0, models.EncryptedPasswordProfile.objects.all().count()) + + def test_cant_delete_other_password_profiles(self): + not_my_password_profile = factories.EncryptedPasswordProfileFactory( + user=factories.UserFactory() + ) + self.assertEqual(1, models.EncryptedPasswordProfile.objects.all().count()) + request = self.client.delete( + "/api/encrypted_password_profiles/%s/" % not_my_password_profile.id + ) + self.assertEqual(404, request.status_code) + self.assertEqual(1, models.EncryptedPasswordProfile.objects.all().count()) + + def test_create_password(self): + self.assertEqual(0, models.EncryptedPasswordProfile.objects.count()) + self.client.post( + "/api/encrypted_password_profiles/", + {"password_profile": "test_create_password"}, + ) + self.assertEqual(1, models.EncryptedPasswordProfile.objects.count()) + password_profile = models.EncryptedPasswordProfile.objects.first() + self.assertEqual(password_profile.password_profile, "test_create_password") + + def test_update_password_profile(self): + password_profile = factories.EncryptedPasswordProfileFactory(user=self.user) + self.assertNotEqual( + "test_update_password_profile", password_profile.password_profile + ) + request = self.client.put( + "/api/encrypted_password_profiles/%s/" % password_profile.id, + {"password_profile": "test_update_password_profile"}, + ) + self.assertEqual(200, request.status_code) + password_profile_updated = models.EncryptedPasswordProfile.objects.get( + id=password_profile.id + ) + self.assertEqual( + "test_update_password_profile", password_profile_updated.password_profile + ) + + def test_cant_update_other_password(self): + not_my_password_profile = factories.EncryptedPasswordProfileFactory( + user=factories.UserFactory(), + password_profile="test_cant_update_other_password", + ) + self.assertEqual( + "test_cant_update_other_password", not_my_password_profile.password_profile + ) + request = self.client.put( + "/api/encrypted_password_profiles/%s/" % not_my_password_profile.id, + {"password_profile": "not_my_password_profile"}, + ) + self.assertEqual(404, request.status_code) + self.assertEqual( + "test_cant_update_other_password", + models.EncryptedPasswordProfile.objects.first().password_profile, + ) diff --git a/containers/backend/api/urls.py b/containers/backend/api/urls.py index 7f0859e..b7a20cf 100644 --- a/containers/backend/api/urls.py +++ b/containers/backend/api/urls.py @@ -7,6 +7,11 @@ from api import views router = DefaultRouter() router.register(r"passwords", views.PasswordViewSet, basename="passwords") +router.register( + r"encrypted_password_profiles", + views.EncryptedPasswordProfileViewSet, + basename="encrypted_password_profiles", +) router.register(r"auth/register", djoser.views.UserViewSet, basename="auth_register") urlpatterns = [ diff --git a/containers/backend/api/views.py b/containers/backend/api/views.py index ef1c395..a59350e 100644 --- a/containers/backend/api/views.py +++ b/containers/backend/api/views.py @@ -21,6 +21,17 @@ class PasswordViewSet(viewsets.ModelViewSet): return models.Password.objects.filter(user=self.request.user) +class EncryptedPasswordProfileViewSet(viewsets.ModelViewSet): + serializer_class = serializers.EncryptedPasswordProfileSerializer + permission_classes = ( + permissions.IsAuthenticated, + IsOwner, + ) + + def get_queryset(self): + return models.EncryptedPasswordProfile.objects.filter(user=self.request.user) + + class BackwardCompatibleTokenObtainPairView(TokenObtainPairView): serializer_class = serializers.BackwardCompatibleTokenObtainPairSerializer