diff --git a/.env b/.env index 5d6cbf605..54a17528a 100644 --- a/.env +++ b/.env @@ -49,3 +49,5 @@ HOST=http://172.17.0.1:8080 USE_DECOS_MOCK_DATA=False SESSION_COOKIE_AGE=25200 AXES_ENABLED=False +BRP_CLIENT_ID = client_id +BRP_CLIENT_SECRET = client_secret diff --git a/.local.env.example b/.local.env.example new file mode 100644 index 000000000..8272b128d --- /dev/null +++ b/.local.env.example @@ -0,0 +1,3 @@ +# Set these in your .local.env file +BRP_CLIENT_ID= +BRP_CLIENT_SECRET= diff --git a/app/apps/addresses/serializers.py b/app/apps/addresses/serializers.py index 7bd84af6a..6213e6404 100644 --- a/app/apps/addresses/serializers.py +++ b/app/apps/addresses/serializers.py @@ -110,6 +110,10 @@ class ResidentsSerializer(serializers.Serializer): _embedded = serializers.DictField() +class GetResidentsSerializer(serializers.Serializer): + obo_access_token = serializers.DictField() + + class MeldingenSerializer(serializers.Serializer): pageNumber = serializers.IntegerField() pageSize = serializers.IntegerField() diff --git a/app/apps/addresses/views.py b/app/apps/addresses/views.py index 7828b2bf6..afffd3748 100644 --- a/app/apps/addresses/views.py +++ b/app/apps/addresses/views.py @@ -4,6 +4,7 @@ from apps.addresses.serializers import ( AddressSerializer, DistrictSerializer, + GetResidentsSerializer, HousingCorporationSerializer, MeldingenSerializer, RegistrationDetailsSerializer, @@ -47,7 +48,7 @@ class AddressViewSet( serializer_class = AddressSerializer queryset = Address.objects.all() lookup_field = "bag_id" - http_method_names = ["get", "patch"] + http_method_names = ["get", "patch", "post"] def update(self, request, bag_id, *args, **kwargs): address_instance = Address.objects.get(bag_id=bag_id) @@ -62,11 +63,12 @@ def update(self, request, bag_id, *args, **kwargs): @action( detail=True, - methods=["get"], + methods=["post"], serializer_class=ResidentsSerializer, url_path="residents", permission_classes=[permissions.CanAccessBRP], ) + @extend_schema(request={GetResidentsSerializer}) def residents_by_bag_id(self, request, bag_id): # Get address try: @@ -86,18 +88,13 @@ def residents_by_bag_id(self, request, bag_id): # nummeraanduiding_id should have been retrieved, so get BRP data if address.nummeraanduiding_id: - try: - brp_data, status_code = get_brp_by_nummeraanduiding_id( - request, address.nummeraanduiding_id - ) - serialized_residents = ResidentsSerializer(data=brp_data) - serialized_residents.is_valid(raise_exception=True) - return Response(serialized_residents.data, status=status_code) - except Exception: - return Response( - {"error": "BRP data could not be obtained"}, - status=status.HTTP_403_FORBIDDEN, - ) + obo_access_token = request.data.get("obo_access_token") + brp_data, status_code = get_brp_by_nummeraanduiding_id( + request, address.nummeraanduiding_id, obo_access_token + ) + serialized_residents = ResidentsSerializer(data=brp_data) + serialized_residents.is_valid(raise_exception=True) + return Response(serialized_residents.data, status=status_code) return Response( {"error": "no nummeraanduiding_id found"}, status=status.HTTP_404_NOT_FOUND diff --git a/app/apps/cases/views/case.py b/app/apps/cases/views/case.py index a7bacc0d0..f28053408 100644 --- a/app/apps/cases/views/case.py +++ b/app/apps/cases/views/case.py @@ -42,7 +42,7 @@ ) from apps.schedules.models import DaySegment, Priority, Schedule, WeekSegment from apps.users.auth_apps import TopKeyAuth -from apps.users.permissions import CanAccessSensitiveCases +from apps.users.permissions import CanAccessSensitiveCases, IsInAuthorizedRealm from apps.workflow.models import CaseUserTask, CaseWorkflow, WorkflowOption from apps.workflow.serializers import ( CaseWorkflowSerializer, @@ -56,7 +56,6 @@ from django_filters import rest_framework as filters from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import OpenApiParameter, extend_schema -from keycloak_oidc.drf.permissions import IsInAuthorizedRealm from rest_framework import mixins, serializers, status, viewsets from rest_framework.decorators import action, parser_classes from rest_framework.pagination import LimitOffsetPagination diff --git a/app/apps/users/admin.py b/app/apps/users/admin.py index 3c350cc40..4945a8e00 100644 --- a/app/apps/users/admin.py +++ b/app/apps/users/admin.py @@ -35,7 +35,6 @@ class UserAdmin(UserAdmin): "is_staff", "is_superuser", "groups", - "user_permissions", ) }, ), @@ -47,3 +46,10 @@ class UserAdmin(UserAdmin): list_display = ("id", "full_name", "email", "is_staff", "last_login", "date_joined") search_fields = ("email",) ordering = ("email",) + readonly_fields = ( + "first_name", + "last_name", + "last_login", + "date_joined", + "username", + ) diff --git a/app/apps/users/auth.py b/app/apps/users/auth.py index ee7632d73..a048a9814 100644 --- a/app/apps/users/auth.py +++ b/app/apps/users/auth.py @@ -1,13 +1,71 @@ import logging +import time from django.conf import settings +from django.core.exceptions import PermissionDenied from drf_spectacular.contrib.rest_framework_simplejwt import SimpleJWTScheme -from keycloak_oidc.auth import OIDCAuthenticationBackend +from mozilla_django_oidc.auth import OIDCAuthenticationBackend from mozilla_django_oidc.contrib.drf import OIDCAuthentication from rest_framework_simplejwt.authentication import JWTAuthentication from .auth_dev import DevelopmentAuthenticationBackend + +class OIDCAuthenticationBackend(OIDCAuthenticationBackend): + def save_user(self, user, claims): + user.first_name = claims.get("given_name", "") + user.last_name = claims.get("family_name", "") + user.save() + return user + + def create_user(self, claims): + user = super(OIDCAuthenticationBackend, self).create_user(claims) + user = self.save_user(user, claims) + return user + + def update_user(self, user, claims): + user = self.save_user(user, claims) + return user + + def validate_issuer(self, payload): + issuer = self.get_settings("OIDC_OP_ISSUER") + if not issuer == payload["iss"]: + raise PermissionDenied( + '"iss": %r does not match configured value for OIDC_OP_ISSUER: %r' + % (payload["iss"], issuer) + ) + + def validate_audience(self, payload): + trusted_audiences = self.get_settings("OIDC_TRUSTED_AUDIENCES", []) + trusted_audiences = set(trusted_audiences) + audience = payload["aud"] + audience = set(audience) + distrusted_audiences = audience.difference(trusted_audiences) + if distrusted_audiences: + raise PermissionDenied( + '"aud" contains distrusted audiences: %r' % distrusted_audiences + ) + + def validate_expiry(self, payload): + expire_time = payload["exp"] + now = time.time() + if now > expire_time: + raise PermissionDenied( + "Access-token is expired %r > %r" % (now, expire_time) + ) + + def validate_access_token(self, payload): + self.validate_issuer(payload) + self.validate_audience(payload) + self.validate_expiry(payload) + return payload + + def get_userinfo(self, access_token, id_token=None, payload=None): + userinfo = self.verify_token(access_token) + self.validate_access_token(userinfo) + return userinfo + + LOGGER = logging.getLogger(__name__) if settings.LOCAL_DEVELOPMENT_AUTHENTICATION: diff --git a/app/apps/users/auth_dev.py b/app/apps/users/auth_dev.py index 8629a2f07..915cb7083 100644 --- a/app/apps/users/auth_dev.py +++ b/app/apps/users/auth_dev.py @@ -2,7 +2,6 @@ from django.conf import settings from django.contrib.auth import get_user_model -from django.contrib.auth.models import Group LOGGER = logging.getLogger(__name__) @@ -33,14 +32,4 @@ def authenticate(self, request): user.first_name = DEFAULT_FIRST_NAME user.last_name = DEFAULT_LAST_NAME user.save() - - realm_access_groups = settings.OIDC_AUTHORIZED_GROUPS - assert ( - realm_access_groups - ), "OIDC_AUTHORIZED_GROUPS access groups must be configured" - - for realm_access_group in realm_access_groups: - group, _ = Group.objects.get_or_create(name=realm_access_group) - group.user_set.add(user) - return user diff --git a/app/apps/users/permissions.py b/app/apps/users/permissions.py index 02790636f..bce2dde28 100644 --- a/app/apps/users/permissions.py +++ b/app/apps/users/permissions.py @@ -1,8 +1,20 @@ from apps.cases.models import Case from apps.users.auth_apps import TonKeyAuth, TopKeyAuth -from keycloak_oidc.drf.permissions import IsInAuthorizedRealm from rest_framework.permissions import BasePermission, IsAuthenticated + +class InAuthGroup(BasePermission): + def has_permission(self, request, view): + return bool(request.user and request.user.is_authenticated) + + +class IsInAuthorizedRealm(InAuthGroup): + """ + A permission to allow access if and only if a user is logged in, + and is a member of one of the OIDC_AUTHORIZED_GROUPS groups in Keycloak + """ + + custom_permissions = [ # Permissions for cases/tasks ("create_case", "Create a new Case"), diff --git a/app/apps/users/tests/tests_auth.py b/app/apps/users/tests/tests_auth.py index db503e6e4..f0a1a5856 100644 --- a/app/apps/users/tests/tests_auth.py +++ b/app/apps/users/tests/tests_auth.py @@ -10,7 +10,7 @@ from django.core.exceptions import SuspiciousOperation from django.test import TestCase -from keycloak_oidc.auth import OIDCAuthenticationBackend +from mozilla_django_oidc.contrib.drf import OIDCAuthenticationBackend from app.utils.unittest_helpers import get_test_user diff --git a/app/apps/users/views.py b/app/apps/users/views.py index a81c918d7..542991dba 100644 --- a/app/apps/users/views.py +++ b/app/apps/users/views.py @@ -1,9 +1,9 @@ import logging +from apps.users.permissions import IsInAuthorizedRealm from django.contrib.auth.models import Permission from django.http import HttpResponseBadRequest from drf_spectacular.utils import extend_schema -from keycloak_oidc.drf.permissions import IsInAuthorizedRealm from rest_framework import generics, serializers, status from rest_framework.decorators import action from rest_framework.response import Response diff --git a/app/apps/workflow/views.py b/app/apps/workflow/views.py index 901e70ac8..8c3c51015 100644 --- a/app/apps/workflow/views.py +++ b/app/apps/workflow/views.py @@ -12,7 +12,7 @@ from apps.main.pagination import EmptyPagination from apps.summons.serializers import SummonTypeSerializer from apps.users.auth_apps import TopKeyAuth -from apps.users.permissions import CanAccessSensitiveCases +from apps.users.permissions import CanAccessSensitiveCases, IsInAuthorizedRealm from apps.workflow.serializers import ( CaseUserTaskSerializer, CaseUserTaskTaskNameSerializer, @@ -24,7 +24,6 @@ from django_filters import rest_framework as filters from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import OpenApiParameter, extend_schema -from keycloak_oidc.drf.permissions import IsInAuthorizedRealm from rest_framework import mixins, serializers, status, viewsets from rest_framework.decorators import action from rest_framework.pagination import LimitOffsetPagination diff --git a/app/config/settings.py b/app/config/settings.py index fb3a3e46b..7bf7f9842 100644 --- a/app/config/settings.py +++ b/app/config/settings.py @@ -5,7 +5,6 @@ from celery.schedules import crontab from dotenv import load_dotenv -from keycloak_oidc.default_settings import * # noqa from opencensus.ext.azure.trace_exporter import AzureExporter from .azure_settings import Azure @@ -14,7 +13,6 @@ load_dotenv() -# config_integration.trace_integrations(["requests", "logging"]) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY") @@ -48,7 +46,6 @@ "django.contrib.postgres", "corsheaders", # Third party apps - "keycloak_oidc", "rest_framework", "rest_framework.authtoken", "drf_spectacular", @@ -162,9 +159,7 @@ "rest_framework.renderers.JSONRenderer", "rest_framework.renderers.BrowsableAPIRenderer", ), - "DEFAULT_PERMISSION_CLASSES": ( - "keycloak_oidc.drf.permissions.IsInAuthorizedRealm", - ), + "DEFAULT_PERMISSION_CLASSES": ("apps.users.permissions.IsInAuthorizedRealm",), "DEFAULT_AUTHENTICATION_CLASSES": ( "apps.users.auth.AuthenticationClass", "rest_framework.authentication.TokenAuthentication", @@ -219,7 +214,7 @@ "level": LOGGING_LEVEL, "propagate": True, }, - "mozilla_django_oidc": {"handlers": ["console"], "level": "INFO"}, + "mozilla_django_oidc": {"handlers": ["console"], "level": LOGGING_LEVEL}, }, } @@ -274,41 +269,38 @@ def filter_traces(envelope): OIDC_AUTHORIZED_GROUPS OIDC_OP_USER_ENDPOINT """ -OIDC_RP_CLIENT_ID = os.environ.get("OIDC_RP_CLIENT_ID", None) OIDC_RP_CLIENT_SECRET = os.environ.get("OIDC_RP_CLIENT_SECRET", None) OIDC_USE_NONCE = False -OIDC_AUTHORIZED_GROUPS = ( - "wonen_zaaksysteem", - "wonen_zaak", - "enable_persistent_token", -) OIDC_AUTHENTICATION_CALLBACK_URL = "oidc-authenticate" - +OIDC_RP_CLIENT_ID = os.environ.get( + "OIDC_RP_CLIENT_ID", "14c4257b-bcd1-4850-889e-7156c9efe2ec" +) OIDC_OP_AUTHORIZATION_ENDPOINT = os.getenv( "OIDC_OP_AUTHORIZATION_ENDPOINT", - "https://acc.iam.amsterdam.nl/auth/realms/datapunt-ad-acc/protocol/openid-connect/auth", + "https://login.microsoftonline.com/72fca1b1-2c2e-4376-a445-294d80196804/oauth2/v2.0/authorize", ) OIDC_OP_TOKEN_ENDPOINT = os.getenv( "OIDC_OP_TOKEN_ENDPOINT", - "https://acc.iam.amsterdam.nl/auth/realms/datapunt-ad-acc/protocol/openid-connect/token", + "https://login.microsoftonline.com/72fca1b1-2c2e-4376-a445-294d80196804/oauth2/v2.0/token", ) OIDC_OP_USER_ENDPOINT = os.getenv( - "OIDC_OP_USER_ENDPOINT", - "https://acc.iam.amsterdam.nl/auth/realms/datapunt-ad-acc/protocol/openid-connect/userinfo", + "OIDC_OP_USER_ENDPOINT", "https://graph.microsoft.com/oidc/userinfo" ) OIDC_OP_JWKS_ENDPOINT = os.getenv( "OIDC_OP_JWKS_ENDPOINT", - "https://acc.iam.amsterdam.nl/auth/realms/datapunt-ad-acc/protocol/openid-connect/certs", + "https://login.microsoftonline.com/72fca1b1-2c2e-4376-a445-294d80196804/discovery/v2.0/keys", ) -OIDC_OP_LOGOUT_ENDPOINT = os.getenv( - "OIDC_OP_LOGOUT_ENDPOINT", - "https://acc.iam.amsterdam.nl/auth/realms/datapunt-ad-acc/protocol/openid-connect/logout", +OIDC_RP_SIGN_ALGO = "RS256" +OIDC_OP_ISSUER = os.getenv( + "OIDC_OP_ISSUER", + "https://sts.windows.net/72fca1b1-2c2e-4376-a445-294d80196804/", ) +OIDC_TRUSTED_AUDIENCES = f"api://{OIDC_RP_CLIENT_ID}" + LOCAL_DEVELOPMENT_AUTHENTICATION = ( os.getenv("LOCAL_DEVELOPMENT_AUTHENTICATION", False) == "True" ) - DATA_UPLOAD_MAX_MEMORY_SIZE = 5242880 DATA_UPLOAD_MAX_NUMBER_FIELDS = 6000 @@ -362,11 +354,13 @@ def filter_traces(envelope): BRP_API_URL = "/".join( [ - os.getenv("BRP_API_URL", "https://acc.bp.data.amsterdam.nl/brp"), + os.getenv("BRP_API_URL", "https://acc.bp.data.amsterdam.nl/entra/brp"), "ingeschrevenpersonen", ] ) +BRP_CLIENT_ID = os.getenv("BRP_CLIENT_ID", "BRP_CLIENT_ID") +BRP_CLIENT_SECRET = os.getenv("BRP_CLIENT_SECRET", "BRP_CLIENT_SECRET") # Secret keys which can be used to access certain parts of the API SECRET_KEY_TOP_ZAKEN = os.getenv("SECRET_KEY_TOP_ZAKEN", None) SECRET_KEY_TON_ZAKEN = os.getenv("SECRET_KEY_TON_ZAKEN", None) diff --git a/app/requirements.txt b/app/requirements.txt index eba0c7cbf..e1982645c 100644 --- a/app/requirements.txt +++ b/app/requirements.txt @@ -14,7 +14,6 @@ click-didyoumean==0.3.1 click-plugins==1.1.1 click-repl==0.2.0 cryptography==43.0.3 -datapunt-keycloak-oidc @ git+https://github.com/remyvdwereld/keycloak_oidc_top.git@main debugpy==1.4.1 Django==4.2.16 django-axes==6.5.0 diff --git a/app/utils/api_queries_brp.py b/app/utils/api_queries_brp.py index 93932cd09..ed3dc05ed 100644 --- a/app/utils/api_queries_brp.py +++ b/app/utils/api_queries_brp.py @@ -2,13 +2,12 @@ import requests from django.conf import settings -from tenacity import after_log, retry, stop_after_attempt from utils.exceptions import MKSPermissionsError logger = logging.getLogger(__name__) -def get_brp_by_nummeraanduiding_id(request, nummeraanduiding_id): +def get_brp_by_nummeraanduiding_id(request, nummeraanduiding_id, obo_access_token): """Returns BRP data by bag_""" queryParams = { @@ -16,7 +15,7 @@ def get_brp_by_nummeraanduiding_id(request, nummeraanduiding_id): "inclusiefoverledenpersonen": "true", "expand": "partners,ouders,kinderen", } - return get_brp(request, queryParams) + return get_brp(queryParams, obo_access_token) def get_brp_by_address(request, postal_code, number, suffix, suffix_letter): @@ -43,18 +42,16 @@ def get_brp_by_address(request, postal_code, number, suffix, suffix_letter): return get_brp(request, queryParams) -@retry(stop=stop_after_attempt(3), after=after_log(logger, logging.ERROR)) -def get_brp(request, queryParams): +def get_brp(queryParams, obo_access_token): """Returns BRP data""" - url = f"{settings.BRP_API_URL}" - + brp_access_token = get_brp_access_token(obo_access_token) response = requests.get( url, params=queryParams, timeout=30, headers={ - "Authorization": request.headers.get("Authorization"), + "Authorization": f"Bearer {brp_access_token}", }, ) if response.status_code == 403: @@ -63,6 +60,21 @@ def get_brp(request, queryParams): return response.json(), response.status_code +def get_brp_access_token(obo_access_token): + url = settings.OIDC_OP_TOKEN_ENDPOINT + payload = { + "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", + "client_id": settings.BRP_CLIENT_ID, + "client_secret": settings.BRP_CLIENT_SECRET, + "assertion": obo_access_token, + "scope": f"{settings.BRP_CLIENT_ID}/.default", + "requested_token_use": "on_behalf_of", + } + + response = requests.request("POST", url, data=payload) + return response.json().get("access_token") + + def get_mock_brp(): return { "message": "mocked data", diff --git a/app/utils/unittest_helpers.py b/app/utils/unittest_helpers.py index bf62d0249..1f7a12413 100644 --- a/app/utils/unittest_helpers.py +++ b/app/utils/unittest_helpers.py @@ -1,4 +1,3 @@ -from django.conf import settings from django.contrib.auth import get_user_model from django.contrib.auth.models import Group, Permission from rest_framework.test import APIClient @@ -11,8 +10,7 @@ def add_user_to_authorized_groups(user): """ Adds users to the authorized groups configured in the OIDC_AUTHORIZED_GROUPS """ - realm_access_groups = settings.OIDC_AUTHORIZED_GROUPS - + realm_access_groups = "all_permissions" all_permissions = Permission.objects.all() for realm_access_group in realm_access_groups: group, _ = Group.objects.get_or_create(name=realm_access_group) diff --git a/docker-compose.local.yml b/docker-compose.local.yml index 5b3df1aed..44e7c1a38 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -35,7 +35,9 @@ services: - database - zaak-redis env_file: - - .env + - path: .env + - path: .local.env + required: false entrypoint: /app/deploy/docker-entrypoint.development.sh command: python -m debugpy --listen 0.0.0.0:5678 ./manage.py runserver 0.0.0.0:8000 volumes: