From 69e427953c0a877762a1c89da266dba70195459a Mon Sep 17 00:00:00 2001 From: Jakob Heher Date: Mon, 3 Oct 2022 17:37:14 +0200 Subject: work in progress on the new & improved atrust handler --- .../asit/pdfover/gui/bku/MobileBKUConnector.java | 208 +++++++++++++++++++ .../asit/pdfover/gui/bku/mobile/ATrustParser.java | 228 +++++++++++++++++++++ 2 files changed, 436 insertions(+) create mode 100644 pdf-over-gui/src/main/java/at/asit/pdfover/gui/bku/MobileBKUConnector.java create mode 100644 pdf-over-gui/src/main/java/at/asit/pdfover/gui/bku/mobile/ATrustParser.java diff --git a/pdf-over-gui/src/main/java/at/asit/pdfover/gui/bku/MobileBKUConnector.java b/pdf-over-gui/src/main/java/at/asit/pdfover/gui/bku/MobileBKUConnector.java new file mode 100644 index 00000000..6f6e5301 --- /dev/null +++ b/pdf-over-gui/src/main/java/at/asit/pdfover/gui/bku/MobileBKUConnector.java @@ -0,0 +1,208 @@ +package at.asit.pdfover.gui.bku; + +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.charset.Charset; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import javax.annotation.Nonnull; + +import org.apache.hc.client5.http.classic.methods.HttpGet; +import org.apache.hc.client5.http.classic.methods.HttpPost; +import org.apache.hc.client5.http.entity.UrlEncodedFormEntity; +import org.apache.hc.client5.http.entity.mime.MultipartEntityBuilder; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.CloseableHttpResponse; +import org.apache.hc.client5.http.impl.classic.HttpClients; +import org.apache.hc.core5.http.ClassicHttpRequest; +import org.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.Header; +import org.apache.hc.core5.http.HttpEntity; +import org.apache.hc.core5.http.HttpStatus; +import org.apache.hc.core5.http.NameValuePair; +import org.apache.hc.core5.http.ProtocolException; +import org.apache.hc.core5.http.io.entity.EntityUtils; +import org.apache.hc.core5.http.message.BasicNameValuePair; +import org.jsoup.Jsoup; +import org.jsoup.nodes.Document; +import org.jsoup.nodes.Element; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import at.asit.pdfover.commons.Constants; +import at.asit.pdfover.gui.bku.mobile.ATrustParser; +import at.asit.pdfover.gui.workflow.states.MobileBKUState; +import at.asit.pdfover.gui.workflow.states.MobileBKUState.UsernameAndPassword; +import at.asit.pdfover.signer.BkuSlConnector; +import at.asit.pdfover.signer.SignatureException; +import at.asit.pdfover.signer.UserCancelledException; +import at.asit.pdfover.signer.pdfas.PdfAs4SLRequest; + +import static at.asit.pdfover.commons.Constants.ISNOTNULL; + +public class MobileBKUConnector implements BkuSlConnector { + private static final Logger log = LoggerFactory.getLogger(MobileBKUConnector.class); + + private final @Nonnull MobileBKUState state; + public MobileBKUConnector(@Nonnull MobileBKUState state) { + this.state = state; + state.storeRememberedCredentialsTo(this.credentials); + } + + public @Nonnull UsernameAndPassword credentials = new UsernameAndPassword(); + + /** + * This method takes the SLRequest from PDF-AS, and blocks until it has obtained a response + */ + @Override + public String handleSLRequest(PdfAs4SLRequest slRequest) throws SignatureException, UserCancelledException { + log.debug("Got security layer request: (has file part: {})\n{}", (slRequest.signatureData != null), slRequest.xmlRequest); + try (final CloseableHttpClient httpClient = HttpClients.custom().disableRedirectHandling().build()) { + ClassicHttpRequest currentRequest = buildInitialRequest(slRequest); + ATrustParser.Result response; + while ((response = sendHTTPRequest(httpClient, currentRequest)).slResponse == null) + currentRequest = presentResponseToUserAndReturnNextRequest(ISNOTNULL(response.html)); + log.debug("Returning security layer response:\n{}", response.slResponse); + return response.slResponse; + } catch (UserCancelledException e) { + throw e; + } catch (Exception e) { + throw new SignatureException(e); + } + } + + /** + * Sends the specified request, following redirects (including meta-tag redirects) recursively + * @return The JSOUP document retrieved + * @throws IOException on HTTP error codes + * @throws ProtocolException + * @throws URISyntaxException + * @throws InterruptedException + */ + private @Nonnull ATrustParser.Result sendHTTPRequest(CloseableHttpClient httpClient, ClassicHttpRequest request) throws IOException, ProtocolException, URISyntaxException { + log.debug("Sending request to '{}'...", request.getUri().toString()); + try (final CloseableHttpResponse response = httpClient.execute(request)) { + int httpStatus = response.getCode(); + if ((httpStatus == HttpStatus.SC_MOVED_PERMANENTLY) || (httpStatus == HttpStatus.SC_MOVED_TEMPORARILY)) { + Header redirectPath = response.getHeader("location"); + if (redirectPath == null) + throw new IOException("Received HTTP redirect, but no Location header."); + return sendHTTPRequest(httpClient, buildRedirectedRequest(request.getUri(), redirectPath.getValue())); + } + + if (httpStatus != HttpStatus.SC_OK) { + throw new IOException("Got HTTP status " + httpStatus + " " + Optional.ofNullable(response.getReasonPhrase()).orElse("(null)")); + } + + Header refreshHeader = response.getHeader("refresh"); + if (refreshHeader != null) + return sendHTTPRequest(httpClient, buildRefreshHeaderRequest(request.getUri(), refreshHeader.getValue())); + + HttpEntity responseEntity = response.getEntity(); + if (responseEntity == null) + throw new IOException("Did not get a HTTP body (entity == null)"); + + ContentType contentType = ContentType.parse(responseEntity.getContentType()); + String entityBody = EntityUtils.toString(response.getEntity(),contentType.getCharset()); + if (entityBody == null) + throw new IOException("Did not get a HTTP body (entity content == null)"); + + if ("text/html".equals(contentType.getMimeType())) { + Document resultDocument = Jsoup.parse(entityBody, request.getUri().toASCIIString()); + if (resultDocument == null) + { + log.error("Failed to parse A-Trust server response as HTML:\n{}", entityBody); + throw new IOException("Failed to parse HTML"); + } + + Element metaRefresh = resultDocument.selectFirst("meta[http-equiv=\"refresh\"i]"); + if (metaRefresh != null) { + String refreshContent = metaRefresh.attr("content"); + if (!refreshContent.isEmpty()) + return sendHTTPRequest(httpClient, buildRefreshHeaderRequest(request.getUri(), refreshContent)); + } + return ATrustParser.Parse(resultDocument); + } else { + return ATrustParser.Parse(request.getUri(), contentType.getMimeType(), entityBody); + } + } + } + + /** + * Builds a HttpRequest for the given base URI and (potentially relative) redirect path + */ + private static @Nonnull ClassicHttpRequest buildRedirectedRequest(URI baseURI, String redirectLocation) { + log.debug("following redirect: {}", redirectLocation); + return new HttpGet(baseURI.resolve(redirectLocation)); + } + + /** + * Builds a HttpRequest for redirection to a given Refresh header value + */ + private static @Nonnull ClassicHttpRequest buildRefreshHeaderRequest(URI baseURI, String refreshHeader) throws IOException { + // refresh value is delay in seconds, semicolon, URL=, url + Pattern pattern = Pattern.compile("^\\s*[0-9\\.]+\\s*;\\s*(?:[uU][rR][lL]\s*=\s*)(.+)$"); + Matcher matcher = pattern.matcher(refreshHeader); + if (!matcher.matches()) + throw new IOException("Got invalid Refresh header with value \"" + refreshHeader + "\"."); + String redirectURL = matcher.group(1); + return buildRedirectedRequest(baseURI, redirectURL); + } + + /** + * Builds the initial request to A-Trust based on the specified SL request + */ + private static @Nonnull ClassicHttpRequest buildInitialRequest(PdfAs4SLRequest slRequest) { + HttpPost post = new HttpPost(Constants.MOBILE_BKU_URL); + if (slRequest.signatureData != null) { + post.setEntity(MultipartEntityBuilder.create() + .addBinaryBody("fileupload", slRequest.signatureData.getByteArray(), ContentType.APPLICATION_PDF, "sign.pdf") + .addTextBody("XMLRequest", slRequest.xmlRequest) + .build()); + } else { + post.setEntity(UrlEncodedFormEntityBuilder.create() + .add("XMLRequest", slRequest.xmlRequest) + .build()); + } + return post; + } + + private static @Nonnull ClassicHttpRequest buildFormSubmit(@Nonnull ATrustParser.HTMLResult html) { + HttpPost post = new HttpPost(html.formTarget); + + var builder = MultipartEntityBuilder.create(); + for (var pair : html.iterateFormOptions()) + builder.addTextBody(pair.getKey(), pair.getValue()); + + post.setEntity(builder.build()); + return post; + } + + /** + * Main lifting function for MobileBKU UX + * @return the next request to make, or null if the current response should be returned + */ + private @Nonnull ClassicHttpRequest presentResponseToUserAndReturnNextRequest(@Nonnull ATrustParser.HTMLResult html) throws UserCancelledException { + if (html.usernamePasswordBlock != null) { + while ((this.credentials.username == null) || (this.credentials.password == null)) { + this.state.getCredentialsFromUserTo(this.credentials, null); + } + html.usernamePasswordBlock.setUsernamePassword(this.credentials.username, this.credentials.password); + return buildFormSubmit(html); + } + throw new IllegalStateException("No top-level block is set? Something has gone terribly wrong."); + } + + private static class UrlEncodedFormEntityBuilder { + private UrlEncodedFormEntityBuilder() {} + private List values = new ArrayList<>(); + public static @Nonnull UrlEncodedFormEntityBuilder create() { return new UrlEncodedFormEntityBuilder(); } + public @Nonnull UrlEncodedFormEntityBuilder add(String key, String value) { values.add(new BasicNameValuePair(key, value)); return this; } + public @Nonnull UrlEncodedFormEntity build() { return new UrlEncodedFormEntity(values, Charset.forName("utf-8")); } + } +} diff --git a/pdf-over-gui/src/main/java/at/asit/pdfover/gui/bku/mobile/ATrustParser.java b/pdf-over-gui/src/main/java/at/asit/pdfover/gui/bku/mobile/ATrustParser.java new file mode 100644 index 00000000..f452202d --- /dev/null +++ b/pdf-over-gui/src/main/java/at/asit/pdfover/gui/bku/mobile/ATrustParser.java @@ -0,0 +1,228 @@ +package at.asit.pdfover.gui.bku.mobile; + +import java.lang.reflect.InvocationTargetException; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import javax.annotation.CheckForNull; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import org.jsoup.Jsoup; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import static at.asit.pdfover.commons.Constants.ISNOTNULL; + +public class ATrustParser { + private static final Logger log = LoggerFactory.getLogger(ATrustParser.class); + + private static class ComponentParseFailed extends Exception {} + + private static class TopLevelFormBlock { + protected @Nonnull org.jsoup.nodes.Document htmlDocument; + protected @Nonnull Map formOptions; + protected TopLevelFormBlock(@Nonnull org.jsoup.nodes.Document d, @Nonnull Map fO) { this.htmlDocument = d; this.formOptions = fO; } + + protected void abortIfElementMissing(@Nonnull String selector) throws ComponentParseFailed { + if (this.htmlDocument.selectFirst(selector) != null) return; + log.debug("Tested for element {} -- not found.", selector); + throw new ComponentParseFailed(); + } + protected @Nonnull String getAttributeEnsureNotNull(@Nonnull String selector, @Nonnull String attribute) throws ComponentParseFailed { + var elm = this.htmlDocument.selectFirst(selector); + if (elm == null) { log.warn("Expected element not found in response: {}", selector); throw new ComponentParseFailed(); } + if (!elm.hasAttr(attribute)) { log.warn("Element {} is missing expected attribute '{}'.", selector, attribute); throw new ComponentParseFailed(); } + return ISNOTNULL(elm.attr(attribute)); + } + protected @Nonnull URI getURIAttributeEnsureNotNull(@Nonnull String selector, @Nonnull String attribute) throws ComponentParseFailed { + String value = getAttributeEnsureNotNull(selector, attribute); + try { + return new URI(value); + } catch (URISyntaxException e) { + if (attribute.startsWith("abs:")) + attribute = ISNOTNULL(attribute.substring(4)); + log.warn("Element {} attribute {} is '{}', could not be parsed as URI", selector, attribute, getAttributeEnsureNotNull(selector, attribute)); + throw new ComponentParseFailed(); + } + } + } + + public static class UsernamePasswordBlock extends TopLevelFormBlock { + private @Nonnull String usernameKey; + private @Nonnull String passwordKey; + + public void setUsernamePassword(String username, String password) { + formOptions.put(usernameKey, username); formOptions.put(passwordKey, password); + } + + private UsernamePasswordBlock(@Nonnull org.jsoup.nodes.Document htmlDocument, @Nonnull Map formOptions) throws ComponentParseFailed { + super(htmlDocument, formOptions); + abortIfElementMissing("#handynummer"); + this.usernameKey = getAttributeEnsureNotNull("#handynummer", "name"); + this.passwordKey = getAttributeEnsureNotNull("#signaturpasswort", "name"); + + /* remove unused submit buttons */ + // TODO: we should generalize this somehow for input type="submit" + // TODO: do we maybe want to use the actual cancel button? + formOptions.remove(getAttributeEnsureNotNull("#Button_Cancel", "name")); + formOptions.remove(getAttributeEnsureNotNull("#Button_localBku", "name")); + } + } + + public static class QRCodeBlock extends TopLevelFormBlock { + public @Nonnull URI qrCodeURI; + + private QRCodeBlock(@Nonnull org.jsoup.nodes.Document htmlDocument, @Nonnull Map formOptions) throws ComponentParseFailed { + super(htmlDocument, formOptions); + abortIfElementMissing("#qrimage"); + this.qrCodeURI = getURIAttributeEnsureNotNull("#qrimage", "abs:src"); + } + } + + public static class Fido2Block extends TopLevelFormBlock { + private @Nonnull String fidoOptions; + private @Nonnull String credentialResultKey; + + public @Nonnull String getFIDOOptions() { return fidoOptions; } + public void setFIDOResult(String result) { formOptions.put(credentialResultKey, result); } + + private Fido2Block(@Nonnull org.jsoup.nodes.Document htmlDocument, @Nonnull Map formOptions) throws ComponentParseFailed { + super(htmlDocument, formOptions); + abortIfElementMissing("#fidoBlock"); + this.fidoOptions = getAttributeEnsureNotNull("#credentialOptions", "value"); + this.credentialResultKey = getAttributeEnsureNotNull("#credentialResult", "name"); + } + } + + public static class HTMLResult { + public final @Nonnull org.jsoup.nodes.Document htmlDocument; + public final @Nonnull URI formTarget; + public final @Nonnull Map formOptions = new HashMap<>(); + + public @Nonnull Iterable> iterateFormOptions() { return ISNOTNULL(formOptions.entrySet()); } + + /* optional mode switch links (any number may or may not be null) */ + public final @CheckForNull URI fido2Link; + + /* top-level blocks (exactly one is not null) */ + public final @CheckForNull UsernamePasswordBlock usernamePasswordBlock; + public final @CheckForNull QRCodeBlock qrCodeBlock; + public final @CheckForNull Fido2Block fido2Block; + + private void validate() { + Set populated = new HashSet<>(); + + if (usernamePasswordBlock != null) populated.add("usernamePasswordBlock"); + if (qrCodeBlock != null) populated.add("qrCodeBlock"); + if (fido2Block != null) populated.add("fido2Block"); + + switch (populated.size()) { + case 0: log.error("Did not find any top-level blocks.\n{}", this.htmlDocument.toString()); break; + case 1: /* passed */ return; + default: log.error("Found too many top-level blocks: {}\n", String.join(", ", populated), this.htmlDocument.toString()); break; + } + throw new IllegalArgumentException("Unknown A-Trust page reached?"); + } + + private @Nullable URI getHrefIfExists(String selector) { + var elm = htmlDocument.selectFirst(selector); + if (elm == null) return null; + + String url = elm.absUrl("href"); + try { + return new URI(url); + } catch (Exception e) { + log.warn("Invalid {} href attribute: {} ({})", selector, elm.attr("href"), url); + return null; + } + } + + /** + * tries to parse T using its constructor; if ComponentParseFailed is thrown, swallows it + */ + private @Nullable T TryParseMainBlock(Class clazz) { + try { + return clazz.getDeclaredConstructor(org.jsoup.nodes.Document.class, Map.class).newInstance(this.htmlDocument, this.formOptions); + } catch (NoSuchMethodException | InstantiationException | IllegalAccessException | IllegalArgumentException | SecurityException e) { + log.error("Internal parser error; check your method signatures?", e); + return null; + } catch (InvocationTargetException wrappedE) { + Throwable e = wrappedE.getCause(); + if (!(e instanceof ComponentParseFailed)) { + if (e instanceof RuntimeException) + throw (RuntimeException)e; + log.warn("Unexpected parser failure.", e); + } + return null; + } + } + + private HTMLResult(@Nonnull org.jsoup.nodes.Document htmlDocument) { + this.htmlDocument = htmlDocument; + + var forms = htmlDocument.getElementsByTag("form"); + if (forms.size() != 1) { + log.error("Found {} forms in A-Trust response document, expected 1. Document:\n{}", forms.size(), htmlDocument.toString()); + throw new IllegalArgumentException("Failed to parse A-Trust response page"); + } + + var mainForm = ISNOTNULL(forms.first()); /* size check above */ + String formAction = mainForm.absUrl("action"); + try { + this.formTarget = new URI(formAction); + } catch (URISyntaxException e) { + log.error("Invalid form target in page: {} ({})", mainForm.attr("action"), formAction, e); + throw new IllegalArgumentException("Failed to parse A-Trust response page"); + } + + for (var input : mainForm.select("input")) { + String name = input.attr("name"); + if (name.isEmpty()) + continue; + this.formOptions.put(name, input.attr("value")); + } + + this.fido2Link = getHrefIfExists("#FidoButton"); + + this.usernamePasswordBlock = TryParseMainBlock(UsernamePasswordBlock.class); + this.qrCodeBlock = TryParseMainBlock(QRCodeBlock.class); + this.fido2Block = TryParseMainBlock(Fido2Block.class); + + validate(); + } + } + + public static class Result { + public final @CheckForNull String slResponse; + public final @CheckForNull HTMLResult html; + + private Result(@Nonnull String slResponse) { this.slResponse = slResponse; this.html = null; } + private Result(@Nonnull org.jsoup.nodes.Document htmlDocument) { this.slResponse = null; this.html = new HTMLResult(htmlDocument); } + } + + public static @Nonnull Result Parse(@Nonnull org.jsoup.nodes.Document htmlDocument) { return new Result(htmlDocument); } + + public static @Nonnull Result Parse(URI baseURI, String contentType, @Nonnull String content) { + if (contentType.equals("text/html")) + { + var document = Jsoup.parse(content, baseURI.toASCIIString()); + if (document == null) + { + log.error("Failed to parse HTML (document == null):\n{}", content); + throw new IllegalArgumentException("A-Trust parsing failed"); + } + return Parse(document); + } + + if (contentType.endsWith("/xml")) + return new Result(content); + + log.error("Unknown content-type \"{}\" from URI {}", contentType, baseURI.toString()); + throw new IllegalArgumentException("Unknown A-Trust page reached?"); + } +} -- cgit v1.2.3