summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--pdf-over-gui/src/main/java/at/asit/pdfover/gui/bku/MobileBKUConnector.java208
-rw-r--r--pdf-over-gui/src/main/java/at/asit/pdfover/gui/bku/mobile/ATrustParser.java228
2 files changed, 436 insertions, 0 deletions
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<NameValuePair> 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<String, String> formOptions;
+ protected TopLevelFormBlock(@Nonnull org.jsoup.nodes.Document d, @Nonnull Map<String,String> 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<String, String> 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<String, String> 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<String, String> 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<String, String> formOptions = new HashMap<>();
+
+ public @Nonnull Iterable<Map.Entry<String, String>> 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<String> 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 <T extends TopLevelFormBlock> @Nullable T TryParseMainBlock(Class<T> 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?");
+ }
+}