diff --git a/office-alexander-logistics-app/src/main/java/com/alexanderlogistics/ksef/KSeFAuthManager.java b/office-alexander-logistics-app/src/main/java/com/alexanderlogistics/ksef/KSeFAuthManager.java new file mode 100644 index 0000000..682ccce --- /dev/null +++ b/office-alexander-logistics-app/src/main/java/com/alexanderlogistics/ksef/KSeFAuthManager.java @@ -0,0 +1,332 @@ +package com.alexanderlogistics.ksef; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.security.PublicKey; +import java.time.Duration; +import java.util.Optional; +import java.util.logging.Logger; + +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.imixs.workflow.exceptions.InvalidAccessException; +import org.imixs.workflow.exceptions.PluginException; + +import jakarta.annotation.PostConstruct; +import jakarta.annotation.security.RunAs; +import jakarta.ejb.Lock; +import jakarta.ejb.LockType; +import jakarta.ejb.Singleton; +import jakarta.inject.Inject; +import jakarta.json.JsonObject; +import jakarta.json.bind.Jsonb; +import jakarta.json.bind.JsonbBuilder; + +/** + * Der KSeFAuthManager stellt Methoden bereit um einen Access Token für das KSeF + * System in Polen zu erstellen. + * + * + */ +@Singleton +@RunAs("org.imixs.ACCESSLEVEL.MANAGERACCESS") +public class KSeFAuthManager { + + private static Logger logger = Logger.getLogger(KSeFAuthManager.class.getName()); + + public static final String ENV_KSEF_API_TOKEN = "ksef.api.token"; + public static final String ENV_KSEF_API_NIP = "ksef.api.nip"; + public static final String ENV_KSEF_API_ENDPOINT = "ksef.api.endpoint"; + public static final String ENV_KSEF_API_DEBUG = "ksef.api.debug"; + + public static final String ERROR_CONFIG = "CONFIG_ERROR"; + public static final String ERROR_API = "API_ERROR"; + + @Inject + @ConfigProperty(name = ENV_KSEF_API_TOKEN) + Optional ksefToken; + + @Inject + @ConfigProperty(name = ENV_KSEF_API_NIP) + Optional ksefNip; + + @Inject + @ConfigProperty(name = ENV_KSEF_API_ENDPOINT) + Optional ksefEndpoint; + + @Inject + @ConfigProperty(name = ENV_KSEF_API_DEBUG, defaultValue = "false") + boolean debug; + + private HttpClient httpClient; + private String baseURI = ""; + private String challenge = ""; + private String refNumber = null; + private String authToken = null; + private String symmetricCertBase64; // der Zertifikat-String aus der API + private PublicKey symmetricPublicKey; // der extrahierte RSA Public Key + + @PostConstruct + void init() { + + if (!ksefToken.isPresent()) { + throw new InvalidAccessException("Missing environment parameter ksef.api.token!"); + } + if (!ksefEndpoint.isPresent()) { + throw new InvalidAccessException("Missing environment parameter ksef.api.endpoint!"); + } + if (!ksefNip.isPresent()) { + throw new InvalidAccessException("Missing environment parameter ksef.api.nip!"); + } + + baseURI = ksefEndpoint.get(); + if (baseURI.endsWith("/")) { + baseURI = baseURI.substring(0, baseURI.length() - 1); + } + + httpClient = HttpClient.newBuilder() + .version(HttpClient.Version.HTTP_2) + .connectTimeout(Duration.ofSeconds(10)) + .build(); + + // this.jsonb = JsonbBuilder.create(); + } + + public void setKsefToken(String token) { + this.ksefToken = Optional.ofNullable(token); + } + + public void setKsefNip(String nip) { + this.ksefNip = Optional.ofNullable(nip); + } + + public void setKsefEndpoint(String endpoint) { + this.ksefEndpoint = Optional.ofNullable(endpoint); + } + + public void setDebug(boolean debug) { + this.debug = debug; + } + + /** + * Encode the access token + * + * @return + */ + public String getEncodedToken() { + + String result = java.util.Base64.getEncoder().encodeToString(ksefToken.orElse("").getBytes()); + + return result; + } + + public String getBaseURI() { + return baseURI; + } + + public String getRefNumber() { + return refNumber; + } + + public String getAuthToken() { + return authToken; + } + + public PublicKey getSymmetricPublicKey() { + return symmetricPublicKey; + } + + public String getSymmetricCertBase64() { + return symmetricCertBase64; + } + + public String getChallengeValue() { + return challenge; + } + + /** + * Diese method führt den challenge Request durch + * + * @throws Exception + */ + // @Lock(LockType.READ) + @Lock(LockType.WRITE) + public void getChallenge() throws PluginException { + logger.info("├── KSeF API Challenge..."); + String uri = baseURI + "/auth/challenge"; + logger.info("│ ├── Endpoint: " + uri); + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(uri)) // nur Endpoint checken + .header("Accept", "application/json") + .POST(HttpRequest.BodyPublishers.noBody()) // + .build(); + + HttpResponse response; + try { + response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + logger.info("│ ├── Response Code = " + response.statusCode()); + if (debug) { + logger.info("Response: " + response.body()); + } + // Prüfen, dass wir eine API-Antwort bekommen, kein HTML + if (response.statusCode() >= 500) { + throw new PluginException(KSeFAuthManager.class.getSimpleName(), ERROR_CONFIG, + "Unable to access KSeF API: response=" + response.statusCode()); + } + + // Challenge aus Response extrahieren und speichern + if (response.statusCode() == 200) { + try (Jsonb jsonb = JsonbBuilder.create()) { + // Annahme: response.body() ist ein String mit JSON-Inhalt + JsonObject jsonObject = jsonb.fromJson(response.body(), JsonObject.class); + challenge = jsonObject.getString("challenge"); + if (debug) { + logger.info("│ ├── challenge: " + challenge); + } + } catch (Exception e) { + logger.severe("├── ⚠️ Error parsing JSON response" + e.getMessage()); + throw new PluginException(KSeFAuthManager.class.getSimpleName(), ERROR_API, + "Error parsing JSON response: " + e.getMessage()); + + } + + } + } catch (IOException | InterruptedException e) { + throw new PluginException(KSeFAuthManager.class.getSimpleName(), ERROR_API, + "API Error: Unable to parse KSeF API response"); + } + + } + + /** + * Helper method to request the KSeF Auth Token and referenceNumber + * + * @throws Exception + */ + @Lock(LockType.WRITE) + public void generateAuthToken() throws Exception { + logger.info("├── KSeF API auth ksef-token..."); + String uri = baseURI + "/auth/ksef-token"; + logger.info("│ ├── Endpoint: " + uri); + String jsonPayload = String.format( + "{" + + "\"challenge\": \"%s\"," + + "\"contextIdentifier\": {" + + " \"type\": \"Nip\"," + + " \"value\": \"%s\"" + + "}," + + "\"encryptedToken\": \"%s\"" + + "}", + challenge, ksefNip.get(), getEncodedToken()); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(uri)) + .header("Accept", "application/json") + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(jsonPayload)) + .build(); + + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + logger.info("│ ├── Response Code = " + response.statusCode()); + if (debug) { + logger.info("Response: " + response.body()); + } + + // Prüfen, dass wir eine API-Antwort bekommen + if (response.statusCode() >= 500) { + throw new PluginException(KSeFAuthManager.class.getSimpleName(), ERROR_CONFIG, + "Unable to access KSeF API: response=" + response.statusCode()); + } + + // JSON parsen und Werte speichern + if (response.statusCode() == 200 || response.statusCode() == 202) { + try (Jsonb jsonb = JsonbBuilder.create()) { + JsonObject jsonObject = jsonb.fromJson(response.body(), JsonObject.class); + + // Reference Number speichern + refNumber = jsonObject.getString("referenceNumber"); + logger.info("Extracted referenceNumber: " + refNumber); + + // Authentication Token speichern + JsonObject authTokenObject = jsonObject.getJsonObject("authenticationToken"); + authToken = authTokenObject.getString("token"); + logger.info("Extracted authenticationToken: " + authToken); + + } catch (Exception e) { + logger.severe("├── ⚠️ Error parsing JSON response" + e.getMessage()); + throw new PluginException(KSeFAuthManager.class.getSimpleName(), ERROR_API, + "Error parsing JSON response: " + e.getMessage()); + } + } + } + + /** + * Helper Method to read the certificates + * + * @throws Exception + */ + @Lock(LockType.WRITE) + public void getKsefPublicKeys() throws Exception { + + logger.info("├── KSeF API public-key-certificates..."); + String uri = baseURI + "/security/public-key-certificates"; + logger.info("│ ├── Endpoint: " + uri); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(uri)) + .header("Accept", "application/json") + .header("Authorization", "Bearer " + authToken) + .GET() + .build(); + + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + + logger.info("│ ├── Response Code = " + response.statusCode()); + if (debug) { + logger.info("Response: " + response.body()); + } + + if (response.statusCode() != 200) { + throw new PluginException(KSeFAuthManager.class.getSimpleName(), ERROR_API, + "Unexpected response from KSeF certificates endpoint: " + response.statusCode()); + } + + // JSON parsen + try (Jsonb jsonb = JsonbBuilder.create()) { + + var certArray = jsonb.fromJson(response.body(), JsonObject[].class); + for (JsonObject entry : certArray) { + // usage = ["KsefTokenEncryption"] oder ["SymmetricKeyEncryption"] + var usages = entry.getJsonArray("usage"); + if (usages.stream().anyMatch(v -> v.toString().contains("SymmetricKeyEncryption"))) { + symmetricCertBase64 = entry.getString("certificate"); + logger.info("│ ├── SymmetricKeyEncryption certificate found."); + // Public Key extrahieren + symmetricPublicKey = extractPublicKeyFromCertificate(symmetricCertBase64); + logger.info("│ ├── ✓ RSA PublicKey successfully extracted."); + return; // gefunden – wir sind fertig + } + } + logger.warning("│ ├── ⚠️ No certificate with usage 'SymmetricKeyEncryption' found!"); + + } catch (Exception e) { + throw new PluginException(KSeFAuthManager.class.getSimpleName(), ERROR_API, + "Error parsing certificates JSON: " + e.getMessage()); + } + } + + private PublicKey extractPublicKeyFromCertificate(String base64Cert) throws Exception { + + byte[] der = java.util.Base64.getDecoder().decode(base64Cert); + + java.security.cert.CertificateFactory factory = java.security.cert.CertificateFactory.getInstance("X.509"); + + var certificate = (java.security.cert.X509Certificate) factory + .generateCertificate(new java.io.ByteArrayInputStream(der)); + + return certificate.getPublicKey(); + } + +} diff --git a/office-alexander-logistics-app/src/test/java/com/alexanderlogistics/ksef/KSeFAuthManagerTest.java b/office-alexander-logistics-app/src/test/java/com/alexanderlogistics/ksef/KSeFAuthManagerTest.java new file mode 100644 index 0000000..54205e7 --- /dev/null +++ b/office-alexander-logistics-app/src/test/java/com/alexanderlogistics/ksef/KSeFAuthManagerTest.java @@ -0,0 +1,95 @@ +package com.alexanderlogistics.ksef; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class KSeFAuthManagerTest { + + private KSeFAuthManager manager; + + @BeforeEach + void setup() throws Exception { + manager = new KSeFAuthManager(); + + // Test config + manager.setKsefToken(Params.TOKEN); + manager.setKsefNip(Params.NIP); + manager.setKsefEndpoint("https://ksef-test.mf.gov.pl/api/v2"); + manager.setDebug(true); + + // init() ausführen + manager.init(); + } + + // ------------------------------------------------------------ + // 1) Challenge abholen + // ------------------------------------------------------------ + @Test + @Order(1) + @DisplayName("GET /auth/challenge should return a challenge") + void testChallenge() throws Exception { + + manager.getChallenge(); + + assertNotNull( + manager.getChallengeValue(), + "Challenge darf nicht NULL sein"); + + assertFalse( + manager.getChallengeValue().isBlank(), + "Challenge darf nicht leer sein"); + + System.out.println("Challenge = " + manager.getChallengeValue()); + } + + // ------------------------------------------------------------ + // 2) Zertifikate holen (nur Public Keys) + // ------------------------------------------------------------ + @Test + @Order(2) + @DisplayName("GET /security/public-key-certificates should return symmetric key certificate") + void testPublicKeys() throws Exception { + + manager.getKsefPublicKeys(); + + assertNotNull(manager.getSymmetricPublicKey(), "Symmetric RSA PublicKey muss vorhanden sein"); + assertNotNull(manager.getSymmetricCertBase64(), "Base64 Zertifikat muss vorhanden sein"); + + System.out.println("Symmetric PublicKey OK"); + } + + // ------------------------------------------------------------ + // 3) KSeF Token Request + // ------------------------------------------------------------ + @Test + @Order(3) + @DisplayName("POST /auth/ksef-token should return a token") + void testKsefToken() throws Exception { + + // Challenge muss vorher geholt worden sein + manager.getChallenge(); + + // Token verschlüsseln + manager.generateAuthToken(); + + // Token-Call durchführen + String authToken = manager.getAuthToken(); + assertNotNull(authToken, "auth token darf nicht NULL sein"); + + String refNumber = manager.getRefNumber(); + assertNotNull(refNumber, "refNumber darf nicht NULL sein"); + + System.out.println("Received KSeF Token OK"); + } +} diff --git a/office-alexander-logistics-app/src/test/java/com/alexanderlogistics/ksef/TestKSeF.java b/office-alexander-logistics-app/src/test/java/com/alexanderlogistics/ksef/TestKSeF.java index 5465fee..31ee2e1 100644 --- a/office-alexander-logistics-app/src/test/java/com/alexanderlogistics/ksef/TestKSeF.java +++ b/office-alexander-logistics-app/src/test/java/com/alexanderlogistics/ksef/TestKSeF.java @@ -9,19 +9,8 @@ import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.security.MessageDigest; -import java.security.SecureRandom; -import java.util.Base64; import java.util.logging.Logger; -import javax.crypto.Cipher; -import javax.crypto.KeyGenerator; -import javax.crypto.SecretKey; -import javax.crypto.spec.IvParameterSpec; - import org.imixs.workflow.exceptions.ModelException; import org.imixs.workflow.exceptions.PluginException; import org.junit.jupiter.api.BeforeAll; @@ -173,68 +162,4 @@ public class TestKSeF { assertTrue(response.body().contains("certificate"), "Response sollte ein Feld 'certificate' enthalten"); } - @Test - @Order(4) - @DisplayName("Rechnung übermitteln an KSeF 2.0 API") - public void testSendInvoice() throws Exception { - assertNotNull(refNumber, "ReferenceNumber nicht gesetzt"); - - // 1. Rechnung laden - Path invoicePath = Paths.get("test/resources/test-invoice.xml"); - byte[] invoiceBytes = Files.readAllBytes(invoicePath); - int invoiceSize = invoiceBytes.length; - - // 2. SHA-256 Hash der Originalrechnung - MessageDigest sha256 = MessageDigest.getInstance("SHA-256"); - byte[] invoiceHashBytes = sha256.digest(invoiceBytes); - String invoiceHash = Base64.getEncoder().encodeToString(invoiceHashBytes); - - // 3. AES-256 Schlüssel + IV generieren - KeyGenerator keyGen = KeyGenerator.getInstance("AES"); - keyGen.init(256); - SecretKey aesKey = keyGen.generateKey(); - byte[] ivBytes = new byte[16]; - new SecureRandom().nextBytes(ivBytes); - IvParameterSpec iv = new IvParameterSpec(ivBytes); - - // 4. Rechnung verschlüsseln (AES-256-CBC + PKCS7Padding) - Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); - cipher.init(Cipher.ENCRYPT_MODE, aesKey, iv); - byte[] encryptedInvoiceBytes = cipher.doFinal(invoiceBytes); - - int encryptedInvoiceSize = encryptedInvoiceBytes.length; - byte[] encryptedInvoiceHashBytes = sha256.digest(encryptedInvoiceBytes); - String encryptedInvoiceHash = Base64.getEncoder().encodeToString(encryptedInvoiceHashBytes); - - // 5. Verschlüsselten Inhalt Base64 codieren - String encryptedInvoiceContent = Base64.getEncoder().encodeToString(encryptedInvoiceBytes); - - // 6. JSON-Payload erstellen - String jsonPayload = "{" - + "\"invoiceHash\":\"" + invoiceHash + "\"," - + "\"invoiceSize\":" + invoiceSize + "," - + "\"encryptedInvoiceHash\":\"" + encryptedInvoiceHash + "\"," - + "\"encryptedInvoiceSize\":" + encryptedInvoiceSize + "," - + "\"encryptedInvoiceContent\":\"" + encryptedInvoiceContent + "\"," - + "\"offlineMode\":false" - + "}"; - - // 7. HTTP POST Request an die API - HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create(KSEF_TEST_URL + "/sessions/online/" + refNumber + "/invoices")) - .header("Content-Type", "application/json") - .header("Accept", "application/json") - .header("Authorization", "Bearer " + authToken) // Token aus Authentifizierung - .POST(HttpRequest.BodyPublishers.ofString(jsonPayload)) - .build(); - - HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); - - System.out.println("Status: " + response.statusCode()); - System.out.println("Response: " + response.body()); - - assertEquals(200, response.statusCode(), "Invoice Upload sollte Status 200 zurückgeben"); - assertTrue(response.body().contains("invoiceId"), "Antwort sollte field invoiceId enthalten"); - } - }