diff --git a/README.md b/README.md index 37d6d52..1361f9c 100644 --- a/README.md +++ b/README.md @@ -1,84 +1,22 @@ # Visione CIE Auth Stack -This folder contains deployable CIE auth services for Visione: +Fork di [italia/spid-cie-oidc-django](https://github.com/italia/spid-cie-oidc-django) riadattato leggermente al progetto Visione. + +## Setup rapido + +1. Copia `.env.example` in `.env`. +2. Imposta domini pubblici, secret Django, `DJANGO_RP_BACKEND_EXCHANGE_URL` e `DJANGO_RP_SHARED_SECRET`. +3. Avvia tutto con `docker compose up --build -d`. + +Stack: - `federation-authority` - `provider` - `relying-party` -It is now structured to work with Docker Compose and Dokploy without host networking. +Il RP Visione usa `/visione/start` per iniziare il flow e `/visione/complete` per chiuderlo verso backend Visione. -## Configuration -Copy [.env.example](/workspace/cie-auth/.env.example) to `.env` and set only what changes in real deploy: -- public domains / internal URLs -- Django secret keys -- shared secret between relying party and Visione backend +## Note -Main variables: -- `CIE_AUTH_ALLOWED_HOSTS` -- `DJANGO_TRUST_ANCHOR` -- `DJANGO_PROVIDER_ISSUER` -- `DJANGO_RP_BACKEND_EXCHANGE_URL` -- `DJANGO_RP_SHARED_SECRET` -- `FEDERATION_AUTHORITY_SECRET_KEY` -- `PROVIDER_SECRET_KEY` -- `RELYING_PARTY_SECRET_KEY` - -## Deploy with Dokploy -Use [docker-compose.yml](/workspace/cie-auth/docker-compose.yml) as compose source. -Expose: -- `federation-authority` on port `8000` -- `provider` on port `8002` -- `relying-party` on port `8001` - -Each service persists its own SQLite DB in a named volume. - -## Local start - -```bash -docker compose up --build -d -``` - -## Visione backend -Visione backend must expose exchange endpoint configured in `DJANGO_RP_BACKEND_EXCHANGE_URL`. -Shared secret must match backend `cie_auth.shared_secret`. - -## Bootstrap behavior -On first boot, each Django service: -1. runs migrations -2. loads `dumps/example.json` only if DB is empty - -Disable fixture import per service with: -- `FEDERATION_AUTHORITY_LOAD_FIXTURE_ON_EMPTY_DB=false` -- `PROVIDER_LOAD_FIXTURE_ON_EMPTY_DB=false` -- `RELYING_PARTY_LOAD_FIXTURE_ON_EMPTY_DB=false` - -## Smoke test - -```bash -curl -sS -X POST http://127.0.0.1:5555/api/v1/auth/cie/start \ - -H 'Content-Type: application/json' \ - -d '{"frontend_redirect_uri":"http://localhost:3000/auth/cie/callback"}' -``` - -Then complete login and poll: - -```bash -curl -sS http://127.0.0.1:5555/api/v1/auth/cie/session/ -``` - -## Reset data - -```bash -docker compose down -v -``` - -If the stack was started before June 17, 2026 DB path fix, also remove legacy SQLite files that were created outside named volumes: - -```bash -rm -f federation_authority/db.sqlite3 provider/db.sqlite3 relying_party/db.sqlite3 -``` - -## Logs -```bash -docker compose logs -f -``` +- Il RP si chiama `Visione`. +- Rifiuto / annullo tornano sempre a Visione. +- Grafica provider allineata a schermata CIE, senza QR e senza lettura carta. diff --git a/federation_authority/dumps/example.json b/federation_authority/dumps/example.json index d78012f..99b8637 100644 --- a/federation_authority/dumps/example.json +++ b/federation_authority/dumps/example.json @@ -257,7 +257,7 @@ ] }, "openid_relying_party": { - "organization_name": "that fancy RP", + "organization_name": "Visione", "application_type": "web", "client_id": "http://127.0.0.1:8000/oidc/rp", "client_registration_types": [ @@ -285,7 +285,7 @@ }, "jwks_uri": "http://127.0.0.1:8000/oidc/rp/openid_relying_party/jwks.json", "signed_jwks_uri": "http://127.0.0.1:8000/oidc/rp/openid_relying_party/jwks.jose", - "client_name": "Name of an example organization", + "client_name": "Visione", "contacts": [ "ops@rp.example.it" ], @@ -1461,7 +1461,7 @@ } ] }, - "client_name": "Name of an example organization", + "client_name": "Visione", "contacts": [ "ops@rp.example.it" ], @@ -1475,7 +1475,8 @@ "response_types": [ "code" ], - "subject_type": "pairwise" + "subject_type": "pairwise", + "organization_name": "Visione" } }, "trust_marks": [], @@ -1726,7 +1727,7 @@ } ] }, - "client_name": "Name of an example organization", + "client_name": "Visione", "contacts": [ "ops@rp.example.it" ], @@ -1740,7 +1741,8 @@ "response_types": [ "code" ], - "subject_type": "pairwise" + "subject_type": "pairwise", + "organization_name": "Visione" } }, "trust_marks": [], diff --git a/provider/dumps/example.json b/provider/dumps/example.json index c13bc4b..631df73 100644 --- a/provider/dumps/example.json +++ b/provider/dumps/example.json @@ -670,7 +670,7 @@ } ] }, - "client_name": "Name of an example organization", + "client_name": "Visione", "contacts": [ "ops@rp.example.it" ], @@ -684,7 +684,8 @@ "response_types": [ "code" ], - "subject_type": "pairwise" + "subject_type": "pairwise", + "organization_name": "Visione" } }, "trust_marks": [], @@ -744,7 +745,7 @@ } ] }, - "client_name": "Name of an example organization", + "client_name": "Visione", "contacts": [ "ops@rp.example.it" ], @@ -758,7 +759,8 @@ "response_types": [ "code" ], - "subject_type": "pairwise" + "subject_type": "pairwise", + "organization_name": "Visione" } }, "trust_marks": [], diff --git a/provider/provider/__pycache__/settingslocal.cpython-310.pyc b/provider/provider/__pycache__/settingslocal.cpython-310.pyc deleted file mode 100644 index 0ab14c6..0000000 Binary files a/provider/provider/__pycache__/settingslocal.cpython-310.pyc and /dev/null differ diff --git a/provider/provider/settings.py b/provider/provider/settings.py index e8fc733..cbb213c 100644 --- a/provider/provider/settings.py +++ b/provider/provider/settings.py @@ -28,7 +28,7 @@ ROOT_URLCONF = 'provider.urls' TEMPLATES = [ { 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [], + 'DIRS': [os.path.join(BASE_DIR, 'templates')], 'APP_DIRS': True, 'OPTIONS': { 'context_processors': [ diff --git a/provider/templates/op_base.html b/provider/templates/op_base.html new file mode 100644 index 0000000..cac6384 --- /dev/null +++ b/provider/templates/op_base.html @@ -0,0 +1,253 @@ +{% load i18n %} + + + + + + {% block page_title %}Visione CIE{% endblock page_title %} + + {% block extra_head %}{% endblock extra_head %} + + +
+
+
+
+ +
+ {% trans "Ministero dell'interno" %} + {% trans "Accesso CIE" %} +
+
+
+ +
+ {% trans "Relying party" %} + Visione +
+
+
+
+
+ {% block container %}{% endblock container %} +
+
+ + diff --git a/provider/templates/op_user_consent.html b/provider/templates/op_user_consent.html new file mode 100644 index 0000000..023231d --- /dev/null +++ b/provider/templates/op_user_consent.html @@ -0,0 +1,68 @@ +{% extends 'op_base.html' %} +{% load i18n %} + +{% block page_title %}Visione CIE Consent{% endblock page_title %} + +{% block container %} +

{% trans "Richiesta d'accesso CIE livello 2" %}

+

{{ client_organization_name|default:"Visione" }}

+ +
+
+
+

{% trans "Conferma rilascio dati" %}

+

+ {% blocktrans with service=client_organization_name|default:"Visione" %} + Per completare accesso a {{ service }} devi autorizzare rilascio degli attributi richiesti. + {% endblocktrans %} +

+
+ {% csrf_token %} + {% for form_field in form.visible_fields %} +
+ + {% if form_field.field.widget.input_type %} + + {% else %} + + {% endif %} + {% if form_field.errors %} +
{{ form_field.errors }}
+ {% endif %} +
+ {% endfor %} + {% for hidden in form.hidden_fields %} + {{ hidden }} + {% endfor %} +
+ + {% trans "Annulla" %} + + +
+
+
+ +
+
+{% endblock container %} diff --git a/provider/templates/op_user_login.html b/provider/templates/op_user_login.html new file mode 100644 index 0000000..b148c3e --- /dev/null +++ b/provider/templates/op_user_login.html @@ -0,0 +1,65 @@ +{% extends 'op_base.html' %} +{% load i18n %} + +{% block page_title %}Visione CIE Login{% endblock page_title %} + +{% block container %} +

{% trans "Richiesta d'accesso CIE livello 2" %}

+

{{ client_organization_name|default:"Visione" }}

+ +
+
+
+

{% trans "Entra con le tue credenziali CIE" %}

+

+ {% blocktrans with acr=acr_value|upper %} + Autenticazione richiesta con livello {{ acr }}. Inserisci le credenziali associate alla tua identità digitale. + {% endblocktrans %} +

+
+ {% csrf_token %} + {% for form_field in form.visible_fields %} +
+ + {% if form_field.field.widget.input_type %} + + {% endif %} + {% if form_field.errors %} +
{{ form_field.errors }}
+ {% endif %} +
+ {% endfor %} + {% for hidden in hidden_form.hidden_fields %} + {{ hidden }} + {% endfor %} +
+ + {% trans "Annulla" %} + + +
+
+
+ +
+
+{% endblock container %} diff --git a/relying_party/dumps/example.json b/relying_party/dumps/example.json index 671820d..781362d 100644 --- a/relying_party/dumps/example.json +++ b/relying_party/dumps/example.json @@ -87,7 +87,7 @@ }, "openid_relying_party": { "application_type": "web", - "organization_name": "Example RP", + "organization_name": "Visione", "client_id": "http://127.0.0.1:8001", "client_registration_types": [ "automatic" @@ -114,7 +114,7 @@ } ] }, - "client_name": "Name of an example organization", + "client_name": "Visione", "contacts": [ "ops@rp.example.it" ], diff --git a/relying_party/relying_party/__pycache__/settingslocal.cpython-310.pyc b/relying_party/relying_party/__pycache__/settingslocal.cpython-310.pyc deleted file mode 100644 index 28d2c5d..0000000 Binary files a/relying_party/relying_party/__pycache__/settingslocal.cpython-310.pyc and /dev/null differ diff --git a/relying_party/relying_party/__pycache__/visione_views.cpython-310.pyc b/relying_party/relying_party/__pycache__/visione_views.cpython-310.pyc deleted file mode 100644 index a706e09..0000000 Binary files a/relying_party/relying_party/__pycache__/visione_views.cpython-310.pyc and /dev/null differ diff --git a/relying_party/relying_party/rp_overrides.py b/relying_party/relying_party/rp_overrides.py new file mode 100644 index 0000000..f848a2b --- /dev/null +++ b/relying_party/relying_party/rp_overrides.py @@ -0,0 +1,337 @@ +import json +import logging +from urllib.parse import urlencode + +from django.http import HttpResponseRedirect +from django.shortcuts import render +from django.utils.translation import gettext as _ +from spid_cie_oidc.entity.jwtse import ( + unpad_jwt_payload, + verify_at_hash, + verify_jws, +) +from spid_cie_oidc.entity.models import FederationEntityConfiguration +from spid_cie_oidc.entity.settings import HTTPC_PARAMS +from spid_cie_oidc.entity.utils import get_jwk_from_jwt, get_jwks +from spid_cie_oidc.relying_party.exceptions import ValidationException +from spid_cie_oidc.relying_party.oidc import iat_now +from spid_cie_oidc.relying_party.settings import RP_ATTR_MAP, RP_DEFAULT_PROVIDER_PROFILES, RP_PROVIDER_PROFILES +from spid_cie_oidc.relying_party.utils import process_user_attributes +from spid_cie_oidc.relying_party.views.rp_begin import SpidCieOidcRpBeginView +from spid_cie_oidc.relying_party.views.rp_callback import SpidCieOidcRpCallbackView +from spid_cie_oidc.relying_party.models import OidcAuthentication, OidcAuthenticationToken + +logger = logging.getLogger(__name__) + +schema_profile = RP_PROVIDER_PROFILES[RP_DEFAULT_PROVIDER_PROFILES] + + +def _visione_redirect_uri(request): + return request.session.get("visione_frontend_redirect_uri") + + +def _visione_error_redirect(request, error, error_description="", status=302): + frontend_redirect_uri = _visione_redirect_uri(request) + if frontend_redirect_uri: + params = { + "state": request.session.get("visione_state", ""), + "error": error, + } + if error_description: + params["error_description"] = error_description + separator = "&" if "?" in frontend_redirect_uri else "?" + return HttpResponseRedirect( + f"{frontend_redirect_uri}{separator}{urlencode(params)}", + status=status, + ) + return None + + +class VisioneRpBeginView(SpidCieOidcRpBeginView): + def get(self, request, *args, **kwargs): + response = super().get(request, *args, **kwargs) + if getattr(response, "status_code", None) and response.status_code >= 400: + frontend_redirect = _visione_error_redirect( + request, + "request_rejected", + "Authentication request rejected", + ) + if frontend_redirect: + return frontend_redirect + return response + + +def visione_rp_error_context(request): + frontend_redirect_uri = _visione_redirect_uri(request) + if not frontend_redirect_uri: + return {} + params = { + "state": request.session.get("visione_state", ""), + "error": "invalid_request", + } + separator = "&" if "?" in frontend_redirect_uri else "?" + return { + "visione_return_url": f"{frontend_redirect_uri}{separator}{urlencode(params)}", + } + + +class VisioneRpCallbackView(SpidCieOidcRpCallbackView): + def get(self, request, *args, **kwargs): + if "error" in request.GET and _visione_redirect_uri(request): + return _visione_error_redirect( + request, + request.GET.get("error", "invalid_request"), + request.GET.get("error_description", ""), + ) + request_args = {k: v for k, v in request.GET.items()} + authz = OidcAuthentication.objects.filter(state=request_args.get("state")) + try: + self.validate_json_schema( + request.GET.dict(), + "authn_response", + "Authn response object validation failed", + ) + except ValidationException: + return _visione_error_redirect( + request, + "invalid_request", + "Authn response object validation failed", + ) or render( + request, + self.error_template, + { + "error": "invalid_request", + "error_description": "Authn response object validation failed", + **visione_rp_error_context(request), + }, + status=400, + ) + + if not authz: + return _visione_error_redirect( + request, + "unauthorized_request", + "Authentication not found", + ) or render( + request, + self.error_template, + { + "error": "unauthorized request", + "error_description": _("Authentication not found"), + **visione_rp_error_context(request), + }, + status=401, + ) + + authz = authz.last() + if request.GET.get("iss") and request.GET["iss"] != authz.provider_id: + return _visione_error_redirect( + request, + "invalid_request", + "authn response validation failed: mixups attack prevention.", + ) or render( + request, + self.error_template, + { + "error": "invalid request", + "error_description": _( + "authn response validation failed: mixups attack prevention." + ), + **visione_rp_error_context(request), + }, + status=400, + ) + + code = request.GET.get("code") + authz_token = OidcAuthenticationToken.objects.create(authz_request=authz, code=code) + self.rp_conf = FederationEntityConfiguration.objects.filter( + sub=authz_token.authz_request.client_id + ).first() + if not self.rp_conf: + return _visione_error_redirect( + request, + "invalid_request", + "Relying party not found", + ) or render( + request, + self.error_template, + { + "error": "invalid request", + "error_description": _("Relying party not found"), + **visione_rp_error_context(request), + }, + status=400, + ) + + authz_data = json.loads(authz.data) + token_response = self.access_token_request( + redirect_uri=authz_data["redirect_uri"], + state=authz.state, + code=code, + issuer_id=authz.provider_id, + client_conf=self.rp_conf, + token_endpoint_url=authz.provider_configuration["token_endpoint"], + audience=[authz.provider_id], + code_verifier=authz_data.get("code_verifier"), + ) + if not token_response: + return _visione_error_redirect( + request, + "invalid_token_response", + "Token response seems not to be valid", + ) or render( + request, + self.error_template, + { + "error": "invalid token response", + "error_description": _("Token response seems not to be valid"), + **visione_rp_error_context(request), + }, + status=400, + ) + + try: + self.validate_json_schema( + token_response, + "token_response", + "Token response object validation failed", + ) + except ValidationException: + return _visione_error_redirect( + request, + "invalid_request", + "Token response object validation failed", + ) or render( + request, + self.error_template, + { + "error": "invalid_request", + "error_description": "Token response object validation failed", + **visione_rp_error_context(request), + }, + status=400, + ) + + jwks = get_jwks(authz.provider_configuration) + access_token = token_response["access_token"] + id_token = token_response["id_token"] + op_ac_jwk = get_jwk_from_jwt(access_token, jwks) + op_id_jwk = get_jwk_from_jwt(id_token, jwks) + if not op_ac_jwk or not op_id_jwk: + return _visione_error_redirect( + request, + "invalid_token", + "Authentication token seems not to be valid.", + ) or render( + request, + self.error_template, + { + "error": "invalid_token", + "error_description": _("Authentication token seems not to be valid."), + **visione_rp_error_context(request), + }, + status=403, + ) + + try: + verify_jws(access_token, op_ac_jwk) + verify_jws(id_token, op_id_jwk) + decoded_id_token = unpad_jwt_payload(id_token) + verify_at_hash(decoded_id_token, access_token) + except Exception as exc: + logger.warning("Token validation error: %s", exc) + return _visione_error_redirect( + request, + "token_verification_failed", + "Authentication token validation error.", + ) or render( + request, + self.error_template, + { + "error": "token verification failed", + "error_description": _("Authentication token validation error."), + **visione_rp_error_context(request), + }, + status=403, + ) + + decoded_access_token = unpad_jwt_payload(access_token) + authz_token.access_token = access_token + authz_token.id_token = id_token + authz_token.scope = token_response.get("scope") + authz_token.token_type = token_response["token_type"] + authz_token.expires_in = token_response["expires_in"] + authz_token.save() + + userinfo = self.get_userinfo( + authz.state, + authz_token.access_token, + authz.provider_configuration, + verify=HTTPC_PARAMS.get("connection", {}).get("ssl", True), + ) + if not userinfo: + return _visione_error_redirect( + request, + "invalid_userinfo_response", + "UserInfo response seems not to be valid", + ) or render( + request, + self.error_template, + { + "error": "invalid userinfo response", + "error_description": _("UserInfo response seems not to be valid"), + **visione_rp_error_context(request), + }, + status=400, + ) + + user_attrs = process_user_attributes(userinfo, RP_ATTR_MAP, authz.__dict__) + if not user_attrs: + return _visione_error_redirect( + request, + "missing_user_attributes", + "No user attributes have been processed", + ) or render( + request, + self.error_template, + { + "error": "missing user attributes", + "error_description": _("No user attributes have been processed"), + **visione_rp_error_context(request), + }, + status=403, + ) + + user = self.user_reunification(user_attrs) + if not user: + return _visione_error_redirect( + request, + "no_user_found", + "No user found", + ) or render( + request, + self.error_template, + { + "error": _("No user found"), + "error_description": "", + **visione_rp_error_context(request), + }, + status=403, + ) + + request.session["rt_expiration"] = 0 + if token_response.get("refresh_token"): + refresh_token = token_response["refresh_token"] + authz_token.refresh_token = refresh_token + decoded_refresh_token = unpad_jwt_payload(refresh_token) + request.session["rt_expiration"] = decoded_refresh_token["exp"] - iat_now() + request.session["rt_jti"] = decoded_refresh_token["jti"] + + from django.contrib.auth import login + login(request, user) + request.session["oidc_rp_user_attrs"] = user_attrs + request.session["at_expiration"] = decoded_access_token["exp"] - iat_now() + request.session["at_jti"] = decoded_access_token["jti"] + authz_token.user = user + authz_token.save() + return HttpResponseRedirect("/visione/complete") diff --git a/relying_party/relying_party/settings.py b/relying_party/relying_party/settings.py index e9f5f63..ac624ca 100644 --- a/relying_party/relying_party/settings.py +++ b/relying_party/relying_party/settings.py @@ -28,7 +28,7 @@ ROOT_URLCONF = 'relying_party.urls' TEMPLATES = [ { 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [], + 'DIRS': [os.path.join(BASE_DIR, 'templates')], 'APP_DIRS': True, 'OPTIONS': { 'context_processors': [ diff --git a/relying_party/relying_party/urls.py b/relying_party/relying_party/urls.py index 33ae8f2..5043015 100644 --- a/relying_party/relying_party/urls.py +++ b/relying_party/relying_party/urls.py @@ -21,6 +21,7 @@ from django.views.static import serve from scripts.build_probe import build_probe_view, diag_view from spid_cie_oidc.entity.urls import urlpatterns as ta_urlpatterns from .visione_views import VisioneCompleteView, VisioneStartView +from .rp_overrides import VisioneRpBeginView, VisioneRpCallbackView admin.site.site_header = "OIDC Federation Relying Party Administration" admin.site.site_title = "OIDC Federation Relying Party" @@ -47,6 +48,10 @@ urlpatterns.extend(ta_urlpatterns) if 'spid_cie_oidc.relying_party' in settings.INSTALLED_APPS: from spid_cie_oidc.relying_party.urls import urlpatterns as rp_urlpatterns urlpatterns.extend(rp_urlpatterns) + urlpatterns = [ + path("oidc/rp/authorization", VisioneRpBeginView.as_view(), name="spid_cie_rp_begin"), + path("oidc/rp/callback", VisioneRpCallbackView.as_view(), name="spid_cie_rp_callback"), + ] + [u for u in urlpatterns if getattr(u, "name", None) not in {"spid_cie_rp_begin", "spid_cie_rp_callback"}] from spid_cie_oidc.entity.views import entity_configuration, resolve_entity_statement diff --git a/relying_party/relying_party/visione_views.py b/relying_party/relying_party/visione_views.py index cabfa76..39d02be 100644 --- a/relying_party/relying_party/visione_views.py +++ b/relying_party/relying_party/visione_views.py @@ -171,3 +171,17 @@ class VisioneCompleteView(View): if redirect_url: return HttpResponseRedirect(redirect_url) return HttpResponseServerError("Visione backend exchange did not redirect") + + +def visione_error_redirect(request, error, error_description=""): + frontend_redirect_uri = request.session.get("visione_frontend_redirect_uri") + if not frontend_redirect_uri: + return None + params = { + "state": request.session.get("visione_state", ""), + "error": error, + } + if error_description: + params["error_description"] = error_description + separator = "&" if "?" in frontend_redirect_uri else "?" + return HttpResponseRedirect(f"{frontend_redirect_uri}{separator}{urlencode(params)}") diff --git a/relying_party/templates/rp_error.html b/relying_party/templates/rp_error.html new file mode 100644 index 0000000..f9bb1a9 --- /dev/null +++ b/relying_party/templates/rp_error.html @@ -0,0 +1,16 @@ +{% extends 'rp_base.html' %} +{% load i18n %} + +{% block centered_container %} +
+
+
{% trans error %}
+

{% trans error_description %}

+ {% if visione_return_url %} +

+ {% trans "Torna a Visione" %} +

+ {% endif %} +
+
+{% endblock %}