2017-04-19

Uwierzytelnianie przez ePUAP (Dostawca Tożsamości) i wywołanie usług Profilu Zaufanego

Uwierzytelnianie przez system ePUAP może przysporzyć wielu problemów, m.in. z powodu ubogiej dokumentacji i wykorzystywania dość ekscentrycznego protokołu bazującego na XML jakim jest SAML 2.0 (Security Assertion Markup Language). Udane zalogowanie to jednak nie koniec problemów - wszystkie wywołania SOAP muszą być realizowane w standardzie WS-Security w celu zapewnienia odpowiedniego bezpieczeństwa, co ponownie może sprawić problemy bez dogłębnej znajomości tematu. W tym artykule przedstawione zostanie logowanie, wylogowanie oraz wywołanie podstawowej usługi TpUserInfo. Aby osiągnąć cel użyjemy frameworku Spring Boot oraz bibliotek OpenSAML3 i Apache CXF. Biblioteka openSAML w wersji 3 praktycznie nie posiada dokumentacji, a w Internecie jest bardzo mało przykładów co skutecznie utrudnia pracę z nią. Niemożliwe jest jednak użycie starszej wersji openSAML2 z dwóch powodów:
1.     Zakończenia wsparcia, co może wiązać się z niezałatanymi lukami bezpieczeństwa
2.     Wykorzystanie starych wersji bibliotek, co powoduje różne dziwne wyjątki podczas uruchamiania aplikacji.
Z dwojga złego lepiej wybrać wersję 3.

Zanim zaczniemy - przyszedł czas na pewną kwestię, której nie mógłbym pominąć w kontekście ePUAPu. Według dokumentacji i wszystkich oficjalnych komunikatów, odpowiedzi z ePUAPU są podpisane certyfikatem, który można pobrać z zakładki „Dla integratorów” na stronie internetowej systemu. Problem w tym, że o ile jest to prawdą dla środowiska produkcyjnego, o tyle na środowisku testowym odpowiedzi są podpisane zupełnie innym certyfikatem. Jest to certyfikat self-signed wystawiony przez/dla firmy Pentacomp. Nigdzie nie znalazłem żadnej informacji o tym fakcie, certyfikat również jest niedostępny do pobrania. Ostatecznie certyfikat wyciągnąłem z przychodzącej odpowiedz SAML i dodałem go do keystore „penta.jks”, który załączony jest do niniejszego artykułu. Dzięki temu odpowiedzi są poprawnie weryfikowane i nie dostajemy na twarz irytujących wyjątków.

Dostęp do usług ePUAPu realizowany jest przez adres https://pz.gov.pl/. W przypadku środowiska testowego należy użyć subdomeny “int” - https://int.pz.gov.pl.

Jak w skrócie wygląda uwierzytelnianie za pomocą protokołu SAML 2.0 wraz z pobraniem szczegółów użytkownika:

#
Działanie
Adres produkcyjny
Adres testowy
1
Wysłanie AuthnRequest
2
Odebranie odpowiedzi w postaci artefaktu SAMLart
3
Wysłanie ArtifactResolve z parametrem SAMLart
4
Odebranie odpowiedzi ArtifactResponse z tokenem sesji
5
Wywołanie usługi TpUserInfo
6
Wysłanie LogoutRequest z nazwą wylogowywanego użytkownika
7
Odebranie i weryfikacja odpowiedzi LogoutResponse

To tyle na wstępie, przejdźmy do sedna sprawy. Tak wygląda kwestia techniczna logowania przez ePUAP: nasza aplikacja generuje zapytanie SAML AuthnRequest (zwyczajny XML), następnie do zapytania dodawany jest element <DigestValue>, <SignatureValue> oraz certyfikat w formie binarnej zakodowany w base64. Tak przygotowane zapytanie jest kompresowane oraz kodowane w base64. W kolejnym kroku biblioteka generuje stronę HTML ze standardowym formularzem. Do formularza dodawane jest pole SAMLRequest o wartości przygotowanego wcześniej zapytania SAML. Formularz jest automatycznie wysyłany metodą POST na adres ePUAPu (chyba że mamy wyłączony JavaScript, wtedy konieczne jest ręczne wysłanie formularza). ePUAP odbiera nasz formularz, parsuje zapytanie zawarte w polu SAMLRequest, weryfikuje podpis i jeśli wszystko jest w porządku pokazuje nam formularz logowania.

Wysłanie żądania uwierzytelniania w praktyce:

private Element marshallAndSign(SignableSAMLObject object) throws IllegalStateException {
    Signature signature = Util.createClientSignature();
    object.setSignature(signature);
    try {
        Marshaller marshaller = XMLObjectProviderRegistrySupport.getMarshallerFactory()
         .getMarshaller(object);
        Element dom = marshaller.marshall(object);
        Signer.signObject(signature);
        return dom;
    } catch (MarshallingException | SignatureException e) {
        throw new IllegalStateException(e.getMessage(), e);
    }
}

@RequestMapping("/login")
public void sendSignedAuthnRequest(HttpServletResponse response) {
    AuthnRequest authnRequest = new AuthnRequestBuilder().buildObject();
    authnRequest.setDestination("https://pz.gov.pl/dt/SingleSignOnService");
    authnRequest.setIssueInstant(new DateTime());
    authnRequest.setProtocolBinding("urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Artifact");
    authnRequest.setAssertionConsumerServiceURL("apilia.pl/login/callback");
    authnRequest.setID("ID_" + UUID.randomUUID().toString());
    authnRequest.setVersion(SAMLVersion.VERSION_20);

    Issuer issuer = new IssuerBuilder().buildObject();
    issuer.setValue("apilia.pl");
    authnRequest.setIssuer(issuer);

    NameIDPolicy pol = new NameIDPolicyBuilder().buildObject();
    pol.setFormat("urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified");
    pol.setAllowCreate(false);
    authnRequest.setNameIDPolicy(pol);

    marshallAndSign(authnRequest);
    SAMLPeerEntityContext peerEntityContext = context.getSubcontext(SAMLPeerEntityContext.class, true);
    SAMLEndpointContext endpointContext = peerEntityContext.getSubcontext(SAMLEndpointContext.class, true);
    SingleSignOnService idpEndpoint = SamlUtil.buildXmlObject(null, SingleSignOnService.class, SingleSignOnService.DEFAULT_ELEMENT_NAME);
    idpEndpoint.setLocation("https://pz.gov.pl/dt/SingleSignOnService");
    idpEndpoint.setBinding(SAMLConstants.SAML2_REDIRECT_BINDING_URI);
    endpointContext.setEndpoint(idpEndpoint);

    HTTPPostEncoder encoder = new HTTPPostEncoder();
    encoder.setVelocityEngine(velocityEngine);
    encoder.setMessageContext(context);
    encoder.setHttpServletResponse(response);

    try {
        encoder.initialize();
        encoder.encode();
    } catch (ComponentInitializationException | MessageEncodingException ex) {
        ex.printStackTrace();
    }
}

Podczas generowania ID, np. authnRequest.setID("ID_" + UUID.randomUUID().toString()); ważne jest aby ID nie rozpoczynało się cyfrą
Jeśli wpisaliśmy poprawne dane logowania, ePUAP wysyła żądanie POST do naszej aplikacji, na adres skonfigurowany podczas rejestracji aplikacji w ePUAPie. W żądaniu przesyłane jest pole SAMLart, które poprzez wysłanie zapytania ArtifactResolve pozwoli na uzyskanie tokenu sesji tgsid pozwalającego na autoryzowanie zapytań do webserwisu. Po odebraniu z żądania ArtifactResolve odpowiedzi (ArtifactResponse), weryfikujemy poprawność podpisu. Cały proces zaprezentowano poniżej:
@RequestMapping(value = "/login/callback", method = RequestMethod.POST)
public void sendArtifactResolve(@RequestParam String SAMLart) {
    ArtifactResolve artifactResolve = new ArtifactResolveBuilder().buildObject();
    artifactResolve.setID("ID_" + UUID.randomUUID().toString());
    artifactResolve.setIssueInstant(new DateTime());

    Artifact artifact = new ArtifactBuilder().buildObject();
    artifact.setArtifact(SAMLart);
    artifactResolve.setArtifact(artifact);

    Issuer issuer = new IssuerBuilder().buildObject();
    issuer.setValue("apilia.pl");
    artifactResolve.setIssuer(issuer);

    Element element = marshallAndSign(artifactResolve); 
    try {
        WebServiceTemplate template = new WebServiceTemplate();
        ArtifactResponse artifactResponse =
            template.sendSourceAndReceive("https://int.pz.gov.pl/dt-services/idpArtifactResolutionService",
            new DOMSource(element), this::processResponse);

        Response msg = (Response) artifactResponse.getMessage();
        Optional<Assertion> assertion = msg.getAssertions().stream().findFirst();
        if (!assertion.isPresent()) {
            return;
        }

        SignatureValidator.validate(artifactResponse.getSignature(), Util.createServiceCredential());
        String tgsid = assertion.get().getID(); //identyfikator sesji
    } catch (SignatureException | TpUserInfoException e) {
        e.printStackTrace();
    }
}

private ArtifactResponse processResponse(Source source) {
    try {
        Transformer transformer = TransformerFactory.newInstance().newTransformer();
        DOMResult result = new DOMResult();
        transformer.transform(source, result);
        Document el = (Document) result.getNode();
        return (ArtifactResponse) new ArtifactResponseUnmarshaller()
            .unmarshall(el.getDocumentElement());
    } catch (TransformerException | UnmarshallingException ex) {
        ex.printStackTrace();
    }
    return null;
}


Wylogowanie przebiega podobnie do logowania: nasza aplikacje generuje zapytanie SAML LogoutRequest, dodaje wymagane elementy oraz nazwę wylogowywanego użytkownika i koduje całość przy pomocy base64. Następnie generowany jest formularz HTML z polem SAMLRequest, którego wartością jest zapytanie, a formularz jest wysyłany POSTem do ePUAPu. ePUAP odbiera i parsuje zapytanie, jeśli wszystko jest w porządku to teraz on generuje formularz HTML który jest automatycznie wysyłany na adres naszej aplikacji skonfigurowany w ePUAPie. Ten formularz posiada pole SAMLResponse z zapytaniem LogoutResponse, które następnie nasza aplikacja weryfikuje i sprawdza czy wylogowanie się powiodło.
Wysyłanie żądania wylogowania w praktyce:
@RequestMapping("/logout")
public void sendSignedLogoutRequest(@RequestParam String username) {
    LogoutRequest logoutRequest = new LogoutRequestBuilder().buildObject();
    logoutRequest.setID("ID_" + UUID.randomUUID().toString());
    logoutRequest.setDestination("https://pz.gov.pl/dt/SingleLogoutService");
    logoutRequest.setIssueInstant(new DateTime());
    logoutRequest.setVersion(SAMLVersion.VERSION_20);

    Issuer issuer = new IssuerBuilder().buildObject();
    issuer.setValue("apilia.pl");
    logoutRequest.setIssuer(issuer);

    NameID pol = new NameIDBuilder().buildObject();
    pol.setFormat("urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified");
    pol.setValue(username);
    logoutRequest.setNameID(pol);

    marshallAndSign(logoutRequest);

    SAMLPeerEntityContext peerEntityContext = context.getSubcontext(SAMLPeerEntityContext.class, true);
    SAMLEndpointContext endpointContext = peerEntityContext.getSubcontext(SAMLEndpointContext.class, true);
    SingleLogoutService idpEndpoint = SamlUtil.buildXmlObject(null, SingleLogoutService.class, SingleLogoutService.DEFAULT_ELEMENT_NAME);
    idpEndpoint.setLocation("https://pz.gov.pl/dt/SingleLogoutService");
    idpEndpoint.setBinding(SAMLConstants.SAML2_REDIRECT_BINDING_URI);
    endpointContext.setEndpoint(idpEndpoint);

    HTTPPostEncoder encoder = new HTTPPostEncoder();
    encoder.setVelocityEngine(velocityEngine);
    encoder.setMessageContext(context);
    encoder.setHttpServletResponse(response);

    try {
        encoder.initialize();
        encoder.encode();
    } catch (ComponentInitializationException | MessageEncodingException ex) {
        ex.printStackTrace();
    }
}

Odebranie i weryfikacja odpowiedzi:
@RequestMapping(value = "/logout/callback", method = RequestMethod.POST)
public void logoutCallback(HttpServletRequest request, HttpServletResponse response) throws IOException {
    try {
        HTTPPostDecoder decoder = new HTTPPostDecoder();
        decoder.setHttpServletRequest(request);
        decoder.initialize();
        decoder.decode();
        LogoutResponse logoutResponse = (LogoutResponse) decoder.getMessageContext().getMessage();
     
        // walidacja certyfikatu odpowiedzi
        SAMLSignatureProfileValidator profileValidator = new SAMLSignatureProfileValidator();
        profileValidator.validate(logoutResponse.getSignature());
        SignatureValidator.validate(logoutResponse.getSignature(), Util.createServiceCredential());
    } catch (MessageDecodingException | SignatureException e) {
        e.printStackTrace();
    }
}


Na koniec została jeszcze konfiguracja usługi TpUserInfo. Samo wywołanie przebiega dokładnie tak samo jak w przypadku wszystkich innych usług wygenerowanych przez CXF, jednak ustawienie wszystkich parametrów może przysporzyć pewnych problemów.

WSDL do usług można znaleźć dodając „?wsdl” do adresu usługi, np. https://int.pz.gov.pl/pz-services/tpUserInfo?wsdl

WSProviderConfig.init(true, false, true);
TpUserInfoServiceService ss = new TpUserInfoServiceService();
TpUserInfoService port = ss.getTpUserInfoService();
Client client = ClientProxy.getClient(port);
Endpoint cxfEndpoint = client.getEndpoint();

Map<String, Object> requestContext = client.getRequestContext();

// dane certyfikatu klienta
Properties securityProperties = new Properties();
securityProperties.setProperty("org.apache.ws.security.certificate", "cert");
securityProperties.setProperty("org.apache.ws.security.crypto.provider", "org.apache.ws.security.components.crypto.Merlin");
securityProperties.setProperty("org.apache.ws.security.crypto.merlin.keystore.type", "jks");
securityProperties.setProperty("org.apache.ws.security.crypto.merlin.keystore.password", "12345");
securityProperties.setProperty("org.apache.ws.security.crypto.merlin.file", "/ścieżka/do/keystore.jks");
securityProperties.setProperty("org.apache.ws.security.crypto.merlin.keystore.private.password", "12345");
securityProperties.setProperty("org.apache.ws.security.crypto.merlin.keystore.alias", "cert");

// dane certyfikatu ePUAP
Properties responseProperties = new Properties();
responseProperties.setProperty("org.apache.ws.security.certificate", "cert");
responseProperties.setProperty("org.apache.ws.security.crypto.provider", "org.apache.ws.security.components.crypto.Merlin");
responseProperties.setProperty("org.apache.ws.security.crypto.merlin.keystore.type", "jks");
responseProperties.setProperty("org.apache.ws.security.crypto.merlin.keystore.alias", "penta");
responseProperties.setProperty("org.apache.ws.security.crypto.merlin.keystore.password", "sspass");
responseProperties.setProperty("org.apache.ws.security.crypto.merlin.file", "/ścieżka/do/penta.jks");
requestContext.put("ws-security.signature.properties", securityProperties);
requestContext.put("ws-security.encryption.properties", responseProperties);

// nie wykorzystujemy adresu z WSDLa, gdyż na zmianę używamy adresu testowego i produkcyjnego
BindingProvider bp = (BindingProvider) port;
bp.getRequestContext().put(BindingProvider.ENDPOINT_ADDRESS_PROPERTY, "https://pz.gov.pl/pz-services/tpUserInfo");
 
//tgsid to identyfikator sesji uzyskany przy pomocy zapytania ArtifactResolve
TpUserInfo tpUserInfo = port.getTpUserInfo(tgsid, null);

Procedurę tworzenia keystore i dodawania certyfikatów można bez problemu znaleźć w Internecie (o, np. tutaj).

Dzięki zaprezentowanym w artykule procesom i metodom możliwe jest uwierzytelnienie i wywołanie usług ePUAPu.

Certyfikat testowego ePUAPu: penta.jks