verbesserte auth methodik

This commit is contained in:
Ralph Soika 2025-11-14 15:24:12 +01:00
parent 9f6d86d328
commit 5ead936783
2 changed files with 153 additions and 324 deletions

View file

@ -7,13 +7,17 @@ import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.security.PublicKey;
import java.security.SecureRandom;
import java.security.spec.MGF1ParameterSpec;
import java.time.Duration;
import java.time.Instant;
import java.time.format.DateTimeFormatter;
import java.util.Base64;
import java.util.Optional;
import java.util.logging.Logger;
import javax.crypto.Cipher;
import javax.crypto.spec.OAEPParameterSpec;
import javax.crypto.spec.PSource;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.imixs.workflow.exceptions.InvalidAccessException;
@ -70,19 +74,11 @@ public class KSeFAuthManager {
private String challenge = "";
private String refNumber = null;
private String authToken = null;
private String accessToken = null;
private String refreshToken = null;
private String symmetricCertBase64; // der Zertifikat-String aus der API
private PublicKey symmetricPublicKey; // der extrahierte RSA Public Key
private String sessionRefNumber = null;
private String sessionValidUntil = null;
private String ksefTokenEncryptionCertBase64 = null;
private PublicKey ksefTokenEncryptionPublicKey;
private String challengeTimestamp = null;
private byte[] sessionAesKey;
private byte[] sessionIv;
private String symmetricCertBase64;
private String ksefTokenCertBase64;
private PublicKey symmetricPublicKey;
private PublicKey ksefTokenPublicKey;
@PostConstruct
void init() {
@ -135,13 +131,12 @@ public class KSeFAuthManager {
*
* @return
*/
// public String getEncodedToken() {
public String getEncodedToken() {
// String result =
// java.util.Base64.getEncoder().encodeToString(ksefToken.orElse("").getBytes());
String result = java.util.Base64.getEncoder().encodeToString(ksefToken.orElse("").getBytes());
// return result;
// }
return result;
}
public String getBaseURI() {
return baseURI;
@ -155,14 +150,6 @@ public class KSeFAuthManager {
return authToken;
}
public PublicKey getSymmetricPublicKey() {
return symmetricPublicKey;
}
public String getSymmetricCertBase64() {
return symmetricCertBase64;
}
public String getChallengeValue() {
return challenge;
}
@ -174,7 +161,7 @@ public class KSeFAuthManager {
*/
// @Lock(LockType.READ)
@Lock(LockType.WRITE)
public void getChallenge() throws PluginException {
public void authChallenge() throws PluginException {
logger.info("├── KSeF API Challenge...");
String uri = baseURI + "/auth/challenge";
logger.info("│ ├── Endpoint: " + uri);
@ -230,30 +217,10 @@ public class KSeFAuthManager {
* @throws Exception
*/
@Lock(LockType.WRITE)
public void generateAuthToken() throws Exception {
public void authKSeFToken() throws Exception {
logger.info("├── KSeF API auth ksef-token...");
String uri = baseURI + "/auth/ksef-token";
logger.info("│ ├── Endpoint: " + uri);
if (ksefTokenEncryptionPublicKey == null) {
throw new PluginException(KSeFAuthManager.class.getSimpleName(), ERROR_CONFIG,
"Missing public key for KSeF token encryption (KsefTokenEncryption).");
}
// token|timestamp (aus challenge)
String token = ksefToken.orElseThrow(() -> new PluginException(
KSeFAuthManager.class.getSimpleName(), ERROR_CONFIG, "Missing KSeF API token"));
String tokenWithTimestamp = token + "|" + challengeTimestamp;
logger.info("│ ├── token|timestamp = " + tokenWithTimestamp);
// Verschlüsseln mit RSA-OAEP SHA-256
String encryptedToken = encryptWithPublicKey(tokenWithTimestamp, ksefTokenEncryptionPublicKey);
logger.info("│ ├── Encrypted token (Base64): " + encryptedToken);
// JSON payload zusammenbauen
String jsonPayload = String.format(
"{" +
"\"challenge\": \"%s\"," +
@ -263,9 +230,7 @@ public class KSeFAuthManager {
"}," +
"\"encryptedToken\": \"%s\"" +
"}",
challenge,
ksefNip.get(),
encryptedToken);
challenge, ksefNip.get(), getEncryptedToken());
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(uri))
@ -275,84 +240,146 @@ public class KSeFAuthManager {
.build();
HttpResponse<String> 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());
}
}
}
/**
* Verschlüsselt den KSeF-Token mit RSA-OAEP (SHA-256) für die Authentifizierung
*
* Format: token|timestamp_in_milliseconds
*
* @return Base64-kodierter verschlüsselter Token
* @throws Exception
*/
public String getEncryptedToken() throws Exception {
if (ksefTokenPublicKey == null) {
throw new IllegalStateException("Public Key not loaded! Call loadPublicKeyCertificates() first.");
}
if (challengeTimestamp == null || challengeTimestamp.isEmpty()) {
throw new IllegalStateException("Challenge timestamp is missing! Call authChallenge() first.");
}
// 1. Timestamp parsen - KSeF liefert zu viele Dezimalstellen
// Format: "2025-11-14T14:18:35.606507+00:00"
// Flexibler Parser der verschiedene Dezimalstellen akzeptiert
DateTimeFormatter formatter = DateTimeFormatter.ISO_OFFSET_DATE_TIME;
Instant instant = Instant.from(formatter.parse(challengeTimestamp));
long timestampMillis = instant.toEpochMilli();
logger.info("│ ├── Timestamp (ms): " + timestampMillis);
// 2. Format erstellen: token|timestamp
String plaintext = ksefToken.get() + "|" + timestampMillis;
if (debug) {
logger.info("│ ├── Plaintext format: [token]|[" + timestampMillis + "]");
}
// 3. RSA-OAEP Verschlüsselung mit SHA-256
Cipher cipher = Cipher.getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding");
OAEPParameterSpec oaepParams = new OAEPParameterSpec(
"SHA-256",
"MGF1",
MGF1ParameterSpec.SHA256,
PSource.PSpecified.DEFAULT);
cipher.init(Cipher.ENCRYPT_MODE, ksefTokenPublicKey, oaepParams);
byte[] encrypted = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8));
// 4. Base64 kodieren
String result = Base64.getEncoder().encodeToString(encrypted);
logger.info("│ ├── ✓ Token encrypted with RSA-OAEP");
return result;
}
/**
* Redeems the KSeF token and returns a session token
*
* POST /auth/token/redeem
*
* @return JsonObject with referenceNumber and sessionToken
* @throws Exception
*/
@Lock(LockType.WRITE)
public void redeemAuthToken() throws Exception {
logger.info("├── KSeF API redeem authentication token...");
public JsonObject redeemToken() throws Exception {
logger.info("├── KSeF API redeem token...");
String uri = baseURI + "/auth/token/redeem";
logger.info("│ ├── Endpoint: " + uri);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(uri))
.header("Accept", "application/json")
.header("Content-Type", "application/json")
.header("Authorization", "Bearer " + authToken) // authToken aus generateAuthToken()
.POST(HttpRequest.BodyPublishers.noBody()) // Payload leer, API erwartet nur Bearer im Header
.header("Authorization", "Bearer " + authToken)
.POST(HttpRequest.BodyPublishers.noBody())
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
HttpResponse<String> response = httpClient.send(request,
HttpResponse.BodyHandlers.ofString());
logger.info("│ ├── Response Code = " + response.statusCode());
if (debug) {
logger.info("Response: " + response.body());
logger.info("│ └── Response: " + response.body());
}
// Prüfen auf Fehler
if (response.statusCode() >= 500) {
throw new PluginException(KSeFAuthManager.class.getSimpleName(), ERROR_CONFIG,
"Unable to access KSeF API: response=" + response.statusCode());
if (response.statusCode() >= 400) {
throw new PluginException(KSeFAuthManager.class.getSimpleName(),
ERROR_API,
"Token redeem failed: " + response.statusCode()
+ " - " + response.body());
}
// JSON parsen und AccessToken + RefreshToken speichern
if (response.statusCode() == 200 || response.statusCode() == 201) {
try (Jsonb jsonb = JsonbBuilder.create()) {
JsonObject jsonObject = jsonb.fromJson(response.body(), JsonObject.class);
try (Jsonb jsonb = JsonbBuilder.create()) {
JsonObject jsonObject = jsonb.fromJson(response.body(), JsonObject.class);
JsonObject accessTokenObj = jsonObject.getJsonObject("accessToken");
accessToken = accessTokenObj.getString("token"); // finaler Bearer für alle weiteren API-Calls
logger.info("Extracted accessToken: " + accessToken);
// Access Token extrahieren und speichern
JsonObject accessTokenObj = jsonObject.getJsonObject("accessToken");
this.authToken = accessTokenObj.getString("token");
JsonObject refreshTokenObj = jsonObject.getJsonObject("refreshToken");
refreshToken = refreshTokenObj.getString("token"); // optional zum späteren Refresh
if (debug) {
logger.info("Extracted refreshToken: " + refreshToken);
}
logger.info("│ └── Access Token received");
} 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());
}
} else {
throw new PluginException(KSeFAuthManager.class.getSimpleName(), ERROR_API,
"Failed to redeem authentication token: " + response.statusCode());
return jsonObject;
} catch (Exception e) {
logger.severe("├── ⚠️ Error parsing JSON response: " + e.getMessage());
throw new PluginException(KSeFAuthManager.class.getSimpleName(),
ERROR_API,
"Error parsing response: " + e.getMessage());
}
}
@ -362,7 +389,7 @@ public class KSeFAuthManager {
* @throws Exception
*/
@Lock(LockType.WRITE)
public void loadKsefPublicKeys() throws Exception {
public void loadPublicKeyCertificates() throws Exception {
logger.info("├── KSeF API load public-key-certificates...");
String uri = baseURI + "/security/public-key-certificates";
@ -383,81 +410,53 @@ public class KSeFAuthManager {
if (response.statusCode() != 200) {
throw new PluginException(KSeFAuthManager.class.getSimpleName(), ERROR_API,
"Unexpected response: " + response.statusCode());
"Unexpected response from KSeF certificates endpoint: " + response.statusCode());
}
// JSON parsen
try (Jsonb jsonb = JsonbBuilder.create()) {
// WICHTIG: JsonArray lesen NICHT JsonObject[]
jakarta.json.JsonArray certArray = jsonb.fromJson(response.body(), jakarta.json.JsonArray.class);
for (var entryValue : certArray) {
JsonObject entry = entryValue.asJsonObject();
var certArray = jsonb.fromJson(response.body(), JsonObject[].class);
for (JsonObject entry : certArray) {
// usage = ["KsefTokenEncryption"] oder ["SymmetricKeyEncryption"]
var usages = entry.getJsonArray("usage");
if (usages != null &&
usages.stream().anyMatch(v -> v.toString().contains("KsefTokenEncryption"))) {
if (usages.stream().anyMatch(v -> v.toString().contains("KsefTokenEncryption"))) {
ksefTokenCertBase64 = entry.getString("certificate");
logger.info("│ ├── KsefTokenEncryption certificate found.");
// Public Key extrahieren
ksefTokenPublicKey = extractPublicKeyFromCertificate(ksefTokenCertBase64);
logger.info("│ ├── ✓ RSA PublicKey successfully extracted.");
ksefTokenEncryptionCertBase64 = entry.getString("certificate");
logger.info("│ ├── Found certificate with usage = KsefTokenEncryption");
}
ksefTokenEncryptionPublicKey = extractPublicKeyFromCertificate(ksefTokenEncryptionCertBase64);
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.");
logger.info("│ ├── ✓ RSA PublicKey for KSeF Token Encryption extracted.");
return;
}
}
throw new PluginException(KSeFAuthManager.class.getSimpleName(), ERROR_API,
"No certificate with usage = KsefTokenEncryption found");
if (ksefTokenPublicKey == null) {
logger.warning("│ ├── ⚠️ No certificate with usage 'ksefTokenPublicKey' found!");
}
if (symmetricPublicKey == null) {
logger.warning("│ ├── ⚠️ No certificate with usage 'symmetricPublicKey' found!");
}
} catch (Exception e) {
throw new PluginException(KSeFAuthManager.class.getSimpleName(), ERROR_API,
"Error parsing certificates JSON: " + e.getMessage());
}
}
@Lock(LockType.WRITE)
public void loadSymmetricKeyPublicKey() throws Exception {
logger.info("├── KSeF API load symmetric public key...");
String uri = baseURI + "/security/public-key";
logger.info("│ ├── Endpoint: " + uri);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(uri))
.header("Accept", "application/json")
.GET()
.build();
HttpResponse<String> 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: " + response.statusCode());
}
try (Jsonb jsonb = JsonbBuilder.create()) {
JsonObject json = jsonb.fromJson(response.body(), JsonObject.class);
symmetricCertBase64 = json.getString("certificate");
logger.info("│ ├── Symmetric key certificate loaded");
symmetricPublicKey = extractPublicKeyFromCertificate(symmetricCertBase64);
logger.info("│ ├── ✓ RSA PublicKey for session encryption extracted.");
} catch (Exception e) {
throw new PluginException(KSeFAuthManager.class.getSimpleName(), ERROR_API,
"Error parsing public key 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();
}
/**
@ -468,90 +467,11 @@ public class KSeFAuthManager {
@Lock(LockType.WRITE)
public void openInteractiveSession() throws Exception {
if (accessToken == null) {
throw new PluginException(KSeFAuthManager.class.getSimpleName(), ERROR_API,
"Error - missing access token!");
}
logger.info("├── KSeF API open interactive session...");
String uri = baseURI + "/sessions/online";
logger.info("│ ├── Endpoint: " + uri);
// --- AES Schlüssel erzeugen ---
SecureRandom rnd = new SecureRandom();
byte[] aesKeyBytes = new byte[32]; // 256-bit AES key
rnd.nextBytes(aesKeyBytes);
byte[] ivBytes = new byte[16]; // 128-bit IV
rnd.nextBytes(ivBytes);
// --- AES Key mit RSA encrypten ---
// Cipher rsa = Cipher.getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding");
Cipher rsa = Cipher.getInstance("RSA/ECB/OAEPWithSHA-1AndMGF1Padding");
rsa.init(Cipher.ENCRYPT_MODE, this.symmetricPublicKey);
byte[] encryptedAesKey = rsa.doFinal(aesKeyBytes);
String jsonPayload = String.format(
"{" +
"\"formCode\": {" +
" \"systemCode\": \"FA (3)\"," +
" \"schemaVersion\": \"1-0E\"," +
" \"value\": \"FA\"" +
"}," +
"\"encryption\": {" +
" \"encryptedSymmetricKey\": \"%s\"," +
" \"initializationVector\": \"%s\"" +
"}" +
"}",
Base64.getEncoder().encodeToString(encryptedAesKey),
Base64.getEncoder().encodeToString(ivBytes));
if (debug) {
logger.info("│ ├── Payload: " + jsonPayload);
}
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(uri))
// .header("Accept-Charset", "UTF-8")
.header("Accept", "application/json")
.header("Content-Type", "application/json")
.header("Authorization", "Bearer " + accessToken)
.POST(HttpRequest.BodyPublishers.ofString(jsonPayload))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
logger.info("│ ├── Response Code = " + response.statusCode());
if (debug) {
logger.info("Response: " + response.body());
}
if (response.statusCode() != 201) {
throw new PluginException(KSeFAuthManager.class.getSimpleName(), ERROR_API,
"Failed to open interactive session: " + response.statusCode());
}
// JSON parsen
// Session RefNumber aus Response extrahieren und speichern
try (Jsonb jsonb = JsonbBuilder.create()) {
// Annahme: response.body() ist ein String mit JSON-Inhalt
JsonObject jsonObject = jsonb.fromJson(response.body(), JsonObject.class);
sessionRefNumber = jsonObject.getString("referenceNumber");
sessionValidUntil = jsonObject.getString("validUntil");
if (debug) {
logger.info("│ ├── Session ReferenceNumber = " + sessionRefNumber);
logger.info("│ └── Session ValidUntil = " + sessionValidUntil);
}
// --- WICHTIG: AES Schlüssel speichern, wir brauchen ihn beim Upload ---
this.sessionAesKey = aesKeyBytes;
this.sessionIv = ivBytes;
} 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());
}
// ............
}
@ -568,9 +488,6 @@ public class KSeFAuthManager {
logger.info("├── KSeF API check authentication status...");
logger.info("Using public key algorithm: " + symmetricPublicKey.getAlgorithm());
logger.info("Key format: " + symmetricPublicKey.getFormat());
String uri = baseURI + "/auth/" + refNumber;
logger.info("│ ├── Endpoint: " + uri);
@ -605,24 +522,4 @@ public class KSeFAuthManager {
}
}
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();
}
private String encryptWithPublicKey(String data, PublicKey publicKey) throws Exception {
Cipher cipher = Cipher.getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding");
cipher.init(Cipher.ENCRYPT_MODE, publicKey);
byte[] encryptedBytes = cipher.doFinal(data.getBytes(StandardCharsets.UTF_8));
return Base64.getEncoder().encodeToString(encryptedBytes);
}
}

View file

@ -1,6 +1,5 @@
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;
@ -37,88 +36,21 @@ public class KSeFAuthManagerTest {
// ------------------------------------------------------------
@Test
@Order(1)
@DisplayName("GET /auth/challenge should return a challenge")
void testChallenge() throws Exception {
@DisplayName("POST /api/v2/auth/token/redeem")
void testRedeemToken() throws Exception {
manager.getChallenge();
manager.loadPublicKeyCertificates();
manager.authChallenge();
manager.authKSeFToken();
manager.redeemToken();
assertNotNull(
manager.getChallengeValue(),
"Challenge darf nicht NULL sein");
manager.getRefNumber(),
"RefNumber 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");
}
@Test
@Order(4)
@DisplayName("Upload Invoice XML (AES-256 + RSA)")
public void testOpenInteractiveSession() throws Exception {
// ---------- 1) Vorbereitung ----------
// API initialisieren
manager.loadKsefPublicKeys();
manager.getChallenge();
manager.generateAuthToken(); // erzeugt authToken + refNumber
manager.getAuthStatus();
manager.redeemAuthToken();
assertNotNull(manager.getAuthToken(), "AccessToken fehlt");
assertNotNull(manager.getRefNumber(), "ReferenceNumber fehlt");
manager.openInteractiveSession();
}
@Test
@Order(5)
@DisplayName("Upload Invoice XML (AES-256 + RSA)")
public void testUploadInvoice() throws Exception {
// ---------- 1) Vorbereitung ----------
// API initialisieren
assertNotNull(
manager.getAuthToken(),
"AuthToken darf nicht leer sein");
}