Guida tecnica

Integrare SPID in Symfony: bundle, Security Component e pattern enterprise

Symfony e' la scelta privilegiata per portali della PA con esigenze enterprise: amministrazione regionale, gestionali sanitari, piattaforme universitarie. Questa guida mostra come integrare SPID nel Security Component di Symfony usando il Custom Authenticator, gestire i providers utente e definire firewalls con livelli SPID differenziati.

Tempo di lettura: 12 minuti

Approccio: Symfony Security + Custom Authenticator

Da Symfony 5.3+ il sistema raccomandato per autenticatori custom e' il nuovo Authenticator basato su AbstractAuthenticator. Per SPID conviene:

  • Implementare un SpidAuthenticator che gestisca il return dall'IdP (ACS endpoint).
  • Mantenere il SAML basso livello su una libreria collaudata come onelogin/php-saml o lightsaml/lightsaml.
  • Esporre il SP via Controller dedicato (login init, ACS, metadata, SLO).
  • Configurare firewalls multipli con livelli SPID diversi se l'applicazione ha aree a sensibilita' diversa.

Pacchetti disponibili nell'ecosistema

Composer offre alcuni bundle specifici:

  • italia/spid-symfony: non sempre allineato a Symfony 6+, ma utile come riferimento di pattern.
  • nbgrp/onelogin-saml-bundle: bundle generico SAML 2.0, ben mantenuto. Va personalizzato con i requisiti SPID (namespace, attributi, regole firma).
  • hslavich/oneloginsaml-bundle: predecessore storico.

Per CIE non esiste un bundle ufficiale: in genere si adatta lightsaml o si interfaccia a un gateway esterno via JWT.

Struttura cartelle

src/
  Controller/
    Spid/
      LoginController.php
      AcsController.php
      MetadataController.php
      LogoutController.php
  Security/
    SpidAuthenticator.php
    SpidUserProvider.php
  Service/
    SpidSamlService.php
    SpidAttributeMapper.php
  Entity/
    User.php (con codice_fiscale come UNIQUE)
config/
  packages/
    security.yaml
    spid.yaml

Configurazione security.yaml

security:
    providers:
        spid_users:
            entity:
                class: App\Entity\User
                property: codiceFiscale
    firewalls:
        spid:
            pattern: ^/(area-riservata|servizi-l2)
            stateless: false
            provider: spid_users
            custom_authenticators:
                - App\Security\SpidAuthenticator
            logout:
                path: spid_logout
        spid_l3:
            pattern: ^/(area-sanitaria|atti-firmati)
            stateless: false
            provider: spid_users
            custom_authenticators:
                - App\Security\SpidL3Authenticator
    access_control:
        - { path: ^/area-riservata, roles: ROLE_SPID_L2 }
        - { path: ^/area-sanitaria, roles: ROLE_SPID_L3 }

SpidAuthenticator: la classe core

class SpidAuthenticator extends AbstractAuthenticator {
    public function __construct(private SpidSamlService $saml,
                                private SpidAttributeMapper $mapper,
                                private EntityManagerInterface $em) {}

    public function supports(Request $request): ?bool {
        return $request->attributes->get('_route') === 'spid_acs';
    }

    public function authenticate(Request $request): Passport {
        $auth = $this->saml->authForRequest($request);
        $auth->processResponse();
        if (!$auth->isAuthenticated()) {
            throw new CustomUserMessageAuthenticationException('SPID auth failed');
        }
        $attrs = $auth->getAttributes();
        $cf = $attrs['fiscalNumber'][0] ?? null;
        $user = $this->mapper->firstOrCreate($attrs);

        return new SelfValidatingPassport(
            new UserBadge($cf, fn() => $user),
            [new RememberMeBadge()]
        );
    }
    // ... onAuthenticationSuccess, onAuthenticationFailure
}

Step-up authentication tra livelli

Pattern raccomandato: se l'utente e' loggato in L2 e tenta di accedere a una pagina L3, intercettare con un EventListener e fare AuthnRequest con AuthnContextClassRef di livello superiore:

class SpidLevelListener {
    public function onAccessDenied(AccessDeniedEvent $event) {
        if (str_contains((string)$event->getRequest()->getPathInfo(), 'sanitaria')) {
            // L3 richiesto
            $event->setResponse(new RedirectResponse($this->urlGen->generate(
                'spid_step_up', ['level' => 'L3', 'return' => $event->getRequest()->getPathInfo()]
            )));
        }
    }
}

Dependency injection del SAML service

Il SpidSamlService riceve config injection dal services.yaml:

parameters:
    spid.entity_id: '%env(SPID_ENTITY_ID)%'
    spid.cert_path: '%kernel.project_dir%/config/spid/sp.crt'
    spid.key_path: '%kernel.project_dir%/config/spid/sp.key'

services:
    App\Service\SpidSamlService:
        arguments:
            $entityId: '%spid.entity_id%'
            $certPath: '%spid.cert_path%'
            $keyPath: '%spid.key_path%'
            $idpRegistry: '@App\Service\SpidIdpRegistry'

Testing in CI

Per testare il flusso SAML in CI senza un IdP reale, si crea un IdP stub:

  • Genera assertion SAML pre-firmate per utenti di test.
  • Il SpidAuthenticator accetta una flag test_mode che bypassa la verifica metadata IdP e accetta certificati di test.
  • Test functional con WebTestCase: $client->request('POST', '/spid/acs', ['SAMLResponse' => $encodedAssertion]).

Profilazione e monitoring

Il SAML response processing puo' essere costoso (validazione firma XML, decifratura assertion). Per app ad alto traffico:

  • Cache delle public key IdP (con TTL allineato alla validita' del metadata IdP).
  • Profilazione del tempo di authenticate(): tipicamente 80-200 ms ben configurato. Sopra 500 ms indica problemi di firma o IO.
  • Metric Prometheus: tempo medio per IdP, success/failure rate, distribuzione per livello.

Symfony enterprise con SPID e CIE pronto in poche ore?

Bundle Symfony nativo che si interfaccia al nostro gateway: niente SAML interno, Security Component standard, multi-livello.

IngressoDigitale è il modulo di identità digitale della piattaforma Ordix: 1.800 ordini professionali e PA lo usano. Vai al sito Ordix