From 568724fcad839e5b6418048160742605e770ab9d Mon Sep 17 00:00:00 2001 From: Tudor Amariei Date: Wed, 18 Sep 2024 11:52:48 +0300 Subject: [PATCH] Add NGO Hub login for admins --- backend/donations/views/account_management.py | 15 ++- backend/redirectioneaza/settings.py | 70 ++++++++++++ backend/redirectioneaza/social_adapters.py | 106 ++++++++++++++++++ backend/redirectioneaza/urls.py | 11 +- backend/requirements-dev.txt | 21 ++-- backend/requirements.in | 6 +- backend/requirements.txt | 17 ++- backend/users/context_processors.py | 3 + backend/users/groups_management.py | 26 +++++ .../migrations/0009_user_is_ngohub_user.py | 18 +++ backend/users/models.py | 7 ++ 11 files changed, 281 insertions(+), 19 deletions(-) create mode 100644 backend/redirectioneaza/social_adapters.py create mode 100644 backend/users/migrations/0009_user_is_ngohub_user.py diff --git a/backend/donations/views/account_management.py b/backend/donations/views/account_management.py index 13a40230..baffc9dd 100644 --- a/backend/donations/views/account_management.py +++ b/backend/donations/views/account_management.py @@ -8,6 +8,7 @@ from django.shortcuts import redirect, render from django.urls import reverse from django.utils import timezone +from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ from redirectioneaza.common.messaging import send_email @@ -74,14 +75,22 @@ class LoginView(BaseAccountView): template_name = "account/login.html" def get(self, request, *args, **kwargs): - context = {"title": "Contul meu"} - - # if the user is logged in just redirect + # if the user is logged in, then redirect if request.user.is_authenticated: if request.user.has_perm("users.can_view_old_dashboard"): return redirect(reverse("admin-index")) return redirect(reverse("contul-meu")) + signup_text: str = _("sign up") + signup_link: str = request.build_absolute_uri(reverse("signup")) + signup_url: str = f'{signup_text}' + + # noinspection DjangoSafeString + context = { + "title": "Contul meu", + "signup_url": mark_safe(signup_url), + } + return render(request, self.template_name, context) def post(self, request: HttpRequest): diff --git a/backend/redirectioneaza/settings.py b/backend/redirectioneaza/settings.py index fbe34efd..91057e53 100644 --- a/backend/redirectioneaza/settings.py +++ b/backend/redirectioneaza/settings.py @@ -19,6 +19,7 @@ import environ import sentry_sdk from cryptography.fernet import Fernet +from django.urls import reverse_lazy from django.templatetags.static import static from django.utils import timezone from localflavor.ro.ro_counties import COUNTIES_CHOICES @@ -62,6 +63,7 @@ DATABASE_PORT=(str, "3306"), # site settings APEX_DOMAIN=(str, "redirectioneaza.ro"), + BASE_WEBSITE=(str, "https://redirectioneaza.ro"), SITE_TITLE=(str, "redirectioneaza.ro"), DONATIONS_LIMIT_DAY=(int, 25), DONATIONS_LIMIT_MONTH=(int, 5), @@ -124,6 +126,17 @@ AWS_SES_CONFIGURATION_SET_NAME=(str, None), AWS_SES_AUTO_THROTTLE=(float, 0.5), AWS_SES_REGION_ENDPOINT=(str, ""), + # Cognito + AWS_COGNITO_DOMAIN=(str, ""), + AWS_COGNITO_CLIENT_ID=(str, ""), + AWS_COGNITO_CLIENT_SECRET=(str, ""), + # NGO Hub + NGOHUB_HOME_HOST=(str, "ngohub.ro"), + NGOHUB_APP_HOST=(str, "app-staging.ngohub.ro"), + NGOHUB_API_HOST=(str, "api-staging.ngohub.ro"), + NGOHUB_API_ACCOUNT=(str, ""), + NGOHUB_API_KEY=(str, ""), + UPDATE_ORGANIZATION_METHOD=(str, "async"), # sentry SENTRY_DSN=(str, ""), SENTRY_TRACES_SAMPLE_RATE=(float, 0), @@ -158,6 +171,7 @@ ALLOWED_HOSTS = env.list("ALLOWED_HOSTS") APEX_DOMAIN = env.str("APEX_DOMAIN") +BASE_WEBSITE = env.str("BASE_WEBSITE") CSRF_HEADER_NAME = "HTTP_X_XSRF_TOKEN" CSRF_COOKIE_NAME = "XSRF-TOKEN" @@ -233,6 +247,11 @@ "storages", "django_q", "django_recaptcha", + # authentication + "allauth", + "allauth.account", + "allauth.socialaccount", + "allauth.socialaccount.providers.amazon_cognito", # custom apps: "donations", "partners", @@ -254,6 +273,12 @@ "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", "partners.middleware.PartnerDomainMiddleware", + "allauth.account.middleware.AccountMiddleware", +] + +AUTHENTICATION_BACKENDS = [ + "django.contrib.auth.backends.ModelBackend", # this is the default + "allauth.account.auth_backends.AuthenticationBackend", ] ROOT_URLCONF = "redirectioneaza.urls" @@ -683,3 +708,48 @@ # Feature flags ENABLE_FLAG_CONTACT = env.bool("ENABLE_FLAG_CONTACT", False) + + +LOGIN_URL = reverse_lazy("login") +LOGIN_REDIRECT_URL = reverse_lazy("home") +LOGOUT_REDIRECT_URL = reverse_lazy("home") + +# Django Allauth settings +SOCIALACCOUNT_PROVIDERS = { + "amazon_cognito": { + "DOMAIN": "https://" + env.str("AWS_COGNITO_DOMAIN"), + "EMAIL_AUTHENTICATION": True, # TODO + "VERIFIED_EMAIL": True, # TODO + "APPS": [ + { + "client_id": env.str("AWS_COGNITO_CLIENT_ID"), + "secret": env.str("AWS_COGNITO_CLIENT_SECRET"), + }, + ], + } +} + +# Django Allauth Social Login adapter +SOCIALACCOUNT_ADAPTER = "redirectioneaza.social_adapters.UserOrgAdapter" + +# Django Allauth allow only social logins +SOCIALACCOUNT_ONLY = False +SOCIALACCOUNT_ENABLED = True +ACCOUNT_EMAIL_VERIFICATION = "none" + +# NGO Hub settings +NGOHUB_HOME_HOST = env("NGOHUB_HOME_HOST") +NGOHUB_HOME_BASE = f"https://{env('NGOHUB_HOME_HOST')}/" +NGOHUB_APP_BASE = f"https://{env('NGOHUB_APP_HOST')}/" +NGOHUB_API_HOST = env("NGOHUB_API_HOST") +NGOHUB_API_BASE = f"https://{NGOHUB_API_HOST}/" +NGOHUB_API_ACCOUNT = env("NGOHUB_API_ACCOUNT") +NGOHUB_API_KEY = env("NGOHUB_API_KEY") + +# NGO Hub user roles +NGOHUB_ROLE_SUPER_ADMIN = "super-admin" +NGOHUB_ROLE_NGO_ADMIN = "admin" +NGOHUB_ROLE_NGO_EMPLOYEE = "employee" + +# Configurations for the NGO Hub integration +UPDATE_ORGANIZATION_METHOD = env("UPDATE_ORGANIZATION_METHOD") diff --git a/backend/redirectioneaza/social_adapters.py b/backend/redirectioneaza/social_adapters.py new file mode 100644 index 00000000..7cb6892f --- /dev/null +++ b/backend/redirectioneaza/social_adapters.py @@ -0,0 +1,106 @@ +import logging +from typing import Dict + +from allauth.core.exceptions import ImmediateHttpResponse +from allauth.socialaccount.adapter import DefaultSocialAccountAdapter +from allauth.socialaccount.models import SocialLogin +from allauth.socialaccount.signals import social_account_added +from django.conf import settings +from django.contrib.auth.models import Group +from django.dispatch import receiver +from django.http import HttpRequest +from django.shortcuts import redirect +from django.urls import reverse +from ngohub import NGOHub +from ngohub.exceptions import HubHTTPException + +from users.groups_management import RESTRICTED_ADMIN + +logger = logging.getLogger(__name__) + + +@receiver(social_account_added) +def handle_new_login(sociallogin: SocialLogin, **kwargs) -> None: + """ + Handler for the social-account-added signal, which is sent for the initial login of a new User. + + We must create a User, an Organization and schedule its data update from NGO Hub. + """ + + common_user_init(sociallogin=sociallogin) + + +class UserOrgAdapter(DefaultSocialAccountAdapter): + """ + Authentication adapter which makes sure that each new `User` also has an `NGO` + """ + + def save_user(self, request: HttpRequest, sociallogin: SocialLogin, form=None) -> settings.AUTH_USER_MODEL: + """ + Besides the default user creation, also mark this user as coming from NGO Hub, + create a blank Organization for them, and schedule a data update from NGO Hub. + """ + + user: settings.AUTH_USER_MODEL = super().save_user(request, sociallogin, form) + user.is_ngohub_user = True + user.save() + + common_user_init(sociallogin=sociallogin) + + return user + + +def common_user_init(sociallogin: SocialLogin) -> settings.AUTH_USER_MODEL: + user = sociallogin.user + if user.is_superuser: + return user + + token: str = sociallogin.token.token + + hub = NGOHub(settings.NGOHUB_API_HOST) + + try: + user_profile: Dict = hub.get_profile(user_token=token) + except HubHTTPException: + user_profile = {} + + user_role: str = user_profile.get("role", "") + + # Check the user role from NGO Hub + if user_role == settings.NGOHUB_ROLE_SUPER_ADMIN: + # A super admin from NGO Hub will become a Django admin + user.first_name = user_profile.get("name", "") + user.save() + + user.groups.add(Group.objects.get(name=RESTRICTED_ADMIN)) + + return None + + # TODO: Implement the following roles when we have the create/update organization tasks + # elif user_role == settings.NGOHUB_ROLE_NGO_ADMIN: + # if not hub.check_user_organization_has_application(ngo_token=token, login_link=settings.BASE_WEBSITE): + # if user.is_active: + # user.is_active = False + # user.save() + # + # raise ImmediateHttpResponse(redirect(reverse("error-app-missing"))) + # elif not user.is_active: + # user.is_active = True + # user.save() + # + # # Add the user to the NGO group + # user.groups.add(Group.objects.get(name=NGO_ADMIN)) + # + # org = Ngo.objects.filter(user=user).first() + # if not org: + # org = create_blank_org(user) + # + # return org + # + # elif user_role == settings.NGOHUB_ROLE_NGO_EMPLOYEE: + # # Employees cannot have organizations + # raise ImmediateHttpResponse(redirect(reverse("error-user-role"))) + + else: + # Unknown user role + raise ImmediateHttpResponse(redirect(reverse("error-unknown-user-role"))) diff --git a/backend/redirectioneaza/urls.py b/backend/redirectioneaza/urls.py index 8b0cbf62..643b479e 100644 --- a/backend/redirectioneaza/urls.py +++ b/backend/redirectioneaza/urls.py @@ -18,7 +18,7 @@ from django.conf import settings from django.conf.urls.static import static from django.contrib import admin, admin as django_admin -from django.urls import path, re_path +from django.urls import include, path, re_path, reverse from django.views.generic import RedirectView from donations.views.account_management import ( @@ -140,6 +140,15 @@ re_path(r"^(?P[\w-]+)/semnatura/", FormSignature.as_view(), name="ngo-twopercent-signature"), re_path(r"^(?P[\w-]+)/succes/", DonationSucces.as_view(), name="ngo-twopercent-success"), re_path(r"^(?P[\w-]+)/$", TwoPercentHandler.as_view(), name="twopercent"), + # Skip the login provider selector page and redirect to Cognito + path( + "allauth/login/", + RedirectView.as_view( + url=f'/allauth{reverse("amazon_cognito_login", urlconf="allauth.urls")}', + permanent=True, + ), + ), + path("allauth/", include("allauth.urls")), ] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/backend/requirements-dev.txt b/backend/requirements-dev.txt index 6fad3c90..a111032d 100644 --- a/backend/requirements-dev.txt +++ b/backend/requirements-dev.txt @@ -1,5 +1,5 @@ # -# This file is autogenerated by pip-compile with Python 3.11 +# This file is autogenerated by pip-compile with Python 3.12 # by the following command: # # pip-compile --output-file=requirements-dev.txt --strip-extras requirements-dev.in @@ -12,11 +12,11 @@ black==24.8.0 # via -r requirements-dev.in blessed==1.20.0 # via -r requirements.txt -boto3==1.35.17 +boto3==1.35.20 # via # -r requirements.txt # django-ses -botocore==1.35.17 +botocore==1.35.20 # via # -r requirements.txt # boto3 @@ -57,6 +57,7 @@ cssselect2==0.7.0 django==5.1.1 # via # -r requirements.txt + # django-allauth # django-localflavor # django-picklefield # django-q2 @@ -64,6 +65,8 @@ django==5.1.1 # django-ses # django-storages # django-unfold +django-allauth==64.2.1 + # via -r requirements.txt django-environ==0.11.2 # via -r requirements.txt django-ipware==7.0.1 @@ -82,7 +85,7 @@ django-ses==4.1.1 # via -r requirements.txt django-storages==1.14.4 # via -r requirements.txt -django-unfold==0.38.0 +django-unfold==0.39.0 # via -r requirements.txt execnet==2.1.1 # via pytest-xdist @@ -96,7 +99,7 @@ greenlet==3.1.0 # gevent gunicorn==23.0.0 # via -r requirements.txt -idna==3.8 +idna==3.10 # via # -r requirements.txt # requests @@ -119,6 +122,8 @@ markupsafe==2.1.5 # jinja2 mypy-extensions==1.0.0 # via black +ngohub==0.0.6 + # via -r requirements.txt packaging==24.1 # via # -r requirements.txt @@ -134,7 +139,7 @@ pillow==10.4.0 # reportlab pip-tools==7.4.1 # via -r requirements-dev.in -platformdirs==4.3.2 +platformdirs==4.3.3 # via black pluggy==1.5.0 # via pytest @@ -190,7 +195,7 @@ reportlab==4.2.2 # svglib requests==2.32.3 # via -r requirements.txt -ruff==0.6.4 +ruff==0.6.5 # via -r requirements-dev.in s3transfer==0.10.2 # via @@ -214,7 +219,7 @@ tinycss2==1.3.0 # -r requirements.txt # cssselect2 # svglib -urllib3==2.2.2 +urllib3==2.2.3 # via # -r requirements.txt # botocore diff --git a/backend/requirements.in b/backend/requirements.in index 4c97a70f..f175f7f7 100644 --- a/backend/requirements.in +++ b/backend/requirements.in @@ -1,11 +1,15 @@ django~=5.1.1 django-environ~=0.11.2 django-ipware~=7.0.1 -django-unfold~=0.38.0 +django-unfold~=0.39.0 +django-allauth~=64.2.1 # captcha django-recaptcha~=4.0.0 +# NGO Hub integration +ngohub~=0.0.6 + # encrypting data cryptography~=43.0.1 diff --git a/backend/requirements.txt b/backend/requirements.txt index 97e13f7f..f3a1758e 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,5 +1,5 @@ # -# This file is autogenerated by pip-compile with Python 3.11 +# This file is autogenerated by pip-compile with Python 3.12 # by the following command: # # pip-compile --output-file=requirements.txt --strip-extras requirements.in @@ -8,11 +8,11 @@ asgiref==3.8.1 # via django blessed==1.20.0 # via -r requirements.in -boto3==1.35.17 +boto3==1.35.20 # via # django-ses # django-storages -botocore==1.35.17 +botocore==1.35.20 # via # boto3 # s3transfer @@ -35,6 +35,7 @@ cssselect2==0.7.0 django==5.1.1 # via # -r requirements.in + # django-allauth # django-localflavor # django-picklefield # django-q2 @@ -43,6 +44,8 @@ django==5.1.1 # django-storages # django-unfold # sentry-sdk +django-allauth==64.2.1 + # via -r requirements.in django-environ==0.11.2 # via -r requirements.in django-ipware==7.0.1 @@ -59,7 +62,7 @@ django-ses==4.1.1 # via -r requirements.in django-storages==1.14.4 # via -r requirements.in -django-unfold==0.38.0 +django-unfold==0.39.0 # via -r requirements.in gevent==24.2.1 # via -r requirements.in @@ -67,7 +70,7 @@ greenlet==3.1.0 # via gevent gunicorn==23.0.0 # via -r requirements.in -idna==3.8 +idna==3.10 # via requests jinja2==3.1.4 # via -r requirements.in @@ -79,6 +82,8 @@ lxml==5.3.0 # via svglib markupsafe==2.1.5 # via jinja2 +ngohub==0.0.6 + # via -r requirements.in packaging==24.1 # via gunicorn pillow==10.4.0 @@ -125,7 +130,7 @@ tinycss2==1.3.0 # via # cssselect2 # svglib -urllib3==2.2.2 +urllib3==2.2.3 # via # botocore # requests diff --git a/backend/users/context_processors.py b/backend/users/context_processors.py index 5d3c6784..3317ca63 100644 --- a/backend/users/context_processors.py +++ b/backend/users/context_processors.py @@ -1,3 +1,4 @@ +from django.conf import settings from django.http import HttpRequest @@ -5,4 +6,6 @@ def is_admin(request: HttpRequest): return { "is_admin": request.user.has_perm("users.can_view_old_dashboard"), "is_staff": request.user.is_staff, + "SOCIALACCOUNT_ENABLED": settings.SOCIALACCOUNT_ENABLED, + "SOCIALACCOUNT_ONLY": settings.SOCIALACCOUNT_ONLY, } diff --git a/backend/users/groups_management.py b/backend/users/groups_management.py index 17080e21..caca19df 100644 --- a/backend/users/groups_management.py +++ b/backend/users/groups_management.py @@ -1,13 +1,17 @@ MAIN_ADMIN = "main admin" RESTRICTED_ADMIN = "restricted admin" +NGO_ADMIN = "ngo admin" +NGO_MEMBER = "ngo member" USER_GROUPS = { MAIN_ADMIN: { "is_superuser": True, + "is_staff": True, "permissions": "*", }, RESTRICTED_ADMIN: { "is_superuser": False, + "is_staff": True, "permissions": ( "donations.delete_donor", "donations.view_donor", @@ -24,4 +28,26 @@ "users.can_view_old_dashboard", ), }, + NGO_ADMIN: { + "is_superuser": False, + "is_staff": False, + "permissions": ( + "donations.delete_donor", + "donations.view_donor", + "donations.add_job", + "donations.change_job", + "donations.view_job", + "partners.delete_partner", + "partners.view_partner", + ), + }, + NGO_MEMBER: { + "is_superuser": False, + "is_staff": False, + "permissions": ( + "donations.view_donor", + "donations.view_job", + "partners.view_partner", + ), + }, } diff --git a/backend/users/migrations/0009_user_is_ngohub_user.py b/backend/users/migrations/0009_user_is_ngohub_user.py new file mode 100644 index 00000000..4f54fa65 --- /dev/null +++ b/backend/users/migrations/0009_user_is_ngohub_user.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.13 on 2024-08-09 09:58 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("users", "0008_groupproxy"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="is_ngohub_user", + field=models.BooleanField(default=False), + ), + ] diff --git a/backend/users/models.py b/backend/users/models.py index 83c95019..7327f858 100644 --- a/backend/users/models.py +++ b/backend/users/models.py @@ -65,6 +65,7 @@ class User(AbstractUser): ) email = models.EmailField(verbose_name=_("email address"), blank=False, null=False, unique=True) + is_ngohub_user = models.BooleanField(default=False) ngo = models.ForeignKey( Ngo, @@ -98,6 +99,12 @@ class Meta: ] permissions = (("can_view_old_dashboard", "Can view the old dashboard"),) + def get_cognito_id(self): + social = self.socialaccount_set.filter(provider="amazon_cognito").last() + if social: + return social.uid + return None + def refresh_token(self, commit=True): self.token_timestamp = timezone.now() self.validation_token = uuid.uuid4()