commit 4df08a68e0e47a06c6517934ab2bcb43e8154058 Author: Radek Davidek Date: Tue Jun 30 17:38:52 2026 +0200 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d769462 --- /dev/null +++ b/.gitignore @@ -0,0 +1,35 @@ +target/ +!.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr + +### Eclipse ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ + +### Mac OS ### +.DS_Store \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..4777761 --- /dev/null +++ b/README.md @@ -0,0 +1,42 @@ +# adfs-auth-ms + +Jednoduchy REST wrapper nad puvodni logikou `AdfsAuthMediator`. + +## Co dela + +- nacte konfiguraci z YAML +- vytvori `client_assertion` stejne jako puvodni Java callout +- zavola ADFS token endpoint +- drzi access token v pametove cache, dokud nevyprsi +- vystavi endpoint `GET /token` a `GET /health` + +## Konfigurace + +Konfigurace je v `config/config.yaml` nebo ve classpath `config.yaml`. + +Klice v sekci `adfs` zachovavaji stejne nazvy jako puvodni policy: + +- `tokenUrl` +- `audience` +- `resource` +- `clientId` +- `certificate` +- `privateKey` +- `proxyHost` +- `proxyPort` +- `proxyUser` +- `proxyPassword` + +## Spusteni + +```bash +mvn package +java -jar target/adfs-auth-ms-1.0-SNAPSHOT.jar +``` + +## API + +```bash +curl http://localhost:8080/health +curl http://localhost:8080/token +``` diff --git a/adfs.crt b/adfs.crt new file mode 100644 index 0000000..2e1c398 --- /dev/null +++ b/adfs.crt @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDLTCCAhWgAwIBAgIQMa6VTqqCHbVHuWPdFQ4JtzANBgkqhkiG9w0BAQsFADAb +MRkwFwYDVQQDDBBmcy5rb21lcmNwb2oubG9jMB4XDTI2MDYzMDEzNDcwMVoXDTI3 +MDYzMDE0MDcwMVowGzEZMBcGA1UEAwwQZnMua29tZXJjcG9qLmxvYzCCASIwDQYJ +KoZIhvcNAQEBBQADggEPADCCAQoCggEBANVbpYlq8O2QLxWcZHjBnBCoj69Aq9fH +NS7psQuFeYvc2legnbgcz0sIE/RSUzRw9t09RaHApig0TOkhYtezuETDEQdGqoay +W8sRaCKMFms5S3dL4MFBhVyerAiIXLOh5qfreshXh+XdRfHv0AYOx83JrXy4z9xh +AALQ23udKdRPbd/acYN/TAgiioclQsTZo8Qn7DzZvwjItzX5pvWqkSDeBJCNIDJ9 ++d7w4SJuSrm4+dFbiHd7NT/qjfQikAw0kLiDBHXqBdWpBBzbssZW3iPxep5u9kQn +mnOEeGbI1egkCXBzm1Fxr8MBMg4j2NL/zG80FzHoyWBsfG2XGNQOe8ECAwEAAaNt +MGswDgYDVR0PAQH/BAQDAgWgMB0GA1UdJQQWMBQGCCsGAQUFBwMCBggrBgEFBQcD +ATAbBgNVHREEFDASghBmcy5rb21lcmNwb2oubG9jMB0GA1UdDgQWBBR1NFW+QJLH +kFdSHbmoLOX2PoeAOTANBgkqhkiG9w0BAQsFAAOCAQEAUf487OGTFkNZCcdBdNsQ +YsNqxFz69y01oELQ8hX38So46MnnDewUiWu7taPOCdsdPNMOfsUzFEYMvEGJJd5i +GaUHNAqP4I0RXEeSLH3H5T2OYkBvduDxbUBCCtIbX0BbveNiNcti/vklgimJN1JK +LRuEIMZ0iucuDAzCkgD7ZoLPk9qGP2Jc0AKmuFLRRmxNFTePTWBOOBTThUo/RieM +k1yDnPMjzWgTLSZbtJgfjFJ9XuTmIwNukdDFFDzNV0OfyFcOnV9x703rTT5S/oWb +ceFCFFzA6tXF4ezdGgMfqyjuXOR24jy0xQ8rdrP1zWeH+Une2yzuG0ILvq/RtXDW +zQ== +-----END CERTIFICATE----- diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..2e9de66 --- /dev/null +++ b/pom.xml @@ -0,0 +1,83 @@ + + 4.0.0 + + cz.trask + adfs-auth-ms + 1.0-SNAPSHOT + jar + + + UTF-8 + 11 + 11 + 2.18.2 + 2.24.3 + + + + + com.fasterxml.jackson.core + jackson-databind + ${jackson.version} + + + com.fasterxml.jackson.dataformat + jackson-dataformat-yaml + ${jackson.version} + + + org.apache.logging.log4j + log4j-api + ${log4j.version} + + + org.apache.logging.log4j + log4j-core + ${log4j.version} + + + + + + + maven-compiler-plugin + 3.13.0 + + ${maven.compiler.target} + + + + maven-assembly-plugin + 3.7.1 + + + + cz.trask.adfsauthms.AdfsAuthMsServer + + + + jar-with-dependencies + + false + + + + make-assembly + package + + single + + + + + + + + src/main/resources + false + + + + diff --git a/src/main/java/cz/trask/adfsauthms/AdfsAuthMsServer.java b/src/main/java/cz/trask/adfsauthms/AdfsAuthMsServer.java new file mode 100644 index 0000000..317151d --- /dev/null +++ b/src/main/java/cz/trask/adfsauthms/AdfsAuthMsServer.java @@ -0,0 +1,117 @@ +package cz.trask.adfsauthms; + +import java.io.FileInputStream; +import java.net.InetSocketAddress; +import java.security.KeyStore; +import java.util.List; +import java.util.concurrent.Executors; + +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManagerFactory; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sun.net.httpserver.HttpServer; +import com.sun.net.httpserver.HttpsConfigurator; +import com.sun.net.httpserver.HttpsServer; + +import cz.trask.adfsauthms.config.AppConfig; +import cz.trask.adfsauthms.config.AppConfig.ContextConfig; +import cz.trask.adfsauthms.config.ConfigurationManager; +import cz.trask.adfsauthms.context.BaseContextHandler; +import cz.trask.adfsauthms.service.AdfsTokenService; + +public class AdfsAuthMsServer { + + private static final Logger logger = LogManager.getLogger(AdfsAuthMsServer.class); + + private final HttpServer server; + + public AdfsAuthMsServer() throws Exception { + logger.debug("Initializing AdfsAuthMsServer"); + ConfigurationManager configurationManager = ConfigurationManager.load(); + AppConfig config = configurationManager.getConfig(); + ObjectMapper objectMapper = new ObjectMapper(); + AdfsTokenService tokenService = new AdfsTokenService(config.getAdfs(), objectMapper); + + this.server = createServer(config); + List contexts = config.getServer().getContexts().getContext(); + for (ContextConfig contextConfig : contexts) { + logger.debug("Creating context: {} -> {}", contextConfig.getPath(), contextConfig.getClassName()); + BaseContextHandler handler = instantiateHandler(contextConfig.getClassName()); + handler.init(config, tokenService, objectMapper); + server.createContext(contextConfig.getPath(), handler); + } + + server.setExecutor(Executors.newFixedThreadPool(config.getServer().getThreads())); + } + + public static void main(String[] args) throws Exception { + try { + AdfsAuthMsServer server = new AdfsAuthMsServer(); + server.start(); + } catch (Exception e) { + logger.error("Failed to start ADFS auth microservice", e); + System.exit(1); + } + } + + public void start() { + server.start(); + logger.info("ADFS auth microservice started on {}", server.getAddress()); + } + + private HttpServer createServer(AppConfig config) throws Exception { + String type = config.getServer().getType(); + int port = config.getServer().getPort(); + if ("https".equalsIgnoreCase(type)) { + HttpsServer httpsServer = HttpsServer.create(new InetSocketAddress(port), 0); + httpsServer.setHttpsConfigurator(new HttpsConfigurator(buildSslContext(config))); + return httpsServer; + } + return HttpServer.create(new InetSocketAddress(port), 0); + } + + private SSLContext buildSslContext(AppConfig config) throws Exception { + AppConfig.TlsConfig tlsConfig = config.getServer().getTls(); + if (tlsConfig == null || tlsConfig.getPath() == null || tlsConfig.getPassphrase() == null) { + throw new IllegalArgumentException("TLS configuration is required for https server.type"); + } + + KeyStore keyStore = KeyStore.getInstance("JKS"); + try (FileInputStream inputStream = new FileInputStream(tlsConfig.getPath())) { + keyStore.load(inputStream, tlsConfig.getPassphrase().toCharArray()); + } + + char[] privateKeyPassphrase = tlsConfig.getPrivatekey() != null + && tlsConfig.getPrivatekey().getPassphrase() != null + ? tlsConfig.getPrivatekey().getPassphrase().toCharArray() + : tlsConfig.getPassphrase().toCharArray(); + + KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance("SunX509"); + keyManagerFactory.init(keyStore, privateKeyPassphrase); + + TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance("SunX509"); + trustManagerFactory.init(keyStore); + + SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(keyManagerFactory.getKeyManagers(), trustManagerFactory.getTrustManagers(), null); + return sslContext; + } + + private BaseContextHandler instantiateHandler(String className) throws Exception { + if (className == null || className.isBlank()) { + throw new IllegalArgumentException("Context handler class is missing."); + } + + Class handlerClass = Class.forName(className); + Object instance = handlerClass.getDeclaredConstructor().newInstance(); + if (!(instance instanceof BaseContextHandler)) { + throw new IllegalArgumentException("Class " + className + " is not a BaseContextHandler"); + } + return (BaseContextHandler) instance; + } +} diff --git a/src/main/java/cz/trask/adfsauthms/config/AdfsConfig.java b/src/main/java/cz/trask/adfsauthms/config/AdfsConfig.java new file mode 100644 index 0000000..b2984aa --- /dev/null +++ b/src/main/java/cz/trask/adfsauthms/config/AdfsConfig.java @@ -0,0 +1,97 @@ +package cz.trask.adfsauthms.config; + +import java.util.List; + +public class AdfsConfig { + + private String tokenUrl; + private String audience; + private String resource; + private List clientIds; + private String certificate; + private String privateKey; + private String proxyHost; + private String proxyPort; + private String proxyUser; + private String proxyPassword; + + public String getTokenUrl() { + return tokenUrl; + } + + public void setTokenUrl(String tokenUrl) { + this.tokenUrl = tokenUrl; + } + + public String getAudience() { + return audience; + } + + public void setAudience(String audience) { + this.audience = audience; + } + + public String getResource() { + return resource; + } + + public void setResource(String resource) { + this.resource = resource; + } + + public List getClientIds() { + return clientIds; + } + + public void setClientIds(List clientIds) { + this.clientIds = clientIds; + } + + public String getCertificate() { + return certificate; + } + + public void setCertificate(String certificate) { + this.certificate = certificate; + } + + public String getPrivateKey() { + return privateKey; + } + + public void setPrivateKey(String privateKey) { + this.privateKey = privateKey; + } + + public String getProxyHost() { + return proxyHost; + } + + public void setProxyHost(String proxyHost) { + this.proxyHost = proxyHost; + } + + public String getProxyPort() { + return proxyPort; + } + + public void setProxyPort(String proxyPort) { + this.proxyPort = proxyPort; + } + + public String getProxyUser() { + return proxyUser; + } + + public void setProxyUser(String proxyUser) { + this.proxyUser = proxyUser; + } + + public String getProxyPassword() { + return proxyPassword; + } + + public void setProxyPassword(String proxyPassword) { + this.proxyPassword = proxyPassword; + } +} diff --git a/src/main/java/cz/trask/adfsauthms/config/AppConfig.java b/src/main/java/cz/trask/adfsauthms/config/AppConfig.java new file mode 100644 index 0000000..4f96620 --- /dev/null +++ b/src/main/java/cz/trask/adfsauthms/config/AppConfig.java @@ -0,0 +1,159 @@ +package cz.trask.adfsauthms.config; + +import java.util.ArrayList; +import java.util.List; + +public class AppConfig { + + private ServerConfig server = new ServerConfig(); + private AdfsConfig adfs = new AdfsConfig(); + private String backendUrl; + + public ServerConfig getServer() { + return server; + } + + public void setServer(ServerConfig server) { + this.server = server; + } + + public AdfsConfig getAdfs() { + return adfs; + } + + public void setAdfs(AdfsConfig adfs) { + this.adfs = adfs; + } + + public String getBackendUrl() { + return backendUrl; + } + + public void setBackendUrl(String backendUrl) { + this.backendUrl = backendUrl; + } + + public static class ServerConfig { + private String type = "http"; + private int port = 8080; + private int threads = 8; + private TlsConfig tls = new TlsConfig(); + private ContextsConfig contexts = new ContextsConfig(); + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public int getPort() { + return port; + } + + public void setPort(int port) { + this.port = port; + } + + public int getThreads() { + return threads; + } + + public void setThreads(int threads) { + this.threads = threads; + } + + public TlsConfig getTls() { + return tls; + } + + public void setTls(TlsConfig tls) { + this.tls = tls; + } + + public ContextsConfig getContexts() { + return contexts; + } + + public void setContexts(ContextsConfig contexts) { + this.contexts = contexts; + } + } + + public static class TlsConfig { + private String path; + private String passphrase; + private PrivateKeyConfig privatekey = new PrivateKeyConfig(); + + public String getPath() { + return path; + } + + public void setPath(String path) { + this.path = path; + } + + public String getPassphrase() { + return passphrase; + } + + public void setPassphrase(String passphrase) { + this.passphrase = passphrase; + } + + public PrivateKeyConfig getPrivatekey() { + return privatekey; + } + + public void setPrivatekey(PrivateKeyConfig privatekey) { + this.privatekey = privatekey; + } + } + + public static class PrivateKeyConfig { + private String passphrase; + + public String getPassphrase() { + return passphrase; + } + + public void setPassphrase(String passphrase) { + this.passphrase = passphrase; + } + } + + public static class ContextsConfig { + private List context = new ArrayList<>(); + + public List getContext() { + return context; + } + + public void setContext(List context) { + this.context = context; + } + } + + public static class ContextConfig { + private String path; + private String className; + + public String getPath() { + return path; + } + + public void setPath(String path) { + this.path = path; + } + + public String getClassName() { + return className; + } + + @com.fasterxml.jackson.annotation.JsonProperty("class") + public void setClassName(String className) { + this.className = className; + } + } +} diff --git a/src/main/java/cz/trask/adfsauthms/config/ConfigurationManager.java b/src/main/java/cz/trask/adfsauthms/config/ConfigurationManager.java new file mode 100644 index 0000000..cc76583 --- /dev/null +++ b/src/main/java/cz/trask/adfsauthms/config/ConfigurationManager.java @@ -0,0 +1,58 @@ +package cz.trask.adfsauthms.config; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import com.fasterxml.jackson.databind.ObjectMapper; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +public class ConfigurationManager { + + private static final Logger logger = LogManager.getLogger(ConfigurationManager.class); + + private static final String CONFIG_DIRECTORY = "config"; + private static final String CONFIG_FILE_NAME = "config.yaml"; + + private final ObjectMapper mapper; + private final AppConfig config; + + private ConfigurationManager() throws IOException { + this.mapper = new ObjectMapper(new YAMLFactory()); + this.mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + try (InputStream inputStream = openConfig()) { + this.config = mapper.readValue(inputStream, AppConfig.class); + } + } + + public static ConfigurationManager load() throws IOException { + return new ConfigurationManager(); + } + + public AppConfig getConfig() { + return config; + } + + private InputStream openConfig() throws IOException { + Path configPath = Paths.get(CONFIG_DIRECTORY, CONFIG_FILE_NAME); + if (Files.isReadable(configPath)) { + logger.info("Loading configuration from file: {}", configPath.toAbsolutePath()); + return Files.newInputStream(configPath); + } + + InputStream classpathStream = ConfigurationManager.class.getClassLoader().getResourceAsStream(CONFIG_FILE_NAME); + if (classpathStream != null) { + logger.info("Loading configuration from classpath: {}", CONFIG_FILE_NAME); + return classpathStream; + } + + logger.error("Configuration file '{}' not found in {} or classpath", CONFIG_FILE_NAME, CONFIG_DIRECTORY); + throw new IOException("Cannot find configuration file '" + CONFIG_DIRECTORY + "/" + CONFIG_FILE_NAME + "'."); + } +} diff --git a/src/main/java/cz/trask/adfsauthms/context/BaseContextHandler.java b/src/main/java/cz/trask/adfsauthms/context/BaseContextHandler.java new file mode 100644 index 0000000..09dbe17 --- /dev/null +++ b/src/main/java/cz/trask/adfsauthms/context/BaseContextHandler.java @@ -0,0 +1,117 @@ +package cz.trask.adfsauthms.context; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sun.net.httpserver.Headers; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import cz.trask.adfsauthms.config.AppConfig; +import cz.trask.adfsauthms.service.AdfsTokenService; + +public abstract class BaseContextHandler implements HttpHandler { + + protected final Logger logger = LogManager.getLogger(getClass()); + + protected static final String METHOD_GET = "GET"; + protected static final String METHOD_POST = "POST"; + protected static final String CONTENT_TYPE_JSON = "application/json;charset=UTF-8"; + + protected static final String HEADER_CONTENT_TYPE = "Content-Type"; + protected static final String HEADER_CACHE_CONTROL = "Cache-Control"; + protected static final String HEADER_PRAGMA = "Pragma"; + protected static final String HEADER_EXPIRES = "Expires"; + protected static final String HEADER_AUTHORIZATION = "Authorization"; + + protected static final String VAL_NO_CACHE = "no-cache, no-store, must-revalidate"; + protected static final String VAL_PRAGMA_NO_CACHE = "no-cache"; + protected static final String VAL_EXPIRES_ZERO = "0"; + + protected AppConfig appConfig; + protected AdfsTokenService tokenService; + protected ObjectMapper objectMapper; + + public void init(AppConfig appConfig, AdfsTokenService tokenService, ObjectMapper objectMapper) { + this.appConfig = appConfig; + this.tokenService = tokenService; + this.objectMapper = objectMapper; + } + + @Override + public void handle(HttpExchange exchange) throws IOException { + String method = exchange.getRequestMethod(); + String path = exchange.getRequestURI().getPath(); + logger.debug("Received request: {} {}", method, path); + + try { + processRequest(exchange); + } catch (Exception e) { + logger.error("Error processing request: {} {}", method, path, e); + sendError(exchange, 500, "internal_error", "An unexpected error occurred."); + } + } + + protected void processRequest(HttpExchange exchange) throws IOException { + String method = exchange.getRequestMethod(); + if (METHOD_GET.equalsIgnoreCase(method)) { + processRequestGet(exchange); + } else if (METHOD_POST.equalsIgnoreCase(method)) { + processRequestPost(exchange); + } else { + sendError(exchange, 405, "method_not_allowed", "Unsupported method: " + method); + } + } + + protected void processRequestGet(HttpExchange exchange) throws IOException { + sendError(exchange, 405, "method_not_allowed", "GET not supported."); + } + + protected void processRequestPost(HttpExchange exchange) throws IOException { + sendError(exchange, 405, "method_not_allowed", "POST not supported."); + } + + protected void sendJson(HttpExchange exchange, int statusCode, Object payload) throws IOException { + byte[] responseBytes = objectMapper.writeValueAsBytes(payload); + sendResponse(exchange, statusCode, CONTENT_TYPE_JSON, responseBytes); + } + + protected void sendResponse(HttpExchange exchange, int statusCode, String contentType, byte[] body) throws IOException { + int length = body != null ? body.length : -1; + logger.debug("Sending response: HTTP {} ({} bytes)", statusCode, length); + Headers headers = exchange.getResponseHeaders(); + if (contentType != null) { + headers.set(HEADER_CONTENT_TYPE, contentType); + } + headers.set(HEADER_CACHE_CONTROL, VAL_NO_CACHE); + headers.set(HEADER_PRAGMA, VAL_PRAGMA_NO_CACHE); + headers.set(HEADER_EXPIRES, VAL_EXPIRES_ZERO); + exchange.sendResponseHeaders(statusCode, length > 0 ? length : 0); + if (body != null && body.length > 0) { + try (OutputStream outputStream = exchange.getResponseBody()) { + outputStream.write(body); + } + } + } + + protected void sendError(HttpExchange exchange, int statusCode, String error, String errorDescription) + throws IOException { + logger.debug("Sending error response: HTTP {} - {}: {}", statusCode, error, errorDescription); + Map response = new LinkedHashMap<>(); + response.put("error", error); + response.put("error_description", errorDescription); + sendJson(exchange, statusCode, response); + } + + protected String getRequestBody(InputStream requestBody) throws IOException { + return new String(requestBody.readAllBytes(), StandardCharsets.UTF_8); + } +} diff --git a/src/main/java/cz/trask/adfsauthms/context/HealthHandler.java b/src/main/java/cz/trask/adfsauthms/context/HealthHandler.java new file mode 100644 index 0000000..aab34a5 --- /dev/null +++ b/src/main/java/cz/trask/adfsauthms/context/HealthHandler.java @@ -0,0 +1,19 @@ +package cz.trask.adfsauthms.context; + +import java.io.IOException; +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.Map; + +import com.sun.net.httpserver.HttpExchange; + +public class HealthHandler extends BaseContextHandler { + + @Override + protected void processRequestGet(HttpExchange exchange) throws IOException { + Map response = new LinkedHashMap<>(); + response.put("status", "UP"); + response.put("timestamp", Instant.now().toString()); + sendJson(exchange, 200, response); + } +} diff --git a/src/main/java/cz/trask/adfsauthms/context/ProcessHandler.java b/src/main/java/cz/trask/adfsauthms/context/ProcessHandler.java new file mode 100644 index 0000000..7faa6b2 --- /dev/null +++ b/src/main/java/cz/trask/adfsauthms/context/ProcessHandler.java @@ -0,0 +1,203 @@ +package cz.trask.adfsauthms.context; + +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URI; +import java.net.URL; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import com.sun.net.httpserver.Headers; +import com.sun.net.httpserver.HttpExchange; + +import cz.trask.adfsauthms.dto.TokenPayloadIdp; + +public class ProcessHandler extends BaseContextHandler { + + private static final String AUTH_PREFIX_BEARER = "Bearer "; + + private static final String ERR_SERVER_ERROR = "server_error"; + private static final String ERR_TOO_MANY_REQUESTS = "too_many_requests"; + private static final String ERR_INVALID_CONFIG = "invalid_configuration"; + private static final String ERR_PROCESS_FAILED = "process_failed"; + + private final AtomicInteger clientIdIndex = new AtomicInteger(0); + + @Override + protected void processRequest(HttpExchange exchange) throws IOException { + handleProxy(exchange); + } + + private void handleProxy(HttpExchange exchange) throws IOException { + try { + List clientIds = appConfig.getAdfs().getClientIds(); + if (clientIds == null || clientIds.isEmpty()) { + logger.error("No client IDs configured in adfs.clientIds"); + sendError(exchange, 500, ERR_SERVER_ERROR, "ADFS Client IDs not configured."); + return; + } + + int index = clientIdIndex.get() % clientIds.size(); + String currentClientId = clientIds.get(index); + + TokenPayloadIdp token = tokenService.getToken(currentClientId); + String accessToken = token.getAccessToken(); + + String backendBaseUrl = appConfig.getBackendUrl(); + if (backendBaseUrl == null || backendBaseUrl.isBlank()) { + logger.error("backend_url is not configured"); + sendError(exchange, 500, ERR_SERVER_ERROR, "Backend URL not configured."); + return; + } + + // Determine target URL + String contextPath = exchange.getHttpContext().getPath(); + String fullPath = exchange.getRequestURI().getPath(); + String relativePath = fullPath.substring(contextPath.length()); + String query = exchange.getRequestURI().getRawQuery(); + + URI baseUri = new URI(backendBaseUrl); + String targetPath = baseUri.getPath(); + if (targetPath == null) targetPath = ""; + if (targetPath.endsWith("/") && relativePath.startsWith("/")) { + targetPath += relativePath.substring(1); + } else if (!targetPath.endsWith("/") && !relativePath.startsWith("/") && !relativePath.isEmpty()) { + targetPath += "/" + relativePath; + } else { + targetPath += relativePath; + } + + String targetQuery = baseUri.getRawQuery(); + if (query != null && !query.isEmpty()) { + if (targetQuery == null || targetQuery.isEmpty()) { + targetQuery = query; + } else { + targetQuery += "&" + query; + } + } + + URI targetUri = new URI(baseUri.getScheme(), baseUri.getAuthority(), targetPath, null, null); + String targetUrl = targetUri.toString(); + if (targetQuery != null && !targetQuery.isEmpty()) { + targetUrl += "?" + targetQuery; + } + + String method = exchange.getRequestMethod(); + byte[] body = null; + if (exchange.getRequestBody() != null) { + body = exchange.getRequestBody().readAllBytes(); + } + + logger.info("Forwarding {} request to backend: {} (Client ID: {})", method, targetUrl, currentClientId); + BackendResponse backendResponse = callBackend(targetUrl, method, exchange.getRequestHeaders(), body, accessToken); + + int retryCount = 0; + int maxRetries = clientIds.size() - 1; + while (backendResponse.statusCode == 429 && retryCount < maxRetries) { + retryCount++; + int nextIndex = clientIdIndex.incrementAndGet() % clientIds.size(); + String retryClientId = clientIds.get(nextIndex); + + logger.warn("Backend returned 429. Retrying ({}/{}) with next client ID: {}...", + retryCount, maxRetries, retryClientId); + + token = tokenService.getToken(retryClientId); + accessToken = token.getAccessToken(); + + logger.info("Retrying request to backend with new token (Client ID: {})", retryClientId); + backendResponse = callBackend(targetUrl, method, exchange.getRequestHeaders(), body, accessToken); + } + + if (backendResponse.statusCode == 429) { + logger.error("Throttling limit reached. Exhausted all {} identities and backend still returns 429.", clientIds.size()); + sendError(exchange, 429, ERR_TOO_MANY_REQUESTS, + "All configured client identities are currently throttled by the backend after " + retryCount + " retries."); + return; + } + + sendResponse(exchange, backendResponse.statusCode, backendResponse.contentType, backendResponse.body); + + } catch (IllegalArgumentException e) { + sendError(exchange, 400, ERR_INVALID_CONFIG, e.getMessage()); + } catch (Exception e) { + logger.error("Failed to process request and forward to backend", e); + sendError(exchange, 502, ERR_PROCESS_FAILED, e.getMessage()); + } + } + + private BackendResponse callBackend(String targetUrl, String method, Headers requestHeaders, byte[] requestBody, String token) throws Exception { + HttpURLConnection connection = null; + try { + URL url = new URI(targetUrl).toURL(); + connection = (HttpURLConnection) url.openConnection(); + connection.setRequestMethod(method); + + // Forward headers + for (Map.Entry> entry : requestHeaders.entrySet()) { + String key = entry.getKey(); + if (shouldForwardHeader(key)) { + for (String value : entry.getValue()) { + connection.addRequestProperty(key, value); + } + } + } + + connection.setRequestProperty(HEADER_AUTHORIZATION, AUTH_PREFIX_BEARER + token); + connection.setConnectTimeout(5000); + connection.setReadTimeout(5000); + + if (requestBody != null && requestBody.length > 0) { + connection.setDoOutput(true); + try (var os = connection.getOutputStream()) { + os.write(requestBody); + } + } + + int responseCode = connection.getResponseCode(); + String contentType = connection.getContentType(); + logger.debug("Backend response code: {}, Content-Type: {}", responseCode, contentType); + + InputStream responseStream = responseCode >= 400 ? connection.getErrorStream() : connection.getInputStream(); + byte[] body = null; + if (responseStream != null) { + body = responseStream.readAllBytes(); + } + + return new BackendResponse(responseCode, contentType, body); + } finally { + if (connection != null) { + connection.disconnect(); + } + } + } + + private boolean shouldForwardHeader(String headerName) { + if (headerName == null) return false; + String lowerCaseHeader = headerName.toLowerCase(); + return !lowerCaseHeader.equals("host") && + !lowerCaseHeader.equals("authorization") && + !lowerCaseHeader.equals("content-length") && + !lowerCaseHeader.equals("connection") && + !lowerCaseHeader.equals("keep-alive") && + !lowerCaseHeader.equals("proxy-authenticate") && + !lowerCaseHeader.equals("proxy-authorization") && + !lowerCaseHeader.equals("te") && + !lowerCaseHeader.equals("trailers") && + !lowerCaseHeader.equals("transfer-encoding") && + !lowerCaseHeader.equals("upgrade"); + } + + private static class BackendResponse { + final int statusCode; + final String contentType; + final byte[] body; + + BackendResponse(int statusCode, String contentType, byte[] body) { + this.statusCode = statusCode; + this.contentType = contentType; + this.body = body; + } + } +} diff --git a/src/main/java/cz/trask/adfsauthms/dto/TokenPayloadIdp.java b/src/main/java/cz/trask/adfsauthms/dto/TokenPayloadIdp.java new file mode 100644 index 0000000..c2e6e7a --- /dev/null +++ b/src/main/java/cz/trask/adfsauthms/dto/TokenPayloadIdp.java @@ -0,0 +1,41 @@ +package cz.trask.adfsauthms.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +@JsonIgnoreProperties(ignoreUnknown = true) +public class TokenPayloadIdp { + + @JsonProperty("access_token") + private String accessToken; + + @JsonProperty("token_type") + private String tokenType; + + @JsonProperty("expires_in") + private int expiresIn; + + public String getAccessToken() { + return accessToken; + } + + public void setAccessToken(String accessToken) { + this.accessToken = accessToken; + } + + public String getTokenType() { + return tokenType; + } + + public void setTokenType(String tokenType) { + this.tokenType = tokenType; + } + + public int getExpiresIn() { + return expiresIn; + } + + public void setExpiresIn(int expiresIn) { + this.expiresIn = expiresIn; + } +} diff --git a/src/main/java/cz/trask/adfsauthms/service/AdfsTokenService.java b/src/main/java/cz/trask/adfsauthms/service/AdfsTokenService.java new file mode 100644 index 0000000..1e129d7 --- /dev/null +++ b/src/main/java/cz/trask/adfsauthms/service/AdfsTokenService.java @@ -0,0 +1,285 @@ +package cz.trask.adfsauthms.service; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.Authenticator; +import java.net.HttpURLConnection; +import java.net.InetSocketAddress; +import java.net.PasswordAuthentication; +import java.net.Proxy; +import java.net.URI; +import java.net.URL; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.security.KeyFactory; +import java.security.MessageDigest; +import java.security.PrivateKey; +import java.security.Signature; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.security.spec.PKCS8EncodedKeySpec; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.UUID; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import cz.trask.adfsauthms.config.AdfsConfig; +import cz.trask.adfsauthms.dto.TokenPayloadIdp; + +public class AdfsTokenService { + + private static final Logger logger = LogManager.getLogger(AdfsTokenService.class); + + private static final String PARAM_RESOURCE = "resource"; + private static final String PARAM_CLIENT_ID = "client_id"; + private static final String PARAM_CLIENT_ASSERTION_TYPE = "client_assertion_type"; + private static final String PARAM_CLIENT_ASSERTION = "client_assertion"; + private static final String PARAM_GRANT_TYPE = "grant_type"; + + private static final String GRANT_TYPE_CLIENT_CREDENTIALS = "client_credentials"; + private static final String CLIENT_ASSERTION_TYPE = + "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"; + + private static final String HEADER_CONTENT_TYPE = "Content-Type"; + private static final String CONTENT_TYPE_FORM = "application/x-www-form-urlencoded"; + private static final String METHOD_POST = "POST"; + + private final AdfsConfig config; + private final ObjectMapper objectMapper; + + private volatile TokenPayloadIdp cachedToken; + + public AdfsTokenService(AdfsConfig config, ObjectMapper objectMapper) { + this.config = config; + this.objectMapper = objectMapper; + } + + public synchronized TokenPayloadIdp getToken(String clientIdOverride) throws Exception { + validateConfig(); + if (clientIdOverride == null && cachedToken != null && cachedToken.getAccessToken() != null + && !cachedToken.getAccessToken().isBlank() + && !isJwtExpired(cachedToken.getAccessToken())) { + logger.debug("Returning cached ADFS token"); + return cachedToken; + } + + logger.info("Fetching new ADFS token from {} (Client ID: {})", config.getTokenUrl(), + clientIdOverride != null ? clientIdOverride : getDefaultClientId()); + TokenPayloadIdp fetchedToken = fetchToken(clientIdOverride); + if (clientIdOverride == null) { + this.cachedToken = fetchedToken; + } + return fetchedToken; + } + + private String getDefaultClientId() { + if (config.getClientIds() != null && !config.getClientIds().isEmpty()) { + return config.getClientIds().get(0); + } + return null; + } + + public synchronized void invalidateCache() { + logger.debug("Invalidating cached ADFS token"); + this.cachedToken = null; + } + + private TokenPayloadIdp fetchToken(String clientIdOverride) throws Exception { + Proxy proxy = buildProxy(); + if (proxy != null) { + logger.debug("Using proxy for ADFS token fetch: {}", config.getProxyHost()); + } + String effectiveClientId = clientIdOverride != null ? clientIdOverride : getDefaultClientId(); + String clientAssertion = generateJwtAssertion(effectiveClientId); + logger.debug("Generated JWT client assertion for ADFS: {}", clientAssertion); + String formData = buildFormData(effectiveClientId, clientAssertion); + + HttpURLConnection connection = null; + try { + URL url = new URI(config.getTokenUrl()).toURL(); + connection = (HttpURLConnection) url.openConnection(proxy != null ? proxy : Proxy.NO_PROXY); + connection.setRequestMethod(METHOD_POST); + connection.setDoInput(true); + connection.setDoOutput(true); + connection.setRequestProperty(HEADER_CONTENT_TYPE, CONTENT_TYPE_FORM); + + try (OutputStream outputStream = connection.getOutputStream()) { + outputStream.write(formData.getBytes(StandardCharsets.UTF_8)); + } + + int responseCode = connection.getResponseCode(); + logger.debug("ADFS response code: {}", responseCode); + InputStream responseStream = responseCode >= 400 ? connection.getErrorStream() : connection.getInputStream(); + String responseBody = readFully(responseStream); + if (responseCode >= 400) { + logger.debug("ADFS error body: {}", responseBody); + throw new IllegalStateException("ADFS returned HTTP " + responseCode + ": " + responseBody); + } + logger.debug("ADFS token fetched successfully"); + return objectMapper.readValue(responseBody, TokenPayloadIdp.class); + } finally { + if (connection != null) { + connection.disconnect(); + } + } + } + + private Proxy buildProxy() { + if (isBlank(config.getProxyHost()) || isBlank(config.getProxyPort())) { + return null; + } + + int port = Integer.parseInt(config.getProxyPort()); + if (!isBlank(config.getProxyUser()) && !isBlank(config.getProxyPassword())) { + Authenticator.setDefault(new Authenticator() { + @Override + protected PasswordAuthentication getPasswordAuthentication() { + return new PasswordAuthentication(config.getProxyUser(), config.getProxyPassword().toCharArray()); + } + }); + } + return new Proxy(Proxy.Type.HTTP, new InetSocketAddress(config.getProxyHost(), port)); + } + + private String buildFormData(String clientId, String clientAssertion) throws Exception { + Map params = new LinkedHashMap<>(); + params.put(PARAM_RESOURCE, config.getResource()); + params.put(PARAM_CLIENT_ID, clientId); + params.put(PARAM_CLIENT_ASSERTION_TYPE, CLIENT_ASSERTION_TYPE); + params.put(PARAM_CLIENT_ASSERTION, clientAssertion); + params.put(PARAM_GRANT_TYPE, GRANT_TYPE_CLIENT_CREDENTIALS); + + StringBuilder builder = new StringBuilder(); + for (Map.Entry entry : params.entrySet()) { + if (builder.length() > 0) { + builder.append('&'); + } + builder.append(URLEncoder.encode(entry.getKey(), StandardCharsets.UTF_8.name())); + builder.append('='); + builder.append(URLEncoder.encode(entry.getValue(), StandardCharsets.UTF_8.name())); + } + return builder.toString(); + } + + private String generateJwtAssertion(String clientId) throws Exception { + String certPem = new String(Base64.getDecoder().decode(config.getCertificate().replaceAll("\\s+", "")), StandardCharsets.UTF_8) + .replace("-----BEGIN CERTIFICATE-----", "") + .replace("-----END CERTIFICATE-----", "") + .replaceAll("\\s+", ""); + X509Certificate cert = getCertificate(certPem); + + String keyPem = new String(Base64.getDecoder().decode(config.getPrivateKey().replaceAll("\\s+", "")), StandardCharsets.UTF_8) + .replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .replaceAll("\\s+", ""); + PrivateKey privateKey = getPrivateKey(keyPem); + + MessageDigest sha1 = MessageDigest.getInstance("SHA-1"); + String x5t = Base64.getUrlEncoder().withoutPadding().encodeToString(sha1.digest(cert.getEncoded())); + + long now = System.currentTimeMillis() / 1000L; + + Map header = new LinkedHashMap<>(); + header.put("alg", "RS256"); + header.put("typ", "JWT"); + header.put("x5t", x5t); + header.put("kid", x5t); + + Map claims = new LinkedHashMap<>(); + claims.put("iss", clientId); + claims.put("sub", clientId); + claims.put("aud", config.getAudience()); + claims.put("jti", UUID.randomUUID().toString()); + claims.put("iat", now); + claims.put("nbf", now); + claims.put("exp", now + 600); + + String headerPart = Base64.getUrlEncoder().withoutPadding() + .encodeToString(objectMapper.writeValueAsBytes(header)); + String claimsPart = Base64.getUrlEncoder().withoutPadding() + .encodeToString(objectMapper.writeValueAsBytes(claims)); + String signingInput = headerPart + "." + claimsPart; + + Signature signature = Signature.getInstance("SHA256withRSA"); + signature.initSign(privateKey); + signature.update(signingInput.getBytes(StandardCharsets.UTF_8)); + String signaturePart = Base64.getUrlEncoder().withoutPadding().encodeToString(signature.sign()); + return signingInput + "." + signaturePart; + } + + private boolean isJwtExpired(String jwt) throws Exception { + String[] parts = jwt.split("\\."); + if (parts.length != 3) { + throw new IllegalArgumentException("Invalid JWT format"); + } + + byte[] decodedBytes = Base64.getUrlDecoder().decode(parts[1]); + Map claims = objectMapper.readValue(decodedBytes, Map.class); + Number exp = (Number) claims.get("exp"); + if (exp == null) { + throw new IllegalArgumentException("JWT does not contain 'exp' claim"); + } + return exp.longValue() * 1000L < System.currentTimeMillis(); + } + + private X509Certificate getCertificate(String certificatePem) throws Exception { + String normalized = certificatePem.replace("-----BEGIN CERTIFICATE-----", "") + .replace("-----END CERTIFICATE-----", "") + .replaceAll("\\s", ""); + byte[] certBytes = Base64.getDecoder().decode(normalized); + CertificateFactory factory = CertificateFactory.getInstance("X.509"); + return (X509Certificate) factory.generateCertificate(new ByteArrayInputStream(certBytes)); + } + + private PrivateKey getPrivateKey(String privateKeyPem) throws Exception { + String normalized = privateKeyPem.replaceAll("-----BEGIN (.*) PRIVATE KEY-----", "") + .replaceAll("-----END (.*) PRIVATE KEY-----", "") + .replaceAll("\\s", ""); + byte[] keyBytes = Base64.getDecoder().decode(normalized); + PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(keyBytes); + KeyFactory keyFactory = KeyFactory.getInstance("RSA"); + return keyFactory.generatePrivate(spec); + } + + private String readFully(InputStream inputStream) throws Exception { + if (inputStream == null) { + return ""; + } + + try (InputStream in = inputStream; ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + byte[] buffer = new byte[4096]; + for (int read = in.read(buffer); read >= 0; read = in.read(buffer)) { + baos.write(buffer, 0, read); + } + return baos.toString(StandardCharsets.UTF_8.name()); + } + } + + private void validateConfig() { + requireValue(config.getTokenUrl(), "tokenUrl"); + requireValue(config.getAudience(), "audience"); + requireValue(config.getResource(), "resource"); + if (config.getClientIds() == null || config.getClientIds().isEmpty()) { + throw new IllegalArgumentException("Missing or empty config value: clientIds"); + } + requireValue(config.getCertificate(), "certificate"); + requireValue(config.getPrivateKey(), "privateKey"); + } + + private void requireValue(String value, String name) { + if (isBlank(value)) { + throw new IllegalArgumentException("Missing or empty config value: " + name); + } + } + + private boolean isBlank(String value) { + return value == null || value.trim().isEmpty(); + } +} diff --git a/src/main/resources/config.yaml b/src/main/resources/config.yaml new file mode 100644 index 0000000..a2113fb --- /dev/null +++ b/src/main/resources/config.yaml @@ -0,0 +1,25 @@ +server: + type: http + port: 8080 + threads: 8 + contexts: + context: + - path: /process + class: cz.trask.adfsauthms.context.ProcessHandler + - path: /health + class: cz.trask.adfsauthms.context.HealthHandler + +adfs: + tokenUrl: "https://fs.komercpoj.loc/adfs/oauth2/token" + audience: "https://fs.komercpoj.loc/adfs/oauth2/token" + resource: "urn:kamma:api" + clientIds: + - "db2edf05-7af2-4523-97f9-39f930afc634" + certificate: "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSURFRENDQWZpZ0F3SUJBZ0lRRjhOQTNUQytVWUpJL1VRNTNTSFVGakFOQmdrcWhraUc5dzBCQVFVRkFEQWIKTVJrd0Z3WURWUVFEREJCUFFYVjBhQ0JEYkdsbGJuUWdTbGRVTUI0WERUSTJNRFl6TURFME1UazBOVm9YRFRJMwpNRFl6TURFME16azBOVm93R3pFWk1CY0dBMVVFQXd3UVQwRjFkR2dnUTJ4cFpXNTBJRXBYVkRDQ0FTSXdEUVlKCktvWklodmNOQVFFQkJRQURnZ0VQQURDQ0FRb0NnZ0VCQU1UVzFpL0tNUFY1cTl2TXgzTGV4V1lsNFdFTmJ6UDAKQVV5ZjhlNm1GcHdEcE5PWUVXV1RQYnAwUm5HRmY4SGpCaUFwTWFDSFZCbmJqMC81Q3pSWWk0N3lWWEMzaGRiQgpYdDdZRGNCeEduYnN5Zy9LdTB5SFRQRW5YblB5VCtpYXUzS2JqQXgyOGU1OXhzRHlFMzZxaktveGRTbURZT0M4CmpzM0hsMHNGTWNjd3JzZ0JxOFEyYllIVUt1TERORmRveE1DaEhOTHlxOE1sNFdnNmVHdWtMN2p4R0tGNXlzczEKM096emJoZkc5dEpWY290WUt0WDlHUThUckdpREF2UGFOREs2WXMxUTZWbDNKVjY5dmdET1ZSVlJCUzhpWmdLaQpQbTdob1hHb2s3VVEzV2R0c3dpU09ReE1iMWZhVDFLOWp0T3lmTm1vNjgyUkluU1VJSi9sTm9rQ0F3RUFBYU5RCk1FNHdEZ1lEVlIwUEFRSC9CQVFEQWdXZ01CMEdBMVVkSlFRV01CUUdDQ3NHQVFVRkJ3TUNCZ2dyQmdFRkJRY0QKQVRBZEJnTlZIUTRFRmdRVTlRNUJSVnpRVDVNbG5mY3N6VzFwUHZiZmRud3dEUVlKS29aSWh2Y05BUUVGQlFBRApnZ0VCQUpUMVlIYk03UCtrYi9wSmJ5bENLUUd6OEZ6eGFpeDVyay9MdndJNDVmcVJTQ0NUaERmemUrdTNiK05ZCjcxV2V0Z2Y2cUhubkVrdkRnVXJyWXU1QUhrZE9WWXlDUkJnN0dWKzIrQUtpWExEYUsrV0FnM2U4Zkk2cFNvOUMKeXdvc0lNNFcyaWJWSnhzVWNIZGhTUFBVVlkrVzNSY1B4VlZmWmV4eTZXUXBsVlV0Z3JYQmpYSEdEWE5YQUw3awo4ZUFwSEpDQVByKytabktQc3VzSWFyeVNqRXRrZXR0UHViMGp0MnJlajdqaHJuU1EyRlg4czhqcU43L0lCeWRlCjd2N2YwMlowMjVPV2hZRHozRVVQaWZLOXpmM2Nac2hZRDJ3OE1jdGVUZkZKaUxPMzM2bGhCRjBNVW5laDFEaDQKOWhSVEp2NVUwNXd3bnRRbWc3bloxOFpJRGNFPQotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg==" + privateKey: "LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCk1JSUV2UUlCQURBTkJna3Foa2lHOXcwQkFRRUZBQVNDQktjd2dnU2pBZ0VBQW9JQkFRREUxdFl2eWpEMWVhdmIKek1keTNzVm1KZUZoRFc4ejlBRk1uL0h1cGhhY0E2VFRtQkZsa3oyNmRFWnhoWC9CNHdZZ0tUR2doMVFaMjQ5UAorUXMwV0l1TzhsVnd0NFhXd1Y3ZTJBM0FjUnAyN01vUHlydE1oMHp4SjE1ejhrL29tcnR5bTR3TWR2SHVmY2JBCjhoTitxb3lxTVhVcGcyRGd2STdOeDVkTEJUSEhNSzdJQWF2RU5tMkIxQ3Jpd3pSWGFNVEFvUnpTOHF2REplRm8KT25ocnBDKzQ4UmloZWNyTE5kenM4MjRYeHZiU1ZYS0xXQ3JWL1JrUEU2eG9nd0x6MmpReXVtTE5VT2xaZHlWZQp2YjRBemxVVlVRVXZJbVlDb2o1dTRhRnhxSk8xRU4xbmJiTUlramtNVEc5WDJrOVN2WTdUc256WnFPdk5rU0owCmxDQ2Y1VGFKQWdNQkFBRUNnZ0VBTlk3ZktKR3pVSmVTazNQY3NQeThhVmJWUkRzTGp1OU5pelBaK0QxbzJYcUUKVnE2QnpVbUsramk1RWhwbzhMQjg4ak5ETlpLYzU0dytLbHh4R0FVZnMvMXNFZ2RkZTFtU0hzQmF2WW9JMFdNTQpkOCtKdHdENWhvUlh4dVNmcVlLd2pqYVVuSytnbEd2VXNKc3Rnc0dWRkpud2J5TTRNTzRkVFJDSnNmZnYyMnFCClNVRVh2MVFzRmhlbTBySnFuendPRlpycXVObXlvSnhTUzREYUJ4bmtqY3VHVG90Mk9qMlpVRjBTZW5HaU16NVkKRjZkR2dtWHNDTXBtc1k2czBrRUtDcExGMXIwS2VaUG8vZmVheU1GUDdONUtxVmxUSWExVGJjS2dQaWJFMGRqNwpXckhwR0l0Rm40ZHhJc2FCQlJ2dTlkZ2JJTVJuc0NhOFE1bURwbVg1SVFLQmdRRFVJQndFZjd1S0JqQ0M3OFFHClZzTmRLY1RuWTRrVDVxOFZiRlpVdkI2M052N0NDYmVCc1d0ZDRSMDFNUnkwVnhVUG4zekpWYk1DNUN5ckFHZ1EKTVRrVzlZQmVxb1A3aEk1ZnM5a1B5cG42SHlJN1NZS0ZldHF5RW5Ca1B4RklPSzUzRXJRWGxkZlRWbUdGdWpNUwpXYVpkOWpEOURmcGZKSzR6S2xuWHlXVlZkd0tCZ1FEdGpWVW9kNldRVEp5eHl1Vjk2Z29xbEl4L2t2UFNoQ2pqCkVqM2ZXY21HTGVBaUU2Z0lBNUh3YnFGM0tCSUVaSURpVVlJTjNkUUxLUlpVdXhDRkxpak9Ea3lBUkV1WFFUeFkKaUl0dWR3a3BmTS90OXdaQ09YSUczSWNXMDUvZHJrSzl5eXloMkRGTEJuZ3JERitoMzhDbWo0bExoc2RadDBQcgpXZjUxWnB6VC93S0JnR3d5aEpmMjN4MmowcEsyNFhHcVI3UDVYaW40Snk4emR4S2lVOWFjcmI0ZUd3dTJFUmZoCit6WERZVGFFZW5PeUIxZ1VyWDIwYkw2SXpBL2RBVGRoSkJHRjM1aHB1VEJOaUtGZ0J0TjdMOWJZa29sVEVYUXMKR2VqQ1p2bDdBY0dveDdTTW9iZDJBc1FWUjJFQ2ZKSmJqL1JWWXQ5d2hjaUoyU0RYOVVPUHdsUTVBb0dBWWp5VwpRTnZwemRqQTNBMktCaDRwQWg3WVUxR1VIelNrSy9NNVB3cEVlb2F5TDZWdFVaTVlZUk4vRm1XdHZiOUtSVTFyCnVReEpTaXc5bmVDV0hsMU9acGduTHN3UGJvZDl5eWI4Y2p3cnY4cHJ1bjd6U2FPejhmNTBwdzN4Q0oydDRBc0wKZEFxUnAvTU84czUxSmQ0QUwyRWdaK2xldTAwOGV6R0dOMHF1QkVVQ2dZRUF1VDZRS0FRUndxeWV4WlZPbmJPZwowZWVtelJSWDJYQStVV3J6YTRKbzV2MnZhYTR0cXhuQ2prQ1BaTldTc0diWUlTZVJNUUV0UXEwdUl0ZW9zMTFWCjJWM25BNXhwZ1ErbmZWNnpyQTFYK3VGekltWHpTbWgxSkpkK0RGUVhNT2xBWWNGY2J2aFdYWUVoaS9SR01DT3kKdU1MNWxBa2swV2JXRHFicFNGM1RMMlk9Ci0tLS0tRU5EIFBSSVZBVEUgS0VZLS0tLS0=" + proxyHost: "" + proxyPort: "" + proxyUser: "" + proxyPassword: "" + +backendUrl: "https://calc.kamma.cz/add?x=543&y=123" diff --git a/src/main/resources/log4j2.xml b/src/main/resources/log4j2.xml new file mode 100644 index 0000000..618bd06 --- /dev/null +++ b/src/main/resources/log4j2.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + +