diff --git a/.env b/.env index c16a1d405..e428317ca 100644 --- a/.env +++ b/.env @@ -15,22 +15,23 @@ PUBLIC_MATOMO_CDN_URL="https://stats.data.gouv.fr/" PUBLIC_MATOMO_SITE_ID="315" #### FranceConnect login proxy. This should be empty in production. -PUBLIC_FC_PROXY="" +PUBLIC_FC_PROXY_BASE_URL="" #### FranceConnect AMI variables FC_AMI_CLIENT_ID="33fe498cc172fe691778912a2967baa650b24f1ae0ebbe47ae552f37b2d25ead" FC_AMI_CLIENT_SECRET="fake secret for AMI" - -#### Pro Connect AMI admin variables -PRO_CONNECT_AGENT_ADMIN_CLIENT_ID="1c68542c1a7a427dd526c78e02293a3d1c101709c08294e691ce28926dd48ee1" -PRO_CONNECT_AGENT_ADMIN_CLIENT_SECRET="fake secret for AMI admin" - -#### FranceConnect variables PUBLIC_FC_BASE_URL="https://fcp-low.sbx.dev-franceconnect.fr" PUBLIC_FC_LOGOUT_ENDPOINT="/api/v2/session/end" FC_SCOPE="openid identite_pivot preferred_username email cnaf_enfants cnaf_adresse" -#### Pro Connect variables +#### AMI FI variables +FI_CLIENT_ID="cad76d3d47ff27acfe961b6fb72b4ac035a9d840e3608483135acc7eb5267d48" +FI_CLIENT_SECRET="fake secret for AMI-FI" +FI_IDP_ID="fe64c556-9728-49e2-83cc-a72acc546ced" + +#### Pro Connect AMI admin variables +PRO_CONNECT_AGENT_ADMIN_CLIENT_ID="1c68542c1a7a427dd526c78e02293a3d1c101709c08294e691ce28926dd48ee1" +PRO_CONNECT_AGENT_ADMIN_CLIENT_SECRET="fake secret for AMI admin" PRO_CONNECT_BASE_URL="https://fca.integ01.dev-agentconnect.fr" #### API Particulier variables diff --git a/.env.development b/.env.development index 066f36029..d9a6b1e5c 100644 --- a/.env.development +++ b/.env.development @@ -1,5 +1,6 @@ DEBUG="true" DJANGO_SECRET_KEY="django-insecure-08fl(&lb$**45l!h!n$e!n(+)$+#p-gnw-d7$msk^^73xj$d" PUBLIC_MATOMO_ENABLED="false" +FI_HASH_SALT="insecure-salt" PUBLIC_WEBSITE_PUBLIC="Website is public" PUBLIC_FEATUREFLAG_REQUESTS_ENABLED="true" diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 0d08fde22..53e0c5d7b 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -29,7 +29,7 @@ jobs: AUTH_COOKIE_JWT_SECRET: secret PARTNERS_PSL_SECRET: secret PUBLIC_WEBSITE_PUBLIC: true - PUBLIC_FC_PROXY: https://ami-fc-proxy-dev.osc-fr1.scalingo.io/ + PUBLIC_FC_PROXY_BASE_URL: https://ami-fc-proxy-dev.osc-fr1.scalingo.io run: make lint-and-format run-tests: @@ -73,7 +73,7 @@ jobs: - name: Run tests env: - PUBLIC_FC_PROXY: https://ami-fc-proxy-dev.osc-fr1.scalingo.io/ + PUBLIC_FC_PROXY_BASE_URL: https://ami-fc-proxy-dev.osc-fr1.scalingo.io run: make test-ci mobile-app-tests: @@ -103,5 +103,5 @@ jobs: AUTH_COOKIE_JWT_SECRET: secret PARTNERS_PSL_SECRET: secret PUBLIC_WEBSITE_PUBLIC: true - PUBLIC_FC_PROXY: https://ami-fc-proxy-dev.osc-fr1.scalingo.io/ + PUBLIC_FC_PROXY_BASE_URL: https://ami-fc-proxy-dev.osc-fr1.scalingo.io run: npm test diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8526df75d..9c6e25f54 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -240,7 +240,7 @@ It needs to be set through Scalingo in the staging and review apps, and for local development, it needs to be set in the `.env.local` file. ``` -PUBLIC_FC_PROXY="https://ami-fc-proxy-dev.osc-fr1.scalingo.io/" +PUBLIC_FC_PROXY_BASE_URL="https://ami-fc-proxy-dev.osc-fr1.scalingo.io" ``` ## agent-admin space ("Espace Partenaire AMI") diff --git a/ami/api/urls.py b/ami/api/urls.py index e581a708b..f9c2e8835 100644 --- a/ami/api/urls.py +++ b/ami/api/urls.py @@ -4,6 +4,7 @@ from ami.agenda import api_urls as agenda_api_urls from ami.authentication import api_urls as authentication_api_urls +from ami.fi import api_urls as fi_api_urls from ami.followup import api_urls as followup_api_urls from ami.notification import api_urls as notification_api_urls from ami.partner import api_urls as partner_api_urls @@ -12,6 +13,7 @@ urlpatterns = [ path("", include(authentication_api_urls)), + path("api/v1/fi/", include(fi_api_urls)), path("", include(util_api_urls)), path("", include(notification_api_urls.root_urlpatterns)), path("api/v1/", include(agenda_api_urls.root_urlpatterns)), diff --git a/ami/authentication/api_views.py b/ami/authentication/api_views.py index a9e865f0a..13e37900e 100644 --- a/ami/authentication/api_views.py +++ b/ami/authentication/api_views.py @@ -19,4 +19,5 @@ def logout(request): RevokedAuthToken.objects.create(jti=request.ami_payload["jti"]) response = Response({}, status=201) response.delete_cookie(settings.AUTH_COOKIE_JWT_NAME) + response.delete_cookie(settings.USERINFO_COOKIE_JWT_NAME) return response diff --git a/ami/authentication/auth.py b/ami/authentication/auth.py index dde5ab5ee..208d988b7 100644 --- a/ami/authentication/auth.py +++ b/ami/authentication/auth.py @@ -65,7 +65,9 @@ async def get_fc_token( await nonce.adelete() # FC - Step 5 - redirect_uri: str = settings.PUBLIC_FC_PROXY or settings.FC_AMI_REDIRECT_URL + redirect_uri: str = settings.FC_AMI_REDIRECT_URL + if settings.PUBLIC_FC_PROXY_BASE_URL: + redirect_uri = settings.PUBLIC_FC_PROXY_BASE_URL + "/" client_id: str = settings.FC_AMI_CLIENT_ID data: dict[str, str] = { "grant_type": "authorization_code", diff --git a/ami/authentication/tests/conftest.py b/ami/authentication/tests/conftest.py index 376db6c2f..f7258d531 100644 --- a/ami/authentication/tests/conftest.py +++ b/ami/authentication/tests/conftest.py @@ -15,22 +15,3 @@ def decoded_id_token() -> dict[str, Any]: "iat": 1763455959, "iss": "https://fcp-low.sbx.dev-franceconnect.fr/api/v2", } - - -@pytest.fixture -def userinfo() -> dict[str, Any]: - return { - "sub": "fake sub", - "given_name": "Angela Claire Louise", - "given_name_array": ["Angela", "Claire", "Louise"], - "family_name": "DUBOIS", - "birthdate": "1962-08-24", - "birthcountry": "99100", - "birthplace": "75107", - "gender": "female", - "email": "angela@dubois.fr", - "aud": "fake aud", - "exp": 1753877658, - "iat": 1753877598, - "iss": "https://fcp-low.sbx.dev-franceconnect.fr/api/v2", - } diff --git a/ami/authentication/tests/test_login.py b/ami/authentication/tests/test_login.py index 25852383e..af9ed475b 100644 --- a/ami/authentication/tests/test_login.py +++ b/ami/authentication/tests/test_login.py @@ -10,10 +10,10 @@ def test_login_france_connect( app, monkeypatch: pytest.MonkeyPatch, ) -> None: + settings.PUBLIC_FC_PROXY_BASE_URL = "https://ami-fc-proxy-dev.osc-fr1.scalingo.io" FAKE_NONCE = "some-random-nonce" monkeypatch.setattr("ami.authentication.views.generate_nonce", lambda: FAKE_NONCE) response = app.get("/login-france-connect") - redirected_url = response.headers["location"] assert Nonce.objects.count() == 1 nonce = Nonce.objects.get() assert nonce.nonce == FAKE_NONCE @@ -29,7 +29,7 @@ def test_login_france_connect( ) assert url_contains_param( "redirect_uri", - settings.PUBLIC_FC_PROXY or settings.FC_AMI_REDIRECT_URL, + f"{settings.PUBLIC_FC_PROXY_BASE_URL}/", redirected_url, ) assert url_contains_param("response_type", "code", redirected_url) diff --git a/ami/authentication/tests/test_login_callback.py b/ami/authentication/tests/test_login_callback.py index 96a451c2a..24fc03823 100644 --- a/ami/authentication/tests/test_login_callback.py +++ b/ami/authentication/tests/test_login_callback.py @@ -94,6 +94,11 @@ def fake_jwt_decode(*args: Any, **params: Any): assert token assert token["jti"] is not None + assert ( + response.client.cookies[settings.USERINFO_COOKIE_JWT_NAME].value + == '"fake userinfo jwt token"' + ) + assert Nonce.objects.count() == 0 assert User.objects.count() == 1 diff --git a/ami/authentication/tests/test_logout.py b/ami/authentication/tests/test_logout.py index 51269addf..9fed95f58 100644 --- a/ami/authentication/tests/test_logout.py +++ b/ami/authentication/tests/test_logout.py @@ -20,6 +20,7 @@ def test_logout( response = app.post("/logout") assert response.status_code == 201 assert not response.client.cookies.get(settings.AUTH_COOKIE_JWT_NAME) + assert not response.client.cookies.get(settings.USERINFO_COOKIE_JWT_NAME) assert RevokedAuthToken.objects.count() == 1 revoked_auth_token = RevokedAuthToken.objects.get() assert revoked_auth_token.jti == token["jti"] diff --git a/ami/authentication/urls.py b/ami/authentication/urls.py index 5f033b91a..2953fcc46 100644 --- a/ami/authentication/urls.py +++ b/ami/authentication/urls.py @@ -4,5 +4,6 @@ urlpatterns = [ path("login-france-connect", views.login_france_connect), + path("login-ami-fi", views.login_ami_fi), path("login-callback", views.login_callback), ] diff --git a/ami/authentication/views.py b/ami/authentication/views.py index 0911c35e9..fd395fba0 100644 --- a/ami/authentication/views.py +++ b/ami/authentication/views.py @@ -33,16 +33,18 @@ def login_france_connect(request): NONCE = generate_nonce() nonce = Nonce.objects.create(nonce=NONCE) + redirect_uri: str = settings.FC_AMI_REDIRECT_URL + if settings.PUBLIC_FC_PROXY_BASE_URL: + redirect_uri = f"{settings.PUBLIC_FC_PROXY_BASE_URL}/" + state: str = str(nonce.id) + if settings.PUBLIC_FC_PROXY_BASE_URL: + state = f"{settings.FC_AMI_REDIRECT_URL}?state={nonce.id}" params = { "scope": settings.FC_SCOPE, - "redirect_uri": settings.PUBLIC_FC_PROXY or settings.FC_AMI_REDIRECT_URL, + "redirect_uri": redirect_uri, "response_type": "code", "client_id": settings.FC_AMI_CLIENT_ID, - "state": ( - f"{settings.FC_AMI_REDIRECT_URL}?state={nonce.id}" - if settings.PUBLIC_FC_PROXY - else str(nonce.id) - ), + "state": state, "nonce": NONCE, "acr_values": "eidas1", "prompt": "login", @@ -57,6 +59,47 @@ def login_france_connect(request): return redirect(f"{settings.PUBLIC_APP_URL}/#/technical-error") +@require_GET +def login_ami_fi(request): + try: + NONCE = generate_nonce() + nonce = Nonce.objects.create(nonce=NONCE) + + redirect_uri: str = settings.FC_AMI_REDIRECT_URL + if settings.PUBLIC_FC_PROXY_BASE_URL: + redirect_uri = f"{settings.PUBLIC_FC_PROXY_BASE_URL}/" + state: str = str(nonce.id) + if settings.PUBLIC_FC_PROXY_BASE_URL: + state = f"{settings.FC_AMI_REDIRECT_URL}?state={nonce.id}" + params = { + "scope": settings.FC_SCOPE, + "redirect_uri": redirect_uri, + "response_type": "code", + "client_id": settings.FC_AMI_CLIENT_ID, + "state": state, + "nonce": NONCE, + "acr_values": "eidas1", + "prompt": "login", + "idp_hint": settings.FI_IDP_ID, + } + + login_url = ( + f"{settings.PUBLIC_FC_BASE_URL}{settings.FC_AUTHORIZATION_ENDPOINT}?{urlencode(params)}" + ) + if settings.PUBLIC_FC_PROXY_BASE_URL: + params = { + "from_url": f"{settings.PUBLIC_API_URL}/", + "fc_url": login_url, + } + login_url = ( + f"{settings.PUBLIC_FC_PROXY_BASE_URL}/ami-fi-authorize-request/?{urlencode(params)}" + ) + return redirect(login_url) + except Exception as e: + logging.exception(e) + return redirect(f"{settings.PUBLIC_APP_URL}/#/technical-error") + + @require_GET async def login_callback(request): try: @@ -138,6 +181,14 @@ async def login_callback(request): httponly=True, samesite="None", ) + response.set_cookie( + key=settings.USERINFO_COOKIE_JWT_NAME, + value=userinfo_result["user_data"], + max_age=365 * 10 * 24 * 3600, + secure=True, + httponly=True, + samesite="None", + ) return response except FCError as e: diff --git a/ami/conftest.py b/ami/conftest.py index 1b1cd19e5..5a64b4d5a 100644 --- a/ami/conftest.py +++ b/ami/conftest.py @@ -109,6 +109,25 @@ def otv_cert_keys_for_signature() -> Dict[str, str]: } +@pytest.fixture +def userinfo() -> dict[str, Any]: + return { + "sub": "fake sub", + "given_name": "Angela Claire Louise", + "given_name_array": ["Angela", "Claire", "Louise"], + "family_name": "DUBOIS", + "birthdate": "1962-08-24", + "birthcountry": "99100", + "birthplace": "75107", + "gender": "female", + "email": "angela@dubois.fr", + "aud": "fake aud", + "exp": 1753877658, + "iat": 1753877598, + "iss": "https://fcp-low.sbx.dev-franceconnect.fr/api/v2", + } + + @pytest.fixture def user() -> User: fc_hash = build_fc_hash( diff --git a/ami/fi/__init__.py b/ami/fi/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ami/fi/api_exceptions.py b/ami/fi/api_exceptions.py new file mode 100644 index 000000000..df0c35041 --- /dev/null +++ b/ami/fi/api_exceptions.py @@ -0,0 +1,31 @@ +from rest_framework.exceptions import APIException + + +class MissingCookie(APIException): + status_code = 403 + default_detail = "Cookie manquant" + default_code = "missing-cookie" + + +class MissingAuthHeader(APIException): + status_code = 403 + default_detail = "Header d'authentification manquant" + default_code = "missing-auth-header" + + +class WrongFormatAuthHeader(APIException): + status_code = 403 + default_detail = "Header d'authentification mal formé" + default_code = "wrong-format-auth-header" + + +class FISessionExpired(APIException): + status_code = 403 + default_detail = "Session de connexion à AMI-FI expirée" + default_code = "fi-session-expired" + + +class FISessionNotFound(APIException): + status_code = 403 + default_detail = "Session de connexion à AMI-FI non trouvée" + default_code = "fi-session-not-found" diff --git a/ami/fi/api_urls.py b/ami/fi/api_urls.py new file mode 100644 index 000000000..1fabcb4a9 --- /dev/null +++ b/ami/fi/api_urls.py @@ -0,0 +1,10 @@ +from django.urls import path + +from ami.fi import api_views + +urlpatterns = [ + path("authorize/", api_views.authorize), + path("token/", api_views.token), + path("userinfo/", api_views.userinfo), + path("logout/", api_views.logout), +] diff --git a/ami/fi/api_views.py b/ami/fi/api_views.py new file mode 100644 index 000000000..429f32abf --- /dev/null +++ b/ami/fi/api_views.py @@ -0,0 +1,142 @@ +import logging +import re +from secrets import token_urlsafe +from typing import cast +from urllib.parse import urlencode + +import jwt +from django.conf import settings +from django.contrib.auth.hashers import make_password +from django.http import HttpResponseBadRequest, HttpResponseRedirect, JsonResponse +from django.shortcuts import redirect +from rest_framework import serializers +from rest_framework.decorators import api_view +from rest_framework.request import Request + +from ami.fi.api_exceptions import ( + FISessionExpired, + FISessionNotFound, + MissingAuthHeader, + MissingCookie, + WrongFormatAuthHeader, +) +from ami.fi.models import FISession +from ami.fi.serializers import AuthorizeSerializer, TokenSerializer +from ami.fi.utils import generate_id_token + +logger = logging.getLogger(__name__) + + +@api_view(["GET"]) +def authorize(request: Request) -> HttpResponseRedirect: + serializer = AuthorizeSerializer(data=request.query_params) + try: + serializer.is_valid(raise_exception=True) + except serializers.ValidationError as e: + logging.exception(e) + raise + data: dict = cast(dict, serializer.validated_data) + + if settings.USERINFO_COOKIE_JWT_NAME not in request.COOKIES: + logger.error("Cookie manquant") + raise MissingCookie + + encoded_user_data = request.COOKIES[settings.USERINFO_COOKIE_JWT_NAME] + decoded_user_data = jwt.decode( + encoded_user_data, options={"verify_signature": False}, algorithms=["ES256"] + ) + + code = token_urlsafe(64) + fi_session = FISession.objects.create( + user_data=decoded_user_data, + state=data["state"], + nonce=data["nonce"], + code=make_password(code, settings.FI_HASH_SALT), + ) + + redirect_uri = f"{data['redirect_uri']}?code={code}&state={fi_session.state}" + if settings.PUBLIC_FC_PROXY_BASE_URL: + params = { + "redirect_uri": redirect_uri, + } + redirect_uri = ( + f"{settings.PUBLIC_FC_PROXY_BASE_URL}/ami-fi-authorize-callback/?{urlencode(params)}" + ) + return redirect(redirect_uri) + + +@api_view(["POST"]) +def token(request: Request) -> JsonResponse: + serializer = TokenSerializer(data=request.data) + try: + serializer.is_valid(raise_exception=True) + except serializers.ValidationError as e: + logging.exception(e) + raise + data: dict = cast(dict, serializer.validated_data) + + try: + code_hash = make_password(data["code"], settings.FI_HASH_SALT) + fi_session = FISession.objects.get(code=code_hash) + if fi_session.is_expired: + logger.error("Session de connexion à AMI-FI expirée") + raise FISessionExpired + except FISession.DoesNotExist: + logger.error("Session de connexion à AMI-FI non trouvée") + raise FISessionNotFound + + encoded_id_token = jwt.encode( + generate_id_token(fi_session), + data["client_secret"], + algorithm="HS256", + ) + + access_token = token_urlsafe(64) + fi_session.access_token = make_password(access_token, settings.FI_HASH_SALT) + fi_session.save() + + return JsonResponse( + { + "access_token": access_token, + "expires_in": 60, + "id_token": encoded_id_token, + "token_type": "Bearer", + } + ) + + +@api_view(["GET"]) +def userinfo(request: Request) -> JsonResponse: + auth_header = request.META.get("HTTP_AUTHORIZATION") + if not auth_header: + logger.error("Header d'authentification manquant") + raise MissingAuthHeader + + pattern = re.compile(r"^Bearer\s([A-Z-a-z-0-9-_/-]+)$") + if not pattern.match(auth_header): + logger.error("Header d'authentification mal formé") + raise WrongFormatAuthHeader + + auth_token = auth_header[7:] + auth_token_hash = make_password(auth_token, settings.FI_HASH_SALT) + try: + fi_session = FISession.objects.get(access_token=auth_token_hash) + if fi_session.is_expired: + logger.error("Session de connexion à AMI-FI expirée") + raise FISessionExpired + except FISession.DoesNotExist: + logger.error("Session de connexion à AMI-FI non trouvée") + raise FISessionNotFound + + return JsonResponse(fi_session.user_data) + + +@api_view(["GET"]) +def logout(request: Request) -> HttpResponseBadRequest | HttpResponseRedirect: + redirect_uri = request.GET.get("post_logout_redirect_uri") + if redirect_uri != f"{settings.PUBLIC_FC_BASE_URL}{settings.FC_LOGOUT_CALLBACK_ENDPOINT}": + return HttpResponseBadRequest() + + redirect_uri = f"{redirect_uri}?state={request.GET.get('state')}" + + return redirect(redirect_uri) diff --git a/ami/fi/apps.py b/ami/fi/apps.py new file mode 100644 index 000000000..bd9b7fe7f --- /dev/null +++ b/ami/fi/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class FiConfig(AppConfig): + name = "ami.fi" diff --git a/ami/fi/management/__init__.py b/ami/fi/management/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ami/fi/management/commands/__init__.py b/ami/fi/management/commands/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ami/fi/management/commands/delete-expired-fi-sessions.py b/ami/fi/management/commands/delete-expired-fi-sessions.py new file mode 100644 index 000000000..9d5f3bd58 --- /dev/null +++ b/ami/fi/management/commands/delete-expired-fi-sessions.py @@ -0,0 +1,18 @@ +import datetime + +from django.conf import settings +from django.core.management.base import BaseCommand +from django.utils.timezone import now + +from ami.fi.models import FISession + + +class Command(BaseCommand): + help = "Delete expired FI Sessions" + + def handle(self, *args, **kwargs): + fi_sessions = FISession.objects.filter( + created_at__lt=now() - datetime.timedelta(seconds=settings.FI_SESSION_AGE) + ) + print(f"Deleting {fi_sessions.count()} FI Sessions") + fi_sessions.delete() diff --git a/ami/fi/migrations/0001_initial.py b/ami/fi/migrations/0001_initial.py new file mode 100644 index 000000000..080ed6c62 --- /dev/null +++ b/ami/fi/migrations/0001_initial.py @@ -0,0 +1,7 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [] + + operations = [] diff --git a/ami/fi/migrations/0002_fi_session.py b/ami/fi/migrations/0002_fi_session.py new file mode 100644 index 000000000..bfcda839d --- /dev/null +++ b/ami/fi/migrations/0002_fi_session.py @@ -0,0 +1,32 @@ +import uuid + +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ("fi", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="FISession", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, editable=False, primary_key=True, serialize=False + ), + ), + ("user_data", models.JSONField()), + ("state", models.CharField(max_length=256)), + ("nonce", models.CharField(max_length=256)), + ("code", models.CharField(max_length=256)), + ("access_token", models.CharField(max_length=256)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ], + ), + ] diff --git a/ami/fi/migrations/__init__.py b/ami/fi/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ami/fi/models.py b/ami/fi/models.py new file mode 100644 index 000000000..ff3839d9f --- /dev/null +++ b/ami/fi/models.py @@ -0,0 +1,23 @@ +import datetime +import uuid + +from django.conf import settings +from django.db import models +from django.utils.timezone import now + + +class FISession(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + + user_data = models.JSONField() + state = models.CharField(max_length=256) + nonce = models.CharField(max_length=256) + code = models.CharField(max_length=256) + access_token = models.CharField(max_length=256) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + @property + def is_expired(self): + return self.created_at < now() - datetime.timedelta(seconds=settings.FI_SESSION_AGE) diff --git a/ami/fi/serializers.py b/ami/fi/serializers.py new file mode 100644 index 000000000..9bb97caa6 --- /dev/null +++ b/ami/fi/serializers.py @@ -0,0 +1,88 @@ +from django.conf import settings +from rest_framework import serializers + + +class AuthorizeSerializer(serializers.Serializer): + state = serializers.CharField() + nonce = serializers.CharField() + response_type = serializers.CharField() + client_id = serializers.CharField() + redirect_uri = serializers.CharField() + scope = serializers.CharField() + acr_values = serializers.CharField() + claims = serializers.JSONField(required=False) + prompt = serializers.CharField() + + def validate_response_type(self, value): + expected = "code" + if value != expected: + raise serializers.ValidationError( + f"'response_type' doit être '{expected}', trouvé '{value}'", "invalid" + ) + return value + + def validate_client_id(self, value): + if value != settings.FI_CLIENT_ID: + raise serializers.ValidationError( + "'client_id' invalide", + "invalid", + ) + return value + + def validate_redirect_uri(self, value): + expected = settings.FI_REDIRECT_URI + if value != expected: + raise serializers.ValidationError( + f"'redirect_uri' doit être '{expected}', trouvé '{value}'", + "invalid", + ) + return value + + def validate_acr_values(self, value): + expected = "eidas1" + if value != expected: + raise serializers.ValidationError( + f"'acr_values' doit être '{expected}', trouvé '{value}'", "invalid" + ) + return value + + +class TokenSerializer(serializers.Serializer): + code = serializers.CharField() + grant_type = serializers.CharField() + redirect_uri = serializers.CharField() + client_id = serializers.CharField() + client_secret = serializers.CharField() + + def validate_grant_type(self, value): + expected = "authorization_code" + if value != expected: + raise serializers.ValidationError( + f"'grant_type' doit être '{expected}', trouvé '{value}'", "invalid" + ) + return value + + def validate_redirect_uri(self, value): + expected = settings.FI_REDIRECT_URI + if value != expected: + raise serializers.ValidationError( + f"'redirect_uri' doit être '{expected}', trouvé '{value}'", + "invalid", + ) + return value + + def validate_client_id(self, value): + if value != settings.FI_CLIENT_ID: + raise serializers.ValidationError( + "'client_id' invalide", + "invalid", + ) + return value + + def validate_client_secret(self, value): + if value != settings.FI_CLIENT_SECRET: + raise serializers.ValidationError( + "'client_secret' invalide", + "invalid", + ) + return value diff --git a/ami/fi/tests/__init__.py b/ami/fi/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ami/fi/tests/test_authorize.py b/ami/fi/tests/test_authorize.py new file mode 100644 index 000000000..0ad6412eb --- /dev/null +++ b/ami/fi/tests/test_authorize.py @@ -0,0 +1,371 @@ +import json +from typing import Any + +import pytest +from django.contrib.auth.hashers import make_password + +from ami.fi.models import FISession +from ami.tests.utils import url_contains_param + + +@pytest.mark.django_db +def test_authorize( + settings, + app, + monkeypatch: pytest.MonkeyPatch, + userinfo: dict[str, Any], +) -> None: + def fake_jwt_decode(*args: Any, **params: Any): + return userinfo + + settings.PUBLIC_FC_PROXY_BASE_URL = "" + + monkeypatch.setattr("jwt.decode", fake_jwt_decode) + + monkeypatch.setattr("ami.fi.api_views.token_urlsafe", lambda a: "fake-code") + expected_code = make_password("fake-code", settings.FI_HASH_SALT) + + app.set_cookie(settings.USERINFO_COOKIE_JWT_NAME, "fake userinfo jwt token") + + authorize_data = { + "state": "fake-state", + "nonce": "fake-nonce", + "response_type": "code", + "client_id": settings.FI_CLIENT_ID, + "redirect_uri": settings.FI_REDIRECT_URI, + "scope": "fake-scope", + "acr_values": "eidas1", + "claims": json.dumps( + { + "id_token": "fake-id-token", + } + ), + "prompt": "fake-prompt", + } + + response = app.get("/api/v1/fi/authorize/", params=authorize_data) + assert response.status_code == 302 + fi_session = FISession.objects.get() + assert fi_session.user_data == userinfo + assert fi_session.state == "fake-state" + assert fi_session.nonce == "fake-nonce" + assert fi_session.code == expected_code + assert fi_session.access_token == "" + redirected_url = response.headers["location"] + assert redirected_url.startswith(settings.FI_REDIRECT_URI) + assert url_contains_param( + "code", + "fake-code", + redirected_url, + ) + assert url_contains_param( + "state", + "fake-state", + redirected_url, + ) + + +@pytest.mark.django_db +def test_authorize_with_proxy( + settings, + app, + monkeypatch: pytest.MonkeyPatch, + userinfo: dict[str, Any], +) -> None: + def fake_jwt_decode(*args: Any, **params: Any): + return userinfo + + settings.PUBLIC_FC_PROXY_BASE_URL = "https://ami-fc-proxy" + + monkeypatch.setattr("jwt.decode", fake_jwt_decode) + + monkeypatch.setattr("ami.fi.api_views.token_urlsafe", lambda a: "fake-code") + expected_code = make_password("fake-code", settings.FI_HASH_SALT) + + app.set_cookie(settings.USERINFO_COOKIE_JWT_NAME, "fake userinfo jwt token") + + authorize_data = { + "state": "fake-state", + "nonce": "fake-nonce", + "response_type": "code", + "client_id": settings.FI_CLIENT_ID, + "redirect_uri": settings.FI_REDIRECT_URI, + "scope": "fake-scope", + "acr_values": "eidas1", + "claims": json.dumps( + { + "id_token": "fake-id-token", + } + ), + "prompt": "fake-prompt", + } + + response = app.get("/api/v1/fi/authorize/", params=authorize_data) + assert response.status_code == 302 + fi_session = FISession.objects.get() + assert fi_session.user_data == userinfo + assert fi_session.state == "fake-state" + assert fi_session.nonce == "fake-nonce" + assert fi_session.code == expected_code + assert fi_session.access_token == "" + redirected_url = response.headers["location"] + assert redirected_url.startswith( + f"{settings.PUBLIC_FC_PROXY_BASE_URL}/ami-fi-authorize-callback/" + ) + redirect_uri = f"{settings.FI_REDIRECT_URI}?code=fake-code&state=fake-state" + assert url_contains_param( + "redirect_uri", + redirect_uri, + redirected_url, + ) + + +def test_authorize_invalid_data_state( + settings, + app, + monkeypatch: pytest.MonkeyPatch, +) -> None: + authorize_data = { + "state": "", + "nonce": "fake-nonce", + "response_type": "code", + "client_id": settings.FI_CLIENT_ID, + "redirect_uri": settings.FI_REDIRECT_URI, + "scope": "fake-scope", + "acr_values": "eidas1", + # without claims + "prompt": "fake-prompt", + } + + response = app.get("/api/v1/fi/authorize/", params=authorize_data, status=400) + assert response.json == {"state": ["Ce champ ne peut être vide."]} + + +def test_authorize_invalid_data_nonce( + settings, + app, + monkeypatch: pytest.MonkeyPatch, +) -> None: + authorize_data = { + "state": "fake-state", + "nonce": "", + "response_type": "code", + "client_id": settings.FI_CLIENT_ID, + "redirect_uri": settings.FI_REDIRECT_URI, + "scope": "fake-scope", + "acr_values": "eidas1", + "claims": json.dumps( + { + "id_token": "fake-id-token", + } + ), + "prompt": "fake-prompt", + } + + response = app.get("/api/v1/fi/authorize/", params=authorize_data, status=400) + assert response.json == {"nonce": ["Ce champ ne peut être vide."]} + + +def test_authorize_invalid_data_response_type( + settings, + app, + monkeypatch: pytest.MonkeyPatch, +) -> None: + authorize_data = { + "state": "fake-state", + "nonce": "fake-nonce", + "response_type": "invalid-response-type", + "client_id": settings.FI_CLIENT_ID, + "redirect_uri": settings.FI_REDIRECT_URI, + "scope": "fake-scope", + "acr_values": "eidas1", + "claims": json.dumps( + { + "id_token": "fake-id-token", + } + ), + "prompt": "fake-prompt", + } + + response = app.get("/api/v1/fi/authorize/", params=authorize_data, status=400) + assert response.json == { + "response_type": ["'response_type' doit être 'code', trouvé 'invalid-response-type'"] + } + + +def test_authorize_invalid_data_client_id( + settings, + app, + monkeypatch: pytest.MonkeyPatch, +) -> None: + authorize_data = { + "state": "fake-state", + "nonce": "fake-nonce", + "response_type": "code", + "client_id": "invalid-client-id", + "redirect_uri": settings.FI_REDIRECT_URI, + "scope": "fake-scope", + "acr_values": "eidas1", + "claims": json.dumps( + { + "id_token": "fake-id-token", + } + ), + "prompt": "fake-prompt", + } + + response = app.get("/api/v1/fi/authorize/", params=authorize_data, status=400) + assert response.json == {"client_id": ["'client_id' invalide"]} + + +def test_authorize_invalid_data_redirect_uri( + settings, + app, + monkeypatch: pytest.MonkeyPatch, +) -> None: + authorize_data = { + "state": "fake-state", + "nonce": "fake-nonce", + "response_type": "code", + "client_id": settings.FI_CLIENT_ID, + "redirect_uri": "invalid-redirect-uri", + "scope": "fake-scope", + "acr_values": "eidas1", + "claims": json.dumps( + { + "id_token": "fake-id-token", + } + ), + "prompt": "fake-prompt", + } + + response = app.get("/api/v1/fi/authorize/", params=authorize_data, status=400) + assert response.json == { + "redirect_uri": [ + "'redirect_uri' doit être 'https://fcp-low.sbx.dev-franceconnect.fr/api/v2/oidc-callback', trouvé 'invalid-redirect-uri'" + ] + } + + +def test_authorize_invalid_data_scope( + settings, + app, + monkeypatch: pytest.MonkeyPatch, +) -> None: + authorize_data = { + "state": "fake-state", + "nonce": "fake-nonce", + "response_type": "code", + "client_id": settings.FI_CLIENT_ID, + "redirect_uri": settings.FI_REDIRECT_URI, + "scope": "", + "acr_values": "eidas1", + "claims": json.dumps( + { + "id_token": "fake-id-token", + } + ), + "prompt": "fake-prompt", + } + + response = app.get("/api/v1/fi/authorize/", params=authorize_data, status=400) + assert response.json == {"scope": ["Ce champ ne peut être vide."]} + + +def test_authorize_invalid_acr_values( + settings, + app, + monkeypatch: pytest.MonkeyPatch, +) -> None: + authorize_data = { + "state": "fake-state", + "nonce": "fake-nonce", + "response_type": "code", + "client_id": settings.FI_CLIENT_ID, + "redirect_uri": settings.FI_REDIRECT_URI, + "scope": "fake-scope", + "acr_values": "invalid-acr-values", + "claims": json.dumps( + { + "id_token": "fake-id-token", + } + ), + "prompt": "fake-prompt", + } + + response = app.get("/api/v1/fi/authorize/", params=authorize_data, status=400) + assert response.json == { + "acr_values": ["'acr_values' doit être 'eidas1', trouvé 'invalid-acr-values'"] + } + + +def test_authorize_invalid_data_claims( + settings, + app, + monkeypatch: pytest.MonkeyPatch, +) -> None: + authorize_data = { + "state": "fake-state", + "nonce": "fake-nonce", + "response_type": "code", + "client_id": settings.FI_CLIENT_ID, + "redirect_uri": settings.FI_REDIRECT_URI, + "scope": "fake-scope", + "acr_values": "eidas1", + "claims": "", + "prompt": "fake-prompt", + } + + response = app.get("/api/v1/fi/authorize/", params=authorize_data, status=400) + assert response.json == {"claims": ["La valeur doit être un JSON valide."]} + + +def test_authorize_invalid_data_prompt( + settings, + app, + monkeypatch: pytest.MonkeyPatch, +) -> None: + authorize_data = { + "state": "fake-state", + "nonce": "fake-nonce", + "response_type": "code", + "client_id": settings.FI_CLIENT_ID, + "redirect_uri": settings.FI_REDIRECT_URI, + "scope": "fake-scope", + "acr_values": "eidas1", + "claims": json.dumps( + { + "id_token": "fake-id-token", + } + ), + "prompt": "", + } + + response = app.get("/api/v1/fi/authorize/", params=authorize_data, status=400) + assert response.json == {"prompt": ["Ce champ ne peut être vide."]} + + +def test_authorize_missing_cookie( + settings, + app, + monkeypatch: pytest.MonkeyPatch, + userinfo: dict[str, Any], +) -> None: + authorize_data = { + "state": "fake-state", + "nonce": "fake-nonce", + "response_type": "code", + "client_id": settings.FI_CLIENT_ID, + "redirect_uri": settings.FI_REDIRECT_URI, + "scope": "fake-scope", + "acr_values": "eidas1", + "claims": json.dumps( + { + "id_token": "fake-id-token", + } + ), + "prompt": "fake-prompt", + } + + response = app.get("/api/v1/fi/authorize/", params=authorize_data, status=403) + assert response.json == {"detail": "Cookie manquant"} diff --git a/ami/fi/tests/test_delete-expired-fi-sessions_command.py b/ami/fi/tests/test_delete-expired-fi-sessions_command.py new file mode 100644 index 000000000..6b4fddef0 --- /dev/null +++ b/ami/fi/tests/test_delete-expired-fi-sessions_command.py @@ -0,0 +1,28 @@ +import datetime + +import pytest +from django.conf import settings +from django.core.management import call_command +from django.utils.timezone import now + +from ami.fi.models import FISession + + +@pytest.mark.django_db +def test_command_delete_expired_fi_sessions(monkeypatch: pytest.MonkeyPatch) -> None: + fi_session_1 = FISession.objects.create(user_data={}) + + fi_session_2 = FISession.objects.create(user_data={}) + fi_session_2.created_at = now() - datetime.timedelta(seconds=settings.FI_SESSION_AGE - 1) + fi_session_2.save() + + fi_session_3 = FISession.objects.create(user_data={}) + fi_session_3.created_at = now() - datetime.timedelta(seconds=settings.FI_SESSION_AGE) + fi_session_3.save() + + call_command("delete-expired-fi-sessions") + + assert FISession.objects.count() == 2 + assert FISession.objects.filter(id=fi_session_1.id).exists() is True + assert FISession.objects.filter(id=fi_session_2.id).exists() is True + assert FISession.objects.filter(id=fi_session_3.id).exists() is False diff --git a/ami/fi/tests/test_logout.py b/ami/fi/tests/test_logout.py new file mode 100644 index 000000000..7627b609a --- /dev/null +++ b/ami/fi/tests/test_logout.py @@ -0,0 +1,44 @@ +from django.conf import settings + +from ami.tests.utils import url_contains_param + + +def test_logout( + app, +) -> None: + redirect_uri = f"{settings.PUBLIC_FC_BASE_URL}{settings.FC_LOGOUT_CALLBACK_ENDPOINT}" + + response = app.get("/api/v1/fi/logout/", params={"post_logout_redirect_uri": redirect_uri}) + assert response.status_code == 302 + redirected_url = response.headers["location"] + assert redirected_url.startswith(redirect_uri) + assert url_contains_param( + "state", + "", + redirected_url, + ) + + response = app.get( + "/api/v1/fi/logout/", + params={"post_logout_redirect_uri": redirect_uri, "state": "fake-state"}, + ) + assert response.status_code == 302 + redirected_url = response.headers["location"] + assert redirected_url.startswith(redirect_uri) + assert url_contains_param( + "state", + "fake-state", + redirected_url, + ) + + +def test_logout_bad_request_no_redirect_uri( + app, +) -> None: + app.get("/api/v1/fi/logout/", status=400) + + +def test_logout_bad_request_wrong_redirect_uri( + app, +) -> None: + app.get("/api/v1/fi/logout/", params={"post_logout_redirect_uri": "wrong-uri"}, status=400) diff --git a/ami/fi/tests/test_models.py b/ami/fi/tests/test_models.py new file mode 100644 index 000000000..c2629ab86 --- /dev/null +++ b/ami/fi/tests/test_models.py @@ -0,0 +1,25 @@ +import datetime + +import pytest +from django.conf import settings + +from ami.fi.models import FISession + + +@pytest.mark.django_db +def test_fi_session_is_expired() -> None: + fi_session = FISession.objects.create(user_data={}) + assert fi_session.is_expired is False + original_created_at = fi_session.created_at + + fi_session.created_at = original_created_at - datetime.timedelta( + seconds=settings.FI_SESSION_AGE - 1 + ) + fi_session.save() + assert fi_session.is_expired is False + + fi_session.created_at = original_created_at - datetime.timedelta( + seconds=settings.FI_SESSION_AGE + ) + fi_session.save() + assert fi_session.is_expired is True diff --git a/ami/fi/tests/test_token.py b/ami/fi/tests/test_token.py new file mode 100644 index 000000000..1025f4565 --- /dev/null +++ b/ami/fi/tests/test_token.py @@ -0,0 +1,201 @@ +import datetime +from unittest import mock + +import pytest +from django.contrib.auth.hashers import make_password +from freezegun import freeze_time + +from ami.fi.models import FISession + + +@freeze_time("2026-04-07 17:21:00") +@pytest.mark.django_db +def test_token( + settings, + app, + monkeypatch: pytest.MonkeyPatch, +) -> None: + settings.PUBLIC_FC_PROXY_BASE_URL = "https://ami-fc-proxy-dev.osc-fr1.scalingo.io" + user_data = {"sub": "fake-sub"} + nonce = "fake-nonce" + code = "fake-code" + code_hash = make_password(code, settings.FI_HASH_SALT) + fi_session = FISession.objects.create(user_data=user_data, nonce=nonce, code=code_hash) + token_data = { + "code": code, + "grant_type": "authorization_code", + "redirect_uri": settings.FI_REDIRECT_URI, + "client_id": settings.FI_CLIENT_ID, + "client_secret": settings.FI_CLIENT_SECRET, + } + + encode_mock = mock.Mock(return_value="fake-encoded-id-token") + monkeypatch.setattr("jwt.encode", encode_mock) + + monkeypatch.setattr("ami.fi.api_views.token_urlsafe", lambda a: "fake-access-token") + expected_access_token = make_password("fake-access-token", settings.FI_HASH_SALT) + + response = app.post("/api/v1/fi/token/", token_data) + assert response.json == { + "access_token": "fake-access-token", + "expires_in": 60, + "id_token": "fake-encoded-id-token", + "token_type": "Bearer", + } + encode_mock.assert_called_once_with( + { + "aud": settings.FI_CLIENT_ID, + "exp": 1775582760, + "iat": 1775582460, + "iss": f"{settings.PUBLIC_FC_PROXY_BASE_URL}/api/v1/fi/", + "sub": "fake-sub", + "nonce": "fake-nonce", + "acr": "eidas1", + }, + settings.FI_CLIENT_SECRET, + algorithm="HS256", + ) + fi_session.refresh_from_db() + assert fi_session.access_token == expected_access_token + + +def test_token_invalid_data_code( + settings, + app, + monkeypatch: pytest.MonkeyPatch, +) -> None: + code = "" + token_data = { + "code": code, + "grant_type": "authorization_code", + "redirect_uri": settings.FI_REDIRECT_URI, + "client_id": settings.FI_CLIENT_ID, + "client_secret": settings.FI_CLIENT_SECRET, + } + + response = app.post("/api/v1/fi/token/", token_data, status=400) + assert response.json == {"code": ["Ce champ ne peut être vide."]} + + +def test_token_invalid_grant_type( + settings, + app, + monkeypatch: pytest.MonkeyPatch, +) -> None: + code = "fake-code" + token_data = { + "code": code, + "grant_type": "invalid-grant-type", + "redirect_uri": settings.FI_REDIRECT_URI, + "client_id": settings.FI_CLIENT_ID, + "client_secret": settings.FI_CLIENT_SECRET, + } + + response = app.post("/api/v1/fi/token/", token_data, status=400) + assert response.json == { + "grant_type": ["'grant_type' doit être 'authorization_code', trouvé 'invalid-grant-type'"] + } + + +def test_token_invalid_redirect_uri( + settings, + app, + monkeypatch: pytest.MonkeyPatch, +) -> None: + code = "fake-code" + token_data = { + "code": code, + "grant_type": "authorization_code", + "redirect_uri": "invalid-redirect-uri", + "client_id": settings.FI_CLIENT_ID, + "client_secret": settings.FI_CLIENT_SECRET, + } + + response = app.post("/api/v1/fi/token/", token_data, status=400) + assert response.json == { + "redirect_uri": [ + f"'redirect_uri' doit être '{settings.FI_REDIRECT_URI}', trouvé 'invalid-redirect-uri'" + ] + } + + +def test_token_invalid_client_id( + settings, + app, + monkeypatch: pytest.MonkeyPatch, +) -> None: + code = "fake-code" + token_data = { + "code": code, + "grant_type": "authorization_code", + "redirect_uri": settings.FI_REDIRECT_URI, + "client_id": "invalid-client-id", + "client_secret": settings.FI_CLIENT_SECRET, + } + + response = app.post("/api/v1/fi/token/", token_data, status=400) + assert response.json == {"client_id": ["'client_id' invalide"]} + + +def test_token_invalid_client_secret( + settings, + app, + monkeypatch: pytest.MonkeyPatch, +) -> None: + code = "fake-code" + token_data = { + "code": code, + "grant_type": "authorization_code", + "redirect_uri": settings.FI_REDIRECT_URI, + "client_id": settings.FI_CLIENT_ID, + "client_secret": "invalid-client-secret", + } + + response = app.post("/api/v1/fi/token/", token_data, status=400) + assert response.json == {"client_secret": ["'client_secret' invalide"]} + + +@pytest.mark.django_db +def test_token_fi_session_expired( + settings, + app, +) -> None: + user_data = {"sub": "fake-sub"} + nonce = "fake-nonce" + code = "fake-code" + code_hash = make_password(code, settings.FI_HASH_SALT) + fi_session = FISession.objects.create(user_data=user_data, nonce=nonce, code=code_hash) + fi_session.created_at -= datetime.timedelta(seconds=settings.FI_SESSION_AGE) + fi_session.save() + token_data = { + "code": code, + "grant_type": "authorization_code", + "redirect_uri": settings.FI_REDIRECT_URI, + "client_id": settings.FI_CLIENT_ID, + "client_secret": settings.FI_CLIENT_SECRET, + } + + response = app.post("/api/v1/fi/token/", token_data, status=403) + assert response.json == {"detail": "Session de connexion à AMI-FI expirée"} + + +@pytest.mark.django_db +def test_token_fi_session_not_found( + settings, + app, +) -> None: + user_data = {"sub": "fake-sub"} + nonce = "fake-nonce" + code = "fake-code" + other_code_hash = make_password("other-code", settings.FI_HASH_SALT) + FISession.objects.create(user_data=user_data, nonce=nonce, code=other_code_hash) + token_data = { + "code": code, + "grant_type": "authorization_code", + "redirect_uri": settings.FI_REDIRECT_URI, + "client_id": settings.FI_CLIENT_ID, + "client_secret": settings.FI_CLIENT_SECRET, + } + + response = app.post("/api/v1/fi/token/", token_data, status=403) + assert response.json == {"detail": "Session de connexion à AMI-FI non trouvée"} diff --git a/ami/fi/tests/test_userinfo.py b/ami/fi/tests/test_userinfo.py new file mode 100644 index 000000000..cbf13736a --- /dev/null +++ b/ami/fi/tests/test_userinfo.py @@ -0,0 +1,68 @@ +import datetime + +import pytest +from django.conf import settings +from django.contrib.auth.hashers import make_password + +from ami.fi.models import FISession + + +@pytest.mark.django_db +def test_userinfo( + app, +) -> None: + auth_token = "fake-access-token" + auth_token_hash = make_password(auth_token, settings.FI_HASH_SALT) + user_data = {"fake-key": "fake-user-data"} + FISession.objects.create(user_data=user_data, access_token=auth_token_hash) + response = app.get("/api/v1/fi/userinfo/", headers={"AUTHORIZATION": f"Bearer {auth_token}"}) + assert response.json == user_data + + +@pytest.mark.django_db +def test_userinfo_missing_auth_header( + app, +) -> None: + user_data = {"fake-key": "fake-user-data"} + FISession.objects.create(user_data=user_data, access_token="fake-access-token") + response = app.get("/api/v1/fi/userinfo/", status=403) + assert response.json == {"detail": "Header d'authentification manquant"} + + +@pytest.mark.django_db +def test_userinfo_wrong_format_auth_header( + app, +) -> None: + auth_token = "fake-access-token" + user_data = {"fake-key": "fake-user-data"} + FISession.objects.create(user_data=user_data, access_token="fake-access-token") + response = app.get("/api/v1/fi/userinfo/", headers={"AUTHORIZATION": auth_token}, status=403) + assert response.json == {"detail": "Header d'authentification mal formé"} + + +@pytest.mark.django_db +def test_userinfo_fi_session_expired( + app, +) -> None: + auth_token = "fake-access-token" + auth_token_hash = make_password(auth_token, settings.FI_HASH_SALT) + user_data = {"fake-key": "fake-user-data"} + fi_session = FISession.objects.create(user_data=user_data, access_token=auth_token_hash) + fi_session.created_at -= datetime.timedelta(seconds=settings.FI_SESSION_AGE) + fi_session.save() + response = app.get( + "/api/v1/fi/userinfo/", headers={"AUTHORIZATION": f"Bearer {auth_token}"}, status=403 + ) + assert response.json == {"detail": "Session de connexion à AMI-FI expirée"} + + +@pytest.mark.django_db +def test_userinfo_fi_session_not_found( + app, +) -> None: + user_data = {"fake-key": "fake-user-data"} + FISession.objects.create(user_data=user_data, access_token="fake-access-token") + response = app.get( + "/api/v1/fi/userinfo/", headers={"AUTHORIZATION": "Bearer azerty"}, status=403 + ) + assert response.json == {"detail": "Session de connexion à AMI-FI non trouvée"} diff --git a/ami/fi/utils.py b/ami/fi/utils.py new file mode 100644 index 000000000..a36a0dab0 --- /dev/null +++ b/ami/fi/utils.py @@ -0,0 +1,18 @@ +import time + +from django.conf import settings + + +def generate_id_token(fi_session): + iss: str = f"{settings.PUBLIC_API_URL}/api/v1/fi/" + if settings.PUBLIC_FC_PROXY_BASE_URL: + iss = f"{settings.PUBLIC_FC_PROXY_BASE_URL}/api/v1/fi/" + return { + "aud": settings.FI_CLIENT_ID, + "exp": int(time.time()) + settings.FI_SESSION_AGE, + "iat": int(time.time()), + "iss": iss, + "sub": fi_session.user_data["sub"], + "nonce": fi_session.nonce, + "acr": "eidas1", + } diff --git a/ami/notification/management/commands/delete-published-scheduled-notifications.py b/ami/notification/management/commands/delete-published-scheduled-notifications.py index 8ab20f6a0..69fef77cf 100644 --- a/ami/notification/management/commands/delete-published-scheduled-notifications.py +++ b/ami/notification/management/commands/delete-published-scheduled-notifications.py @@ -7,7 +7,7 @@ class Command(BaseCommand): - help = "Create scheduled notifications to be distributed automatically" + help = "Delete already sent scheduled notifications" def handle(self, *args, **kwargs): scheduled_notifications = ScheduledNotification.objects.filter( diff --git a/ami/notification/management/commands/publish-scheduled-notifications.py b/ami/notification/management/commands/publish-scheduled-notifications.py index 6af43fece..d9ac4d756 100644 --- a/ami/notification/management/commands/publish-scheduled-notifications.py +++ b/ami/notification/management/commands/publish-scheduled-notifications.py @@ -5,7 +5,7 @@ class Command(BaseCommand): - help = "Create scheduled notifications to be distributed automatically" + help = "Create scheduled notifications to be automatically send" def handle(self, *args, **kwargs): scheduled_notifications = ScheduledNotification.objects.filter( diff --git a/ami/settings.py b/ami/settings.py index 4e5cec892..35182f330 100644 --- a/ami/settings.py +++ b/ami/settings.py @@ -58,6 +58,7 @@ "django.forms", "sass_processor", "ami.authentication", + "ami.fi", "ami.notification", "ami.user", "ami.agenda", @@ -249,7 +250,7 @@ def before_send(event, hint): # This should not be set in production: # It should be set in the .env.local file for local development # and in the Scalingo staging and review apps as an env variable. -PUBLIC_FC_PROXY = CONFIG.get("PUBLIC_FC_PROXY") +PUBLIC_FC_PROXY_BASE_URL = CONFIG.get("PUBLIC_FC_PROXY_BASE_URL") FC_SCOPE = CONFIG["FC_SCOPE"] FC_AMI_REDIRECT_URL = PUBLIC_API_URL + "/login-callback" @@ -257,9 +258,20 @@ def before_send(event, hint): FC_JWKS_ENDPOINT = "/api/v2/jwks" FC_USERINFO_ENDPOINT = "/api/v2/userinfo" FC_AUTHORIZATION_ENDPOINT = "/api/v2/authorize" +FC_LOGOUT_CALLBACK_ENDPOINT = "/api/v2/client/logout-callback" SECTOR_IDENTIFIER_URL = CONFIG.get("SECTOR_IDENTIFIER_URL", "") +# AMI-FI authentication +FI_CLIENT_ID = CONFIG["FI_CLIENT_ID"] +FI_CLIENT_SECRET = CONFIG["FI_CLIENT_SECRET"] +FI_IDP_ID = CONFIG["FI_IDP_ID"] +USERINFO_COOKIE_JWT_NAME = "ami-fi-userinfo" +FI_HASH_SALT = CONFIG["FI_HASH_SALT"] +assert FI_HASH_SALT, "set a random FI_HASH_SALT in your .env.local file" +FI_SESSION_AGE = CONFIG.get("FI_SESSION_AGE", 5 * 60) +FI_REDIRECT_URI = PUBLIC_FC_BASE_URL + "/api/v2/oidc-callback" + # ProConnect authentication OIDC_RP_SIGN_ALGO = "RS256" OIDC_RP_CLIENT_ID = CONFIG["PRO_CONNECT_AGENT_ADMIN_CLIENT_ID"] diff --git a/ami/tests/utils.py b/ami/tests/utils.py index ad7e624de..dded72392 100644 --- a/ami/tests/utils.py +++ b/ami/tests/utils.py @@ -17,6 +17,7 @@ def url_contains_param(param_name: str, param_value: str, url: str) -> bool: def login(app, user: User) -> None: jwt_token = create_jwt_token(user_id=str(user.id), jti=uuid.uuid4().hex) app.set_cookie(settings.AUTH_COOKIE_JWT_NAME, f"Bearer {jwt_token}") + app.set_cookie(settings.USERINFO_COOKIE_JWT_NAME, "fake userinfo jwt token") def assert_query_fails_without_auth( diff --git a/bin/start.sh b/bin/start.sh index 8f30f22cd..3bb2c0c73 100755 --- a/bin/start.sh +++ b/bin/start.sh @@ -6,7 +6,7 @@ HOST="${HOST:-0.0.0.0}" if [ "$APP" == "ami-back-prod" ] then # We don't want to use the FranceConnect proxy in production - export PUBLIC_FC_PROXY="" + export PUBLIC_FC_PROXY_BASE_URL="" fi if [ ! -z "$CONTAINER" ] diff --git a/cron.json b/cron.json index 7cce2c5b0..d6cb7214c 100644 --- a/cron.json +++ b/cron.json @@ -9,6 +9,9 @@ { "command": "0 2 * * * make delete-published-scheduled-notifications" }, + { + "command": "1 2 * * * make delete-expired-fi-sessions" + }, { "command": "0 3 * * * make replicate-anonymized-data" } diff --git a/public/mobile-app/src/lib/france-connect.test.js b/public/mobile-app/src/lib/france-connect.test.js index 252d37454..2b8612feb 100644 --- a/public/mobile-app/src/lib/france-connect.test.js +++ b/public/mobile-app/src/lib/france-connect.test.js @@ -8,7 +8,7 @@ describe('/france-connect', () => { }); describe('franceConnectLogout', () => { - test('should call logout endpoint when click on FranceConnect logout button', async () => { + test('should call logout endpoint with home as return url', async () => { // Given vi.stubGlobal('location', { href: 'http://example.com' }); @@ -20,5 +20,17 @@ describe('/france-connect', () => { 'https://fcp-low.sbx.dev-franceconnect.fr/api/v2/session/end?id_token_hint=fake-id-token&state=https%3A%2F%2Flocalhost%3A5173%2F%3Fis_logged_out&post_logout_redirect_uri=https%3A%2F%2Fami-fc-proxy-dev.osc-fr1.scalingo.io%2F' ); }); + test('should call logout endpoint with another return url', async () => { + // Given + vi.stubGlobal('location', { href: 'http://example.com' }); + + // When + await franceConnectLogout('fake-id-token', 'http://other-return-url/'); + + // Then + expect(window.location.href).toBe( + 'https://fcp-low.sbx.dev-franceconnect.fr/api/v2/session/end?id_token_hint=fake-id-token&state=http%3A%2F%2Fother-return-url%2F&post_logout_redirect_uri=https%3A%2F%2Fami-fc-proxy-dev.osc-fr1.scalingo.io%2F' + ); + }); }); }); diff --git a/public/mobile-app/src/lib/france-connect.ts b/public/mobile-app/src/lib/france-connect.ts index 252ac7e50..01edcde89 100644 --- a/public/mobile-app/src/lib/france-connect.ts +++ b/public/mobile-app/src/lib/france-connect.ts @@ -2,7 +2,7 @@ import { PUBLIC_APP_URL, PUBLIC_FC_BASE_URL, PUBLIC_FC_LOGOUT_ENDPOINT, - PUBLIC_FC_PROXY, + PUBLIC_FC_PROXY_BASE_URL, } from '$env/static/public'; import type { UserInfo } from '$lib/state/User.svelte'; @@ -20,12 +20,19 @@ export function parseJwt(token: string): UserInfo { return JSON.parse(jsonPayload); } -export const franceConnectLogout = async (id_token_hint: string) => { - const redirect_url = `${PUBLIC_APP_URL}/?is_logged_out`; +export const franceConnectLogout = async ( + id_token_hint: string, + redirect_url: string | null = null +) => { + const redirect_uri = redirect_url || `${PUBLIC_APP_URL}/?is_logged_out`; + let post_logout_redirect_uri = redirect_uri; + if (PUBLIC_FC_PROXY_BASE_URL) { + post_logout_redirect_uri = `${PUBLIC_FC_PROXY_BASE_URL}/`; + } const params = new URLSearchParams({ id_token_hint, - state: redirect_url, - post_logout_redirect_uri: PUBLIC_FC_PROXY || redirect_url, + state: redirect_uri, + post_logout_redirect_uri: post_logout_redirect_uri, }); const url = new URL(`${PUBLIC_FC_BASE_URL}${PUBLIC_FC_LOGOUT_ENDPOINT}`); url.search = params.toString(); diff --git a/public/mobile-app/src/routes/fi/+page.svelte b/public/mobile-app/src/routes/fi/+page.svelte new file mode 100644 index 000000000..5b64d44e8 --- /dev/null +++ b/public/mobile-app/src/routes/fi/+page.svelte @@ -0,0 +1,20 @@ + + +