From de89069d70360cd993b0ded7b169346894fce9aa Mon Sep 17 00:00:00 2001 From: Tudor Date: Fri, 2 Feb 2024 13:58:11 +0200 Subject: [PATCH] Fix encryption issues (#161) * Fix encryption issues * update donations gen * make the change owner operation atomic --- .env.example | 2 + Makefile | 2 +- .../management/commands/generate_donations.py | 40 +++++++++---------- .../management/commands/generate_orgs.py | 1 + ...donor_address_remove_donor_cnp_and_more.py | 31 ++++++++++++++ backend/donations/models/main.py | 26 ++++++++++-- backend/donations/views/admin.py | 4 +- backend/donations/views/my_account.py | 8 ++++ backend/donations/views/ngo.py | 26 +++++++----- backend/redirectioneaza/settings.py | 9 +++++ backend/requirements-dev.txt | 30 +++++++------- backend/requirements.in | 10 +++-- backend/requirements.txt | 27 +++++++------ backend/templates/v1/ngo/donations-view.html | 4 +- backend/templates/v1/ngo/my-account.html | 2 +- docker-compose.yml | 2 +- 16 files changed, 152 insertions(+), 72 deletions(-) create mode 100644 backend/donations/migrations/0008_remove_donor_address_remove_donor_cnp_and_more.py diff --git a/.env.example b/.env.example index 312ea2a9..83806857 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,8 @@ ALLOWED_HOSTS=localhost APEX_DOMAIN=redirectioneaza.ro SECRET_KEY="replace-this-example-key" SENTRY_DSN="" +# key used for encrypting, data; has to be exactly 32 characters long +ECNRYPT_KEY="this-key-should-be-exactly-32-ch" CORS_ALLOWED_ORIGINS=http://localhost:3000 CORS_ALLOW_ALL_ORIGINS=True diff --git a/Makefile b/Makefile index e752ee23..06b8ece1 100644 --- a/Makefile +++ b/Makefile @@ -33,7 +33,7 @@ upd-sqlite: ## run the project with sqlite in detached mod docker compose --profile sqlite3 up -d --build up-mysql: ## run the project with mysql - docker compose --profile mysql up --build`` + docker compose --profile mysql up --build upd-mysql: ## run the project with mysql in detached mode docker compose --profile mysql up -d --build diff --git a/backend/donations/management/commands/generate_donations.py b/backend/donations/management/commands/generate_donations.py index 0529cba8..c9fdc384 100644 --- a/backend/donations/management/commands/generate_donations.py +++ b/backend/donations/management/commands/generate_donations.py @@ -1,6 +1,6 @@ import random import string -from typing import Any, Dict, List +from typing import List from django.core.management import BaseCommand from faker import Faker @@ -33,45 +33,43 @@ def handle(self, *args, **options): target_org = options.get("org", None) self.stdout.write(f"Generating {total_donations} donations") - # create a list of all the NGOs if not target_org: ngos = list(Ngo.objects.filter(is_active=True)) else: ngos = [Ngo.objects.get(id=target_org)] - generated_donations: List[Dict[str, Any]] = [] + generated_donations: List[Donor] = [] while len(generated_donations) < total_donations: # pick a random NGO ngo = ngos[random.randint(0, len(ngos) - 1)] # generate a random donor - donor = { - "ngo": ngo, - "first_name": fake.first_name(), - "last_name": fake.last_name(), - "initial": fake.first_name()[0], - "cnp": fake.ssn(), - "email": fake.email(), - "phone": fake.phone_number(), - "address": { + donor = Donor( + ngo=ngo, + first_name=fake.first_name(), + last_name=fake.last_name(), + initial=random.choice(string.ascii_uppercase), + email=fake.email(), + phone=fake.phone_number(), + city=fake.city(), + county=COUNTIES_CHOICES[random.randint(0, len(COUNTIES_CHOICES) - 1)][1], + income_type="wage", + ) + donor.set_cnp(fake.ssn()) + donor.set_address( + { "street": fake.street_address(), "number": fake.building_number(), "bl": random.choice(["", random.randint(1, 20)]), "sc": random.choice(["", random.choice(string.ascii_uppercase)]), "et": random.choice(["", random.randint(1, 20)]), "ap": random.choice(["", random.randint(1, 200)]), - }, - "city": fake.city(), - "county": COUNTIES_CHOICES[random.randint(0, len(COUNTIES_CHOICES) - 1)][1], - "income_type": "wage", - } + } + ) - # generate a random donation generated_donations.append(donor) - # write to the database self.stdout.write(self.style.SUCCESS("Writing to the database...")) - - Donor.objects.bulk_create([Donor(**donation) for donation in generated_donations]) + Donor.objects.bulk_create(generated_donations, batch_size=10) self.stdout.write(self.style.SUCCESS("Done!")) diff --git a/backend/donations/management/commands/generate_orgs.py b/backend/donations/management/commands/generate_orgs.py index cd9556f6..2d5a58b6 100644 --- a/backend/donations/management/commands/generate_orgs.py +++ b/backend/donations/management/commands/generate_orgs.py @@ -198,6 +198,7 @@ def handle(self, *args, **options): "email": random.choice([owner_email, fake.email()]), "website": fake.url(), "is_active": create_valid or random.choice([True, False]), + "is_accepting_forms": create_valid or random.choice([True, False]), } try: org = Ngo.objects.create(**organization_details) diff --git a/backend/donations/migrations/0008_remove_donor_address_remove_donor_cnp_and_more.py b/backend/donations/migrations/0008_remove_donor_address_remove_donor_cnp_and_more.py new file mode 100644 index 00000000..2eb0864d --- /dev/null +++ b/backend/donations/migrations/0008_remove_donor_address_remove_donor_cnp_and_more.py @@ -0,0 +1,31 @@ +# Generated by Django 4.2.9 on 2024-02-01 17:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("donations", "0007_donor_address"), + ] + + operations = [ + migrations.RemoveField( + model_name="donor", + name="address", + ), + migrations.RemoveField( + model_name="donor", + name="cnp", + ), + migrations.AddField( + model_name="donor", + name="encrypted_address", + field=models.TextField(blank=True, default="", verbose_name="address"), + ), + migrations.AddField( + model_name="donor", + name="encrypted_cnp", + field=models.TextField(blank=True, default="", verbose_name="CNP"), + ), + ] diff --git a/backend/donations/models/main.py b/backend/donations/models/main.py index bac03626..253d1d3d 100644 --- a/backend/donations/models/main.py +++ b/backend/donations/models/main.py @@ -1,4 +1,5 @@ import hashlib +import json from functools import partial from django.conf import settings @@ -8,7 +9,6 @@ from django.db.models.functions import Lower from django.utils import timezone from django.utils.translation import gettext_lazy as _ -from django_cryptography.fields import encrypt def select_public_storage(): @@ -216,7 +216,7 @@ class Donor(models.Model): last_name = models.CharField(verbose_name=_("last name"), blank=True, null=False, default="", max_length=100) initial = models.CharField(verbose_name=_("initials"), blank=True, null=False, default="", max_length=5) - cnp = encrypt(models.CharField(verbose_name=_("CNP"), blank=True, null=False, default="", max_length=13)) + encrypted_cnp = models.TextField(verbose_name=_("CNP"), blank=True, null=False, default="") city = models.CharField( verbose_name=_("city"), @@ -234,7 +234,7 @@ class Donor(models.Model): max_length=100, db_index=True, ) - address = models.JSONField(verbose_name=_("address"), blank=True, null=False, default=dict) + encrypted_address = models.TextField(verbose_name=_("address"), blank=True, null=False, default="") # originally: tel phone = models.CharField(verbose_name=_("telephone"), blank=True, null=False, default="", max_length=30) @@ -285,3 +285,23 @@ class Meta: def __str__(self): return f"{self.ngo} {self.date_created} {self.email}" + + def set_cnp(self, cnp: str): + self.encrypted_cnp = settings.FERNET_OBJECT.encrypt(cnp.encode()).decode() + + def get_cnp(self) -> str: + return self.decrypt_cnp(self.encrypted_cnp) + + def set_address(self, address: dict): + self.encrypted_address = settings.FERNET_OBJECT.encrypt(str(address).encode()).decode() + + def get_address(self) -> dict: + return self.decrypt_address(self.encrypted_address) + + @staticmethod + def decrypt_cnp(cnp: str) -> str: + return settings.FERNET_OBJECT.decrypt(cnp.encode()).decode() + + @staticmethod + def decrypt_address(address): + return json.loads(settings.FERNET_OBJECT.decrypt(address.encode()).decode()) diff --git a/backend/donations/views/admin.py b/backend/donations/views/admin.py index f594f3d2..f2a6eb7a 100644 --- a/backend/donations/views/admin.py +++ b/backend/donations/views/admin.py @@ -68,8 +68,8 @@ def get(self, request, *args, **kwargs): donations = Donor.objects.filter(date_created__gte=from_date).all() stats = deepcopy(stats_dict) - stats["ngos"] = len(ngos) + stats_dict["ngos"] - stats["forms"] = len(donations) + stats_dict["forms"] + stats["ngos"] = ngos.count() + stats_dict["ngos"] + stats["forms"] = donations.count() + stats_dict["forms"] self.add_data(stats, ngos, donations) diff --git a/backend/donations/views/my_account.py b/backend/donations/views/my_account.py index 13982fb4..d295f6a7 100644 --- a/backend/donations/views/my_account.py +++ b/backend/donations/views/my_account.py @@ -4,6 +4,7 @@ from django.contrib.auth import get_user_model from django.contrib.auth.decorators import login_required from django.core.validators import validate_email +from django.db import transaction from django.db.models import Q, QuerySet from django.shortcuts import redirect, render from django.urls import reverse, reverse_lazy @@ -56,12 +57,18 @@ def get(self, request, *args, **kwargs): grouped_donors[index] = [] grouped_donors[index].append(donor) + now = timezone.now() + can_donate = not now.date() > settings.DONATIONS_LIMIT + context = { "user": request.user, "limit": settings.DONATIONS_LIMIT, "ngo": user_ngo, "donors": grouped_donors, "counties": settings.FORM_COUNTIES, + "can_donate": can_donate, + "has_signed_form": user_ngo.is_accepting_forms, + "current_year": timezone.now().year, } return render(request, self.template_name, context) @@ -158,6 +165,7 @@ def post(self, request, *args, **kwargs): return redirect(reverse("association")) @staticmethod + @transaction.atomic def change_ngo_owner(ngo, new_ngo_owner): try: validate_email(new_ngo_owner) diff --git a/backend/donations/views/ngo.py b/backend/donations/views/ngo.py index de33cd1e..09558486 100644 --- a/backend/donations/views/ngo.py +++ b/backend/donations/views/ngo.py @@ -321,27 +321,33 @@ def get_post_value(arg, add_to_error_list=True): donor = Donor( first_name=donor_dict["first_name"], last_name=donor_dict["last_name"], + initial=donor_dict["father"], city=donor_dict["city"], county=donor_dict["county"], - address={ - "street": donor_dict["street"], - "number": donor_dict["number"], - "bl": donor_dict["bl"], - "sc": donor_dict["sc"], - "et": donor_dict["et"], - "ap": donor_dict["ap"], - }, - email=donor_dict["email"], phone=donor_dict["tel"], + email=donor_dict["email"], is_anonymous=donor_dict["anonymous"], - two_years=two_years, income_type=donor_dict["income"], + two_years=two_years, # TODO: # make a request to get geo ip data for this user # geoip = self.get_geoip_data(), ngo=ngo, # TODO: 'filename' is unused ) + + donor.set_cnp(donor_dict["cnp"]) + donor.set_address( + { + "street": donor_dict["street"], + "number": donor_dict["number"], + "bl": donor_dict["bl"], + "sc": donor_dict["sc"], + "et": donor_dict["et"], + "ap": donor_dict["ap"], + } + ) + donor.save() pdf = create_pdf(donor_dict, ngo_data) diff --git a/backend/redirectioneaza/settings.py b/backend/redirectioneaza/settings.py index ceb7402e..ae50c795 100644 --- a/backend/redirectioneaza/settings.py +++ b/backend/redirectioneaza/settings.py @@ -11,12 +11,14 @@ """ import os +from base64 import urlsafe_b64encode from copy import deepcopy from datetime import date, datetime from pathlib import Path import environ import sentry_sdk +from cryptography.fernet import Fernet from django.utils import timezone from localflavor.ro.ro_counties import COUNTIES_CHOICES @@ -461,3 +463,10 @@ ZIP_ENDPOINT = env.str("ZIP_ENDPOINT") ZIP_SECRET = env.str("ZIP_SECRET") + + +# encryption +ECNRYPT_KEY = env.str("ECNRYPT_KEY") +if len(ECNRYPT_KEY) != 32: + raise Exception("ECNRYPT_KEY must be exactly 32 characters long") +FERNET_OBJECT = Fernet(urlsafe_b64encode(ECNRYPT_KEY.encode("utf-8"))) diff --git a/backend/requirements-dev.txt b/backend/requirements-dev.txt index 7c940611..5fcee452 100644 --- a/backend/requirements-dev.txt +++ b/backend/requirements-dev.txt @@ -24,6 +24,7 @@ build==1.0.3 certifi==2023.11.17 # via # -r requirements.txt + # requests # sentry-sdk cffi==1.16.0 # via @@ -33,16 +34,18 @@ chardet==5.2.0 # via # -r requirements.txt # reportlab +charset-normalizer==3.3.2 + # via + # -r requirements.txt + # requests click==8.1.7 # via # black # pip-tools croniter==2.0.1 # via -r requirements.txt -cryptography==42.0.1 - # via - # -r requirements.txt - # django-cryptography +cryptography==42.0.2 + # via -r requirements.txt cssselect2==0.7.0 # via # -r requirements.txt @@ -50,19 +53,11 @@ cssselect2==0.7.0 django==4.2.9 # via # -r requirements.txt - # django-appconf - # django-cryptography # django-localflavor # django-picklefield # django-q2 # django-recaptcha # django-storages -django-appconf==1.0.6 - # via - # -r requirements.txt - # django-cryptography -django-cryptography==1.1 - # via -r requirements.txt django-environ==0.11.2 # via -r requirements.txt django-localflavor==4.0 @@ -87,6 +82,10 @@ greenlet==3.0.3 # gevent gunicorn==21.2.0 # via -r requirements.txt +idna==3.6 + # via + # -r requirements.txt + # requests jinja2==3.1.3 # via -r requirements.txt jmespath==1.0.1 @@ -128,7 +127,7 @@ pycparser==2.21 # cffi pymysql==1.1.0 # via -r requirements.txt -pypdf==4.0.0 +pypdf==4.0.1 # via -r requirements.txt pyproject-hooks==1.0.0 # via build @@ -156,13 +155,15 @@ reportlab==4.0.9 # via # -r requirements.txt # svglib +requests==2.31.0 + # via -r requirements.txt ruff==0.1.14 # via -r requirements-dev.in s3transfer==0.10.0 # via # -r requirements.txt # boto3 -sentry-sdk==1.39.2 +sentry-sdk==1.40.0 # via -r requirements.txt six==1.16.0 # via @@ -184,6 +185,7 @@ urllib3==2.0.7 # via # -r requirements.txt # botocore + # requests # sentry-sdk wcwidth==0.2.12 # via diff --git a/backend/requirements.in b/backend/requirements.in index 2907687d..6e389247 100644 --- a/backend/requirements.in +++ b/backend/requirements.in @@ -1,6 +1,5 @@ django~=4.2.9,<5.0 django-environ~=0.11.2 -django-cryptography~=1.1 # captcha django-recaptcha==4.0.0 @@ -13,10 +12,10 @@ croniter~=2.0.1 # optional requirement for django-q2 # MySQL database pymysql~=1.1.0 -cryptography~=42.0.1 +cryptography==42.0.2 # Sentry -sentry-sdk[django]~=1.39.2 +sentry-sdk[django]==1.40.0 # Jinja2 jinja2~=3.1.3 @@ -37,5 +36,8 @@ django-localflavor~=4.0.0 # pdf creation reportlab~=4.0.9 -pypdf~=4.0.0 +pypdf==4.0.1 svglib~=1.5.1 + +# requests +requests==2.31.0 diff --git a/backend/requirements.txt b/backend/requirements.txt index 78cd54ce..508e5bdc 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -15,34 +15,30 @@ botocore==1.34.9 # boto3 # s3transfer certifi==2023.11.17 - # via sentry-sdk + # via + # requests + # sentry-sdk cffi==1.16.0 # via cryptography chardet==5.2.0 # via reportlab +charset-normalizer==3.3.2 + # via requests croniter==2.0.1 # via -r requirements.in -cryptography==42.0.1 - # via - # -r requirements.in - # django-cryptography +cryptography==42.0.2 + # via -r requirements.in cssselect2==0.7.0 # via svglib django==4.2.9 # via # -r requirements.in - # django-appconf - # django-cryptography # django-localflavor # django-picklefield # django-q2 # django-recaptcha # django-storages # sentry-sdk -django-appconf==1.0.6 - # via django-cryptography -django-cryptography==1.1 - # via -r requirements.in django-environ==0.11.2 # via -r requirements.in django-localflavor==4.0 @@ -63,6 +59,8 @@ greenlet==3.0.3 # via gevent gunicorn==21.2.0 # via -r requirements.in +idna==3.6 + # via requests jinja2==3.1.3 # via -r requirements.in jmespath==1.0.1 @@ -83,7 +81,7 @@ pycparser==2.21 # via cffi pymysql==1.1.0 # via -r requirements.in -pypdf==4.0.0 +pypdf==4.0.1 # via -r requirements.in python-dateutil==2.8.2 # via @@ -101,9 +99,11 @@ reportlab==4.0.9 # via # -r requirements.in # svglib +requests==2.31.0 + # via -r requirements.in s3transfer==0.10.0 # via boto3 -sentry-sdk==1.39.2 +sentry-sdk==1.40.0 # via # -r requirements.in # sentry-sdk @@ -122,6 +122,7 @@ tinycss2==1.2.1 urllib3==2.0.7 # via # botocore + # requests # sentry-sdk wcwidth==0.2.12 # via blessed diff --git a/backend/templates/v1/ngo/donations-view.html b/backend/templates/v1/ngo/donations-view.html index 89892c7d..18cc61e0 100644 --- a/backend/templates/v1/ngo/donations-view.html +++ b/backend/templates/v1/ngo/donations-view.html @@ -2,7 +2,7 @@
-

Mai jos găsești o listă cu toate persoanele care au completat formularul de redirectionare:

+

Mai jos găsești o listă cu toate persoanele care au completat formularul de redirecționare:

{% for key, value in donors.items() %} @@ -101,7 +101,7 @@

- Din păcate nu exită nici un document completat pentru acest an. + Din păcate nu există nici un document completat pentru acest an.

diff --git a/backend/templates/v1/ngo/my-account.html b/backend/templates/v1/ngo/my-account.html index 2af27f11..9e38d409 100644 --- a/backend/templates/v1/ngo/my-account.html +++ b/backend/templates/v1/ngo/my-account.html @@ -36,7 +36,7 @@
-
+
diff --git a/docker-compose.yml b/docker-compose.yml index 460306e2..e465056b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -40,7 +40,7 @@ services: - "${WEBAPP_PORT:-8080}:8000" zippy: - profiles: [ "_base", "sqlite3", "mysql" ] + profiles: [ "_base", "zippy" ] extends: file: dkcp.base.yml service: zippy_base