Per-request timeout — {@link HttpTimeouts} из {@link HttpRequestParameters} отображается
+ * в HC5 {@link RequestConfig} при каждом вызове
+ *
+ *
+ *
SSL/mTLS настраивается один раз при создании через {@link Builder#sslContext(SSLContext)}.
+ * Per-request поля {@code sslSocketFactory} и {@code hostnameVerifier} из {@link HttpRequestParameters}
+ * игнорируются — HC5 использует pooled connections с одним SSL-контекстом.
+ *
+ *
+ */
+public class ApacheHC5HttpClient implements IHttpClient, AutoCloseable {
+
+ private static final Logger LOG = LoggerFactory.getLogger(ApacheHC5HttpClient.class);
+
+ // ── connection pool defaults ──────────────────────────────────────────────
+
+ /**
+ * Максимальное суммарное число соединений в пуле.
+ *
+ *
Дефолт {@code 20} рассчитан на single-host setup (consumer видит один SSO-host):
+ * {@code maxConnTotal == maxConnPerRoute}. Покрывает peak-нагрузку типичного consumer
+ * (paynet ≈ 20–30 RPS). Для multi-host или high-RPS — увеличить осознанно через builder.
+ */
+ public static final int DEFAULT_MAX_CONN_TOTAL = 20;
+
+ /**
+ * Максимальное число соединений на один маршрут (host:port).
+ *
+ *
Симметрично {@link #DEFAULT_MAX_CONN_TOTAL} для single-host setup: при одном
+ * SSO-сервере «total» и «perRoute» — один и тот же лимит. Для multi-host увеличить
+ * {@code maxConnTotal} пропорционально количеству хостов, сохраняя {@code perRoute=20}.
+ */
+ public static final int DEFAULT_MAX_CONN_PER_ROUTE = 20;
+
+ /** Время простоя (сек) после которого соединение вытесняется из пула. */
+ public static final int DEFAULT_IDLE_EVICTION_SEC = 30;
+
+ // ── state ─────────────────────────────────────────────────────────────────
+
+ private final CloseableHttpClient httpClient;
+ private final PoolingHttpClientConnectionManager connManager;
+ private volatile boolean closed = false;
+
+ // ── constructor ───────────────────────────────────────────────────────────
+
+ private ApacheHC5HttpClient(Builder builder) {
+ SSLConnectionSocketFactory sslSF = buildSslSocketFactory(builder.sslContext, builder.hostnameVerifier);
+
+ this.connManager = PoolingHttpClientConnectionManagerBuilder.create()
+ .setSSLSocketFactory(sslSF)
+ .setMaxConnTotal(builder.maxConnTotal)
+ .setMaxConnPerRoute(builder.maxConnPerRoute)
+ .build();
+
+ this.httpClient = HttpClients.custom()
+ .setConnectionManager(connManager)
+ .evictIdleConnections(TimeValue.of(builder.idleEvictionSec, TimeUnit.SECONDS))
+ .build();
+
+ LOG.info("ApacheHC5HttpClient created: maxConnTotal={} maxConnPerRoute={} idleEvictionSec={} ssl={}",
+ builder.maxConnTotal, builder.maxConnPerRoute, builder.idleEvictionSec,
+ builder.sslContext != null ? "configured" : "default");
+ }
+
+ // ── IHttpClient ───────────────────────────────────────────────────────────
+
+ @Override
+ public HttpResponse send(HttpRequest request, HttpRequestParameters params)
+ throws HttpConnectException, HttpReadException, HttpWriteException {
+
+ if (closed) {
+ throw new IllegalStateException("ApacheHC5HttpClient is already closed");
+ }
+
+ String url = request.getUrl();
+ long startMs = System.currentTimeMillis();
+
+ LOG.debug("send: method={} url={} timeouts={}", request.getMethod(), url, formatTimeouts(params));
+
+ ClassicHttpRequest hcRequest = buildHcRequest(request);
+ HttpClientContext context = buildContext(params);
+
+ try {
+ HttpResponse response = httpClient.execute(hcRequest, context, httpResponse -> {
+ int status = httpResponse.getCode();
+ String reason = httpResponse.getReasonPhrase();
+ List headers = mapResponseHeaders(httpResponse.getHeaders());
+ byte[] body = readBody(httpResponse.getEntity());
+
+ long elapsed = System.currentTimeMillis() - startMs;
+ logPoolStats();
+ LOG.debug("send: status={} reason={} bodyLen={} elapsed={}ms url={}",
+ status, reason, body.length, elapsed, url);
+
+ return new HttpResponse(status, reason, headers, body);
+ });
+
+ return response;
+
+ } catch (ConnectTimeoutException e) {
+ LOG.warn("send: connect timeout after {}ms url={}", System.currentTimeMillis() - startMs, url);
+ throw new HttpConnectException("Connect timeout to " + url, e);
+
+ } catch (SocketTimeoutException e) {
+ LOG.warn("send: response timeout after {}ms url={}", System.currentTimeMillis() - startMs, url);
+ throw new HttpReadException("Response timeout from " + url, e);
+
+ } catch (IOException e) {
+ String msg = e.getMessage() != null ? e.getMessage().toLowerCase() : "";
+ if (msg.contains("connect") || msg.contains("refused") || msg.contains("unreachable")) {
+ LOG.warn("send: connect error after {}ms url={} error={}", System.currentTimeMillis() - startMs, url, e.getMessage());
+ throw new HttpConnectException("Connection failed to " + url, e);
+ }
+ LOG.warn("send: read/write error after {}ms url={} error={}", System.currentTimeMillis() - startMs, url, e.getMessage());
+ throw new HttpReadException("I/O error communicating with " + url, e);
+ }
+ }
+
+ // ── AutoCloseable ─────────────────────────────────────────────────────────
+
+ @Override
+ public void close() {
+ if (closed) {
+ LOG.debug("close: already closed, ignoring");
+ return;
+ }
+ closed = true;
+ LOG.info("ApacheHC5HttpClient closing: shutting down connection pool and HTTP client");
+ try {
+ httpClient.close();
+ } catch (IOException e) {
+ LOG.warn("close: error closing httpClient: {}", e.getMessage());
+ }
+ connManager.close();
+ LOG.info("ApacheHC5HttpClient closed");
+ }
+
+ // ── Builder ───────────────────────────────────────────────────────────────
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ public static final class Builder {
+
+ private SSLContext sslContext = null;
+ private HostnameVerifier hostnameVerifier = null;
+ private int maxConnTotal = DEFAULT_MAX_CONN_TOTAL;
+ private int maxConnPerRoute = DEFAULT_MAX_CONN_PER_ROUTE;
+ private int idleEvictionSec = DEFAULT_IDLE_EVICTION_SEC;
+
+ private Builder() {}
+
+ /** SSLContext для mTLS/TLS. {@code null} → JVM default SSL context. */
+ public Builder sslContext(SSLContext sslContext) {
+ this.sslContext = sslContext;
+ return this;
+ }
+
+ /** HostnameVerifier для проверки CN сертификата сервера. {@code null} → стандартная проверка. */
+ public Builder hostnameVerifier(HostnameVerifier hostnameVerifier) {
+ this.hostnameVerifier = hostnameVerifier;
+ return this;
+ }
+
+ /** Максимальное суммарное число соединений в пуле. */
+ public Builder maxConnTotal(int maxConnTotal) {
+ this.maxConnTotal = maxConnTotal;
+ return this;
+ }
+
+ /** Максимальное число соединений на один маршрут. */
+ public Builder maxConnPerRoute(int maxConnPerRoute) {
+ this.maxConnPerRoute = maxConnPerRoute;
+ return this;
+ }
+
+ /** Время простоя (сек) после которого соединение вытесняется. */
+ public Builder idleEvictionSec(int idleEvictionSec) {
+ this.idleEvictionSec = idleEvictionSec;
+ return this;
+ }
+
+ public ApacheHC5HttpClient build() {
+ return new ApacheHC5HttpClient(this);
+ }
+ }
+
+ // ── internals ─────────────────────────────────────────────────────────────
+
+ private static SSLConnectionSocketFactory buildSslSocketFactory(
+ SSLContext sslContext, HostnameVerifier hostnameVerifier) {
+
+ SSLConnectionSocketFactoryBuilder sslBuilder = SSLConnectionSocketFactoryBuilder.create();
+ if (sslContext != null) {
+ sslBuilder.setSslContext(sslContext);
+ LOG.debug("buildSslSocketFactory: using custom SSLContext");
+ }
+ if (hostnameVerifier != null) {
+ sslBuilder.setHostnameVerifier(hostnameVerifier);
+ LOG.debug("buildSslSocketFactory: using custom HostnameVerifier={}", hostnameVerifier.getClass().getSimpleName());
+ }
+ return sslBuilder.build();
+ }
+
+ private static ClassicHttpRequest buildHcRequest(HttpRequest request) {
+ String methodName = request.getMethod() != null ? request.getMethod().name() : HttpMethod.GET.name();
+ BasicClassicHttpRequest hcRequest = new BasicClassicHttpRequest(methodName, request.getUrl());
+
+ HttpHeaders headers = request.getHeaders();
+ if (headers != null) {
+ for (HttpHeader header : headers.asList()) {
+ hcRequest.addHeader(header.getName(), header.getValue());
+ }
+ }
+
+ byte[] body = request.getBody();
+ if (body != null && body.length > 0) {
+ hcRequest.setEntity(new ByteArrayEntity(body, null));
+ }
+
+ return hcRequest;
+ }
+
+ private static HttpClientContext buildContext(HttpRequestParameters params) {
+ HttpClientContext context = HttpClientContext.create();
+ if (params != null && params.getTimeouts() != null) {
+ HttpTimeouts timeouts = params.getTimeouts();
+ RequestConfig requestConfig = RequestConfig.custom()
+ .setConnectTimeout(Timeout.ofMilliseconds(timeouts.getConnectTimeoutMs()))
+ .setResponseTimeout(Timeout.ofMilliseconds(timeouts.getReadTimeoutMs()))
+ .build();
+ context.setRequestConfig(requestConfig);
+ LOG.debug("buildContext: connectTimeout={}ms responseTimeout={}ms",
+ timeouts.getConnectTimeoutMs(), timeouts.getReadTimeoutMs());
+ }
+ return context;
+ }
+
+ private static byte[] readBody(HttpEntity entity) throws IOException {
+ if (entity == null) {
+ return new byte[0];
+ }
+ byte[] bytes = EntityUtils.toByteArray(entity);
+ return bytes != null ? bytes : new byte[0];
+ }
+
+ private static List mapResponseHeaders(Header[] headers) {
+ if (headers == null) {
+ return List.of();
+ }
+ List result = new ArrayList<>(headers.length);
+ for (Header h : headers) {
+ result.add(new HttpHeader(h.getName(), h.getValue()));
+ }
+ return result;
+ }
+
+ private void logPoolStats() {
+ if (LOG.isDebugEnabled()) {
+ var stats = connManager.getTotalStats();
+ LOG.debug("pool: available={} leased={} pending={} max={}",
+ stats.getAvailable(), stats.getLeased(), stats.getPending(), stats.getMax());
+ }
+ }
+
+ private static String formatTimeouts(HttpRequestParameters params) {
+ if (params == null || params.getTimeouts() == null) {
+ return "none";
+ }
+ HttpTimeouts t = params.getTimeouts();
+ return "connect=" + t.getConnectTimeoutMs() + "ms read=" + t.getReadTimeoutMs() + "ms";
+ }
+}
diff --git a/superfly-httpclient-hc5/src/test/java/com/payneteasy/httpclient/contrib/ssl/JdkSslSocketFactoryBuilderTest.java b/superfly-httpclient-hc5/src/test/java/com/payneteasy/httpclient/contrib/ssl/JdkSslSocketFactoryBuilderTest.java
new file mode 100644
index 00000000..efe72c9f
--- /dev/null
+++ b/superfly-httpclient-hc5/src/test/java/com/payneteasy/httpclient/contrib/ssl/JdkSslSocketFactoryBuilderTest.java
@@ -0,0 +1,199 @@
+package com.payneteasy.httpclient.contrib.ssl;
+
+import org.easymock.EasyMock;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+import javax.net.ssl.HostnameVerifier;
+import javax.net.ssl.SSLContext;
+import javax.net.ssl.SSLSession;
+import javax.net.ssl.SSLSocketFactory;
+import javax.net.ssl.X509TrustManager;
+import javax.security.auth.x500.X500Principal;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.net.URL;
+import java.security.GeneralSecurityException;
+import java.security.KeyStore;
+import java.security.cert.Certificate;
+import java.security.cert.X509Certificate;
+
+import static org.easymock.EasyMock.*;
+import static org.junit.Assert.*;
+
+public class JdkSslSocketFactoryBuilderTest {
+
+ @Rule
+ public TemporaryFolder tmp = new TemporaryFolder();
+
+ // ── buildSocketFactory ────────────────────────────────────────────────────
+
+ @Test
+ public void testBuildSocketFactory_withBothStores_returnsNonNull() throws Exception {
+ URL ksUrl = emptyJks("ks");
+ URL tsUrl = emptyJks("ts");
+
+ SSLSocketFactory factory = JdkSslSocketFactoryBuilder.buildSocketFactory(
+ ksUrl, "testpass", tsUrl, "testpass");
+
+ assertNotNull("SSLSocketFactory must not be null", factory);
+ }
+
+ @Test
+ public void testBuildSocketFactory_nullKeyStore_doesNotThrow() throws Exception {
+ URL tsUrl = emptyJks("ts");
+
+ SSLSocketFactory factory = JdkSslSocketFactoryBuilder.buildSocketFactory(
+ null, null, tsUrl, "testpass");
+
+ assertNotNull(factory);
+ }
+
+ @Test
+ public void testBuildSocketFactory_nullTrustStore_doesNotThrow() throws Exception {
+ URL ksUrl = emptyJks("ks");
+
+ SSLSocketFactory factory = JdkSslSocketFactoryBuilder.buildSocketFactory(
+ ksUrl, "testpass", null, null);
+
+ assertNotNull(factory);
+ }
+
+ @Test
+ public void testBuildSocketFactory_bothNull_doesNotThrow() throws Exception {
+ SSLSocketFactory factory = JdkSslSocketFactoryBuilder.buildSocketFactory(
+ null, null, null, null);
+
+ assertNotNull(factory);
+ }
+
+ @Test(expected = IOException.class)
+ public void testBuildSocketFactory_nonExistentKeyStoreUrl_throwsIOException() throws Exception {
+ URL bad = new File(tmp.getRoot(), "does-not-exist.jks").toURI().toURL();
+
+ JdkSslSocketFactoryBuilder.buildSocketFactory(bad, "pass", null, null);
+ }
+
+ // ── buildSslContext ───────────────────────────────────────────────────────
+
+ @Test
+ public void testBuildSslContext_withBothStores_returnsNonNull() throws Exception {
+ URL ksUrl = emptyJks("ks");
+ URL tsUrl = emptyJks("ts");
+
+ SSLContext ctx = JdkSslSocketFactoryBuilder.buildSslContext(ksUrl, "testpass", tsUrl, "testpass");
+
+ assertNotNull("SSLContext must not be null", ctx);
+ }
+
+ @Test
+ public void testBuildSslContext_nullKeyStore_doesNotThrow() throws Exception {
+ URL tsUrl = emptyJks("ts");
+
+ SSLContext ctx = JdkSslSocketFactoryBuilder.buildSslContext(null, null, tsUrl, "testpass");
+
+ assertNotNull(ctx);
+ }
+
+ @Test
+ public void testBuildSslContext_bothNull_doesNotThrow() throws Exception {
+ SSLContext ctx = JdkSslSocketFactoryBuilder.buildSslContext(null, null, null, null);
+
+ assertNotNull(ctx);
+ }
+
+ @Test
+ public void testBuildSslContext_socketFactoryConsistent() throws Exception {
+ // buildSocketFactory and buildSslContext must produce equivalent contexts
+ URL ksUrl = emptyJks("ks2");
+ URL tsUrl = emptyJks("ts2");
+
+ SSLContext ctx = JdkSslSocketFactoryBuilder.buildSslContext(ksUrl, "testpass", tsUrl, "testpass");
+ SSLSocketFactory factoryFromCtx = ctx.getSocketFactory();
+
+ assertNotNull("SocketFactory from SSLContext must not be null", factoryFromCtx);
+ }
+
+ // ── buildTrustManager ────────────────────────────────────────────────────
+
+ @Test
+ public void testBuildTrustManager_withTrustStore_returnsNonNull() throws Exception {
+ URL tsUrl = emptyJks("ts");
+
+ X509TrustManager tm = JdkSslSocketFactoryBuilder.buildTrustManager(tsUrl, "testpass");
+
+ assertNotNull("TrustManager must not be null", tm);
+ }
+
+ @Test
+ public void testBuildTrustManager_nullUrl_returnsNull() throws Exception {
+ X509TrustManager tm = JdkSslSocketFactoryBuilder.buildTrustManager(null, null);
+
+ assertNull(tm);
+ }
+
+ // ── buildCnHostnameVerifier ───────────────────────────────────────────────
+
+ @Test
+ public void testBuildCnHostnameVerifier_matchingCn_returnsTrue() throws Exception {
+ HostnameVerifier verifier = JdkSslSocketFactoryBuilder.
+ buildCnHostnameVerifier("superfly-server");
+
+ boolean result = verifier.verify("localhost", sessionWithCn("superfly-server"));
+
+ assertTrue("Verifier must accept cert with matching CN", result);
+ }
+
+ @Test
+ public void testBuildCnHostnameVerifier_nonMatchingCn_returnsFalse() throws Exception {
+ HostnameVerifier verifier = JdkSslSocketFactoryBuilder.buildCnHostnameVerifier("superfly-server");
+
+ boolean result = verifier.verify("localhost", sessionWithCn("other-server"));
+
+ assertFalse("Verifier must reject cert with non-matching CN", result);
+ }
+
+ @Test
+ public void testBuildCnHostnameVerifier_emptyCnList_returnsFalse() throws Exception {
+ HostnameVerifier verifier = JdkSslSocketFactoryBuilder.buildCnHostnameVerifier();
+
+ boolean result = verifier.verify("localhost", sessionWithCn("superfly-server"));
+
+ assertFalse("Verifier with empty allowed list must reject any CN", result);
+ }
+
+ @Test
+ public void testBuildCnHostnameVerifier_multipleCns_acceptsEitherOne() throws Exception {
+ HostnameVerifier verifier = JdkSslSocketFactoryBuilder.buildCnHostnameVerifier("superfly-server", "superfly-dev");
+
+ assertTrue(verifier.verify("localhost", sessionWithCn("superfly-server")));
+ assertTrue(verifier.verify("localhost", sessionWithCn("superfly-dev")));
+ assertFalse(verifier.verify("localhost", sessionWithCn("other")));
+ }
+
+ private SSLSession sessionWithCn(String cn) throws Exception {
+ X509Certificate cert = EasyMock.createMock(X509Certificate.class);
+ expect(cert.getSubjectX500Principal()).andReturn(new X500Principal("CN=" + cn)).anyTimes();
+ replay(cert);
+
+ SSLSession session = EasyMock.createMock(SSLSession.class);
+ expect(session.getPeerCertificates()).andReturn(new Certificate[]{ cert }).anyTimes();
+ replay(session);
+
+ return session;
+ }
+
+ // ── helpers ───────────────────────────────────────────────────────────────
+
+ private URL emptyJks(String prefix) throws GeneralSecurityException, IOException {
+ KeyStore ks = KeyStore.getInstance("JKS");
+ ks.load(null, null);
+ File f = tmp.newFile(prefix + ".jks");
+ try (FileOutputStream fos = new FileOutputStream(f)) {
+ ks.store(fos, "testpass".toCharArray());
+ }
+ return f.toURI().toURL();
+ }
+}
diff --git a/superfly-httpclient-hc5/src/test/java/com/payneteasy/superfly/api/transport/ApacheHC5HttpClientTest.java b/superfly-httpclient-hc5/src/test/java/com/payneteasy/superfly/api/transport/ApacheHC5HttpClientTest.java
new file mode 100644
index 00000000..29346e0f
--- /dev/null
+++ b/superfly-httpclient-hc5/src/test/java/com/payneteasy/superfly/api/transport/ApacheHC5HttpClientTest.java
@@ -0,0 +1,185 @@
+package com.payneteasy.superfly.api.transport;
+
+import com.payneteasy.http.client.api.HttpHeader;
+import com.payneteasy.http.client.api.HttpHeaders;
+import com.payneteasy.http.client.api.HttpMethod;
+import com.payneteasy.http.client.api.HttpRequest;
+import com.payneteasy.http.client.api.HttpRequestParameters;
+import com.payneteasy.http.client.api.HttpResponse;
+import com.payneteasy.http.client.api.HttpTimeouts;
+import com.payneteasy.http.client.api.exceptions.HttpConnectException;
+import com.payneteasy.http.client.api.exceptions.HttpReadException;
+import com.sun.net.httpserver.HttpServer;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.net.InetSocketAddress;
+import java.net.ServerSocket;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.concurrent.Executors;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.junit.Assert.*;
+
+/**
+ * Unit-тесты {@link ApacheHC5HttpClient} с JDK {@link HttpServer} в качестве embedded HTTP-сервера.
+ */
+public class ApacheHC5HttpClientTest {
+
+ private HttpServer server;
+ private int serverPort;
+ private ApacheHC5HttpClient client;
+
+ @Before
+ public void setUp() throws Exception {
+ server = HttpServer.create(new InetSocketAddress(0), 10);
+ server.setExecutor(Executors.newCachedThreadPool());
+ server.start();
+ serverPort = server.getAddress().getPort();
+ client = ApacheHC5HttpClient.builder().build();
+ }
+
+ @After
+ public void tearDown() {
+ if (client != null) client.close();
+ if (server != null) server.stop(0);
+ }
+
+ /**
+ * Contract test: дефолты pool size — single-host симметрия (20/20).
+ * Изменение этих констант должно потребовать осознанного обновления теста и причины в PR
+ * (см. ROADMAP.md SSO-3-tune: paynet — single SSO-host setup, ≈ 20–30 RPS).
+ */
+ @Test
+ public void testDefaultPoolSizes() {
+ assertEquals(20, ApacheHC5HttpClient.DEFAULT_MAX_CONN_TOTAL);
+ assertEquals(20, ApacheHC5HttpClient.DEFAULT_MAX_CONN_PER_ROUTE);
+ assertEquals(30, ApacheHC5HttpClient.DEFAULT_IDLE_EVICTION_SEC);
+ }
+
+ @Test
+ public void testSuccessfulPost() throws Exception {
+ server.createContext("/api/login", exchange -> {
+ byte[] resp = "{\"result\":\"ok\"}".getBytes(StandardCharsets.UTF_8);
+ exchange.getResponseHeaders().add("Content-Type", "application/json");
+ exchange.sendResponseHeaders(200, resp.length);
+ exchange.getResponseBody().write(resp);
+ exchange.close();
+ });
+
+ HttpResponse response = client.send(
+ HttpRequest.builder()
+ .url("http://localhost:" + serverPort + "/api/login")
+ .method(HttpMethod.POST)
+ .body("{\"user\":\"test\"}".getBytes(StandardCharsets.UTF_8))
+ .build(),
+ HttpRequestParameters.builder().timeouts(new HttpTimeouts(5_000, 5_000)).build());
+
+ assertEquals(200, response.getStatusCode());
+ assertEquals("{\"result\":\"ok\"}", new String(response.getBody(), StandardCharsets.UTF_8));
+ }
+
+ @Test(expected = HttpConnectException.class)
+ public void testConnectTimeoutThrows() throws Exception {
+ int freePort;
+ try (ServerSocket ss = new ServerSocket(0)) { freePort = ss.getLocalPort(); }
+
+ ApacheHC5HttpClient freshClient = ApacheHC5HttpClient.builder().build();
+ try {
+ freshClient.send(
+ HttpRequest.builder().url("http://localhost:" + freePort + "/test").method(HttpMethod.GET).build(),
+ HttpRequestParameters.builder().timeouts(new HttpTimeouts(200, 200)).build());
+ fail("Expected HttpConnectException");
+ } finally {
+ freshClient.close();
+ }
+ }
+
+ @Test(expected = HttpReadException.class)
+ public void testResponseTimeoutThrows() throws Exception {
+ server.createContext("/api/slow", exchange -> {
+ try { Thread.sleep(3_000); } catch (InterruptedException ignored) { Thread.currentThread().interrupt(); }
+ byte[] resp = "slow".getBytes(StandardCharsets.UTF_8);
+ exchange.sendResponseHeaders(200, resp.length);
+ exchange.getResponseBody().write(resp);
+ exchange.close();
+ });
+
+ client.send(
+ HttpRequest.builder().url("http://localhost:" + serverPort + "/api/slow").method(HttpMethod.GET).build(),
+ HttpRequestParameters.builder().timeouts(new HttpTimeouts(5_000, 150)).build());
+ fail("Expected HttpReadException");
+ }
+
+ @Test
+ public void testPerRequestTimeoutOverride() throws Exception {
+ server.createContext("/api/fast", exchange -> {
+ byte[] resp = "fast".getBytes(StandardCharsets.UTF_8);
+ exchange.sendResponseHeaders(200, resp.length);
+ exchange.getResponseBody().write(resp);
+ exchange.close();
+ });
+ server.createContext("/api/delayed", exchange -> {
+ try { Thread.sleep(500); } catch (InterruptedException ignored) { Thread.currentThread().interrupt(); }
+ byte[] resp = "delayed".getBytes(StandardCharsets.UTF_8);
+ exchange.sendResponseHeaders(200, resp.length);
+ exchange.getResponseBody().write(resp);
+ exchange.close();
+ });
+
+ HttpResponse fast = client.send(
+ HttpRequest.builder().url("http://localhost:" + serverPort + "/api/fast").method(HttpMethod.GET).build(),
+ HttpRequestParameters.builder().timeouts(new HttpTimeouts(5_000, 5_000)).build());
+ assertEquals(200, fast.getStatusCode());
+
+ try {
+ client.send(
+ HttpRequest.builder().url("http://localhost:" + serverPort + "/api/delayed").method(HttpMethod.GET).build(),
+ HttpRequestParameters.builder().timeouts(new HttpTimeouts(5_000, 100)).build());
+ fail("Expected HttpReadException from per-request timeout");
+ } catch (HttpReadException e) {
+ // expected
+ }
+ }
+
+ @Test(expected = IllegalStateException.class)
+ public void testAutoCloseableShutdown() throws Exception {
+ ApacheHC5HttpClient shortLived = ApacheHC5HttpClient.builder().build();
+ shortLived.close();
+ shortLived.send(
+ HttpRequest.builder().url("http://localhost:" + serverPort + "/test").method(HttpMethod.GET).build(),
+ HttpRequestParameters.builder().timeouts(new HttpTimeouts(5_000, 5_000)).build());
+ }
+
+ @Test
+ public void testHeadersForwardedCorrectly() throws Exception {
+ AtomicReference capturedToken = new AtomicReference<>();
+ AtomicReference capturedCt = new AtomicReference<>();
+
+ server.createContext("/api/auth", exchange -> {
+ capturedToken.set(exchange.getRequestHeaders().getFirst("X-Subsystem-Token"));
+ capturedCt.set(exchange.getRequestHeaders().getFirst("Content-Type"));
+ byte[] resp = "ok".getBytes(StandardCharsets.UTF_8);
+ exchange.sendResponseHeaders(200, resp.length);
+ exchange.getResponseBody().write(resp);
+ exchange.close();
+ });
+
+ HttpResponse response = client.send(
+ HttpRequest.builder()
+ .url("http://localhost:" + serverPort + "/api/auth")
+ .method(HttpMethod.POST)
+ .headers(new HttpHeaders(List.of(
+ new HttpHeader("X-Subsystem-Token", "token-abc"),
+ new HttpHeader("Content-Type", "application/x-java-serialized-object"))))
+ .body(new byte[]{1, 2, 3})
+ .build(),
+ HttpRequestParameters.builder().timeouts(new HttpTimeouts(5_000, 5_000)).build());
+
+ assertEquals(200, response.getStatusCode());
+ assertEquals("token-abc", capturedToken.get());
+ assertNotNull(capturedCt.get());
+ }
+}
diff --git a/superfly-httpclient-ssl/pom.xml b/superfly-httpclient-ssl/pom.xml
deleted file mode 100644
index 4c2df62d..00000000
--- a/superfly-httpclient-ssl/pom.xml
+++ /dev/null
@@ -1,23 +0,0 @@
-
-
- superfly-parent
- com.payneteasy.superfly
- 2.0-3-SNAPSHOT
-
- 4.0.0
- superfly-httpclient-ssl
- HttpClient SSL Extensions
- Extensions to HttpClient which allow to use client SSL authentication as well as server trust check
-
-
-
- commons-httpclient
- commons-httpclient
-
-
-
- org.slf4j
- slf4j-api
-
-
-
diff --git a/superfly-httpclient-ssl/src/main/java/com/payneteasy/httpclient/contrib/ssl/AuthSSLInitializationError.java b/superfly-httpclient-ssl/src/main/java/com/payneteasy/httpclient/contrib/ssl/AuthSSLInitializationError.java
deleted file mode 100644
index 64ce5605..00000000
--- a/superfly-httpclient-ssl/src/main/java/com/payneteasy/httpclient/contrib/ssl/AuthSSLInitializationError.java
+++ /dev/null
@@ -1,63 +0,0 @@
-/*
- * $HeadURL$
- * $Revision$
- * $Date$
- *
- * ====================================================================
- *
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements. See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You under the Apache License, Version 2.0
- * (the "License"); you may not use this file except in compliance with
- * the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- * ====================================================================
- *
- * This software consists of voluntary contributions made by many
- * individuals on behalf of the Apache Software Foundation. For more
- * information on the Apache Software Foundation, please see
- * .
- */
-
-package com.payneteasy.httpclient.contrib.ssl;
-
-/**
- *
- * Signals fatal error in initialization of {@link AuthSSLProtocolSocketFactory}.
- *
- * DISCLAIMER: HttpClient developers DO NOT actively support this component.
- * The component is provided as a reference material, which may be inappropriate
- * for use without additional customization.
- *
- */
-
-public class AuthSSLInitializationError extends Error {
-
- /**
- * Creates a new AuthSSLInitializationError.
- */
- public AuthSSLInitializationError() {
- super();
- }
-
- /**
- * Creates a new AuthSSLInitializationError with the specified message.
- *
- * @param message error message
- */
- public AuthSSLInitializationError(String message) {
- super(message);
- }
-}
diff --git a/superfly-httpclient-ssl/src/main/java/com/payneteasy/httpclient/contrib/ssl/AuthSSLProtocolSocketFactory.java b/superfly-httpclient-ssl/src/main/java/com/payneteasy/httpclient/contrib/ssl/AuthSSLProtocolSocketFactory.java
deleted file mode 100644
index 5a145ba3..00000000
--- a/superfly-httpclient-ssl/src/main/java/com/payneteasy/httpclient/contrib/ssl/AuthSSLProtocolSocketFactory.java
+++ /dev/null
@@ -1,471 +0,0 @@
-/*
- * $HeadURL$
- * $Revision$
- * $Date$
- *
- * ====================================================================
- *
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements. See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You under the Apache License, Version 2.0
- * (the "License"); you may not use this file except in compliance with
- * the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- * ====================================================================
- *
- * This software consists of voluntary contributions made by many
- * individuals on behalf of the Apache Software Foundation. For more
- * information on the Apache Software Foundation, please see
- * .
- *
- */
-
-package com.payneteasy.httpclient.contrib.ssl;
-
-import org.apache.commons.httpclient.ConnectTimeoutException;
-import org.apache.commons.httpclient.params.HttpConnectionParams;
-import org.apache.commons.httpclient.protocol.SecureProtocolSocketFactory;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import javax.net.SocketFactory;
-import javax.net.ssl.KeyManager;
-import javax.net.ssl.KeyManagerFactory;
-import javax.net.ssl.SSLContext;
-import javax.net.ssl.SSLSocket;
-import javax.net.ssl.TrustManager;
-import javax.net.ssl.TrustManagerFactory;
-import javax.net.ssl.X509KeyManager;
-import javax.net.ssl.X509TrustManager;
-import java.io.IOException;
-import java.io.InputStream;
-import java.net.InetAddress;
-import java.net.InetSocketAddress;
-import java.net.Socket;
-import java.net.SocketAddress;
-import java.net.URL;
-import java.net.UnknownHostException;
-import java.security.GeneralSecurityException;
-import java.security.KeyStore;
-import java.security.KeyStoreException;
-import java.security.NoSuchAlgorithmException;
-import java.security.UnrecoverableKeyException;
-import java.security.cert.Certificate;
-import java.security.cert.CertificateException;
-import java.security.cert.X509Certificate;
-import java.util.Enumeration;
-
-/**
- *
- * AuthSSLProtocolSocketFactory can be used to validate the identity of the HTTPS
- * server against a list of trusted certificates and to authenticate to the HTTPS
- * server using a private key.
- *
- *
- *
- * AuthSSLProtocolSocketFactory will enable server authentication when supplied with
- * a {@link KeyStore truststore} file containg one or several trusted certificates.
- * The client secure socket will reject the connection during the SSL session handshake
- * if the target HTTPS server attempts to authenticate itself with a non-trusted
- * certificate.
- *
- *
- *
- * Use JDK keytool utility to import a trusted certificate and generate a truststore file:
- *
- * keytool -import -alias "my server cert" -file server.crt -keystore my.truststore
- *
- *
- *
- *
- * AuthSSLProtocolSocketFactory will enable client authentication when supplied with
- * a {@link KeyStore keystore} file containg a private key/public certificate pair.
- * The client secure socket will use the private key to authenticate itself to the target
- * HTTPS server during the SSL session handshake if requested to do so by the server.
- * The target HTTPS server will in its turn verify the certificate presented by the client
- * in order to establish client's authenticity
- *
- *
- *
- * Use the following sequence of actions to generate a keystore file
- *
- *
- *
- *
- * Use JDK keytool utility to generate a new key
- *
- * For simplicity use the same password for the key as that of the keystore
- *
- *
- *
- *
- * Issue a certificate signing request (CSR)
- *
keytool -certreq -alias "my client key" -file mycertreq.csr -keystore my.keystore
- *
- *
- *
- *
- * Send the certificate request to the trusted Certificate Authority for signature.
- * One may choose to act as her own CA and sign the certificate request using a PKI
- * tool, such as OpenSSL.
- *
- *
- *
- *
- * Import the trusted CA root certificate
- *
keytool -import -alias "my trusted ca" -file caroot.crt -keystore my.keystore
- *
- *
- *
- *
- * Import the PKCS#7 file containg the complete certificate chain
- *
keytool -import -alias "my client key" -file mycert.p7 -keystore my.keystore
- *
- *
- *
- *
- * Verify the content the resultant keystore file
- *
keytool -list -v -keystore my.keystore
- *
- *
- *
- *
- * Example of using custom protocol socket factory for a specific host:
- *
- * Protocol authhttps = new Protocol("https",
- * new AuthSSLProtocolSocketFactory(
- * new URL("file:my.keystore"), "mypassword",
- * new URL("file:my.truststore"), "mypassword"), 443);
- *
- * HttpClient client = new HttpClient();
- * client.getHostConfiguration().setHost("localhost", 443, authhttps);
- * // use relative url only
- * GetMethod httpget = new GetMethod("/");
- * client.executeMethod(httpget);
- *
- *
- *
- * Example of using custom protocol socket factory per default instead of the standard one:
- *
- * Protocol authhttps = new Protocol("https",
- * new AuthSSLProtocolSocketFactory(
- * new URL("file:my.keystore"), "mypassword",
- * new URL("file:my.truststore"), "mypassword"), 443);
- * Protocol.registerProtocol("https", authhttps);
- *
- * HttpClient client = new HttpClient();
- * GetMethod httpget = new GetMethod("https://localhost/");
- * client.executeMethod(httpget);
- *
- * DISCLAIMER: HttpClient developers DO NOT actively support this component.
- * The component is provided as a reference material, which may be inappropriate
- * for use without additional customization.
- *
- */
-
-public class AuthSSLProtocolSocketFactory implements SecureProtocolSocketFactory {
-
- /** Log object for this class. */
- private static final Logger LOG = LoggerFactory.getLogger(AuthSSLProtocolSocketFactory.class);
-
- private URL keystoreUrl = null;
- private String keystorePassword = null;
- private URL truststoreUrl = null;
- private String truststorePassword = null;
- private SSLContext sslcontext = null;
-
- private String[] enabledProtocols = null;
-
- /**
- * Constructor for AuthSSLProtocolSocketFactory. Either a keystore or truststore file
- * must be given. Otherwise SSL context initialization error will result.
- *
- * @param keystoreUrl URL of the keystore file. May be null if HTTPS client
- * authentication is not to be used.
- * @param keystorePassword Password to unlock the keystore. IMPORTANT: this implementation
- * assumes that the same password is used to protect the key and the keystore itself.
- * @param truststoreUrl URL of the truststore file. May be null if HTTPS server
- * authentication is not to be used.
- * @param truststorePassword Password to unlock the truststore.
- */
- public AuthSSLProtocolSocketFactory(
- final URL keystoreUrl, final String keystorePassword,
- final URL truststoreUrl, final String truststorePassword)
- {
- super();
- this.keystoreUrl = keystoreUrl;
- this.keystorePassword = keystorePassword;
- this.truststoreUrl = truststoreUrl;
- this.truststorePassword = truststorePassword;
- }
-
- public void setEnabledProtocols(String[] enabledProtocols) {
- if (enabledProtocols == null) {
- this.enabledProtocols = null;
- } else {
- this.enabledProtocols = new String[enabledProtocols.length];
- System.arraycopy(enabledProtocols, 0, this.enabledProtocols, 0, enabledProtocols.length);
- }
- }
-
- private static KeyStore createKeyStore(final URL url, final String password)
- throws KeyStoreException, NoSuchAlgorithmException, CertificateException, IOException
- {
- if (url == null) {
- throw new IllegalArgumentException("Keystore url may not be null");
- }
- LOG.debug("Initializing key store");
- KeyStore keystore = KeyStore.getInstance("jks");
- InputStream is = null;
- try {
- is = url.openStream();
- keystore.load(is, password != null ? password.toCharArray(): null);
- } finally {
- if (is != null) is.close();
- }
- return keystore;
- }
-
- private static KeyManager[] createKeyManagers(final KeyStore keystore, final String password)
- throws KeyStoreException, NoSuchAlgorithmException, UnrecoverableKeyException
- {
- if (keystore == null) {
- throw new IllegalArgumentException("Keystore may not be null");
- }
- LOG.debug("Initializing key manager");
- KeyManagerFactory kmfactory = KeyManagerFactory.getInstance(
- KeyManagerFactory.getDefaultAlgorithm());
- kmfactory.init(keystore, password != null ? password.toCharArray(): null);
- KeyManager[] keymanagers = kmfactory.getKeyManagers();
- for (int i = 0; i < keymanagers.length; i++) {
- if (keymanagers[i] instanceof X509KeyManager) {
- keymanagers[i] = new AuthSSLX509KeyManager((X509KeyManager) keymanagers[i]);
- }
- }
- return keymanagers;
- }
-
- private static TrustManager[] createTrustManagers(final KeyStore keystore)
- throws KeyStoreException, NoSuchAlgorithmException
- {
- if (keystore == null) {
- throw new IllegalArgumentException("Keystore may not be null");
- }
- LOG.debug("Initializing trust manager");
- TrustManagerFactory tmfactory = TrustManagerFactory.getInstance(
- TrustManagerFactory.getDefaultAlgorithm());
- tmfactory.init(keystore);
- TrustManager[] trustmanagers = tmfactory.getTrustManagers();
- for (int i = 0; i < trustmanagers.length; i++) {
- if (trustmanagers[i] instanceof X509TrustManager) {
- trustmanagers[i] = new AuthSSLX509TrustManager(
- (X509TrustManager)trustmanagers[i]);
- }
- }
- return trustmanagers;
- }
-
- private SSLContext createSSLContext() {
- try {
- KeyManager[] keymanagers = null;
- TrustManager[] trustmanagers = null;
- if (this.keystoreUrl != null) {
- KeyStore keystore = createKeyStore(this.keystoreUrl, this.keystorePassword);
- if (LOG.isDebugEnabled()) {
- Enumeration aliases = keystore.aliases();
- while (aliases.hasMoreElements()) {
- String alias = (String)aliases.nextElement();
- Certificate[] certs = keystore.getCertificateChain(alias);
- if (certs != null) {
- LOG.debug("Certificate chain '" + alias + "':");
- for (int c = 0; c < certs.length; c++) {
- if (certs[c] instanceof X509Certificate) {
- X509Certificate cert = (X509Certificate)certs[c];
- LOG.debug(" Certificate " + (c + 1) + ":");
- LOG.debug(" Subject DN: " + cert.getSubjectDN());
- LOG.debug(" Signature Algorithm: " + cert.getSigAlgName());
- LOG.debug(" Valid from: " + cert.getNotBefore() );
- LOG.debug(" Valid until: " + cert.getNotAfter());
- LOG.debug(" Issuer: " + cert.getIssuerDN());
- }
- }
- }
- }
- }
- keymanagers = createKeyManagers(keystore, this.keystorePassword);
- }
- if (this.truststoreUrl != null) {
- KeyStore keystore = createKeyStore(this.truststoreUrl, this.truststorePassword);
- if (LOG.isDebugEnabled()) {
- Enumeration aliases = keystore.aliases();
- while (aliases.hasMoreElements()) {
- String alias = (String)aliases.nextElement();
- LOG.debug("Trusted certificate '" + alias + "':");
- Certificate trustedcert = keystore.getCertificate(alias);
- if (trustedcert != null && trustedcert instanceof X509Certificate) {
- X509Certificate cert = (X509Certificate)trustedcert;
- LOG.debug(" Subject DN: " + cert.getSubjectDN());
- LOG.debug(" Signature Algorithm: " + cert.getSigAlgName());
- LOG.debug(" Valid from: " + cert.getNotBefore() );
- LOG.debug(" Valid until: " + cert.getNotAfter());
- LOG.debug(" Issuer: " + cert.getIssuerDN());
- }
- }
- }
- trustmanagers = createTrustManagers(keystore);
- }
- SSLContext sslcontext = SSLContext.getInstance("SSL");
- sslcontext.init(keymanagers, trustmanagers, null);
- return sslcontext;
- } catch (NoSuchAlgorithmException e) {
- LOG.error(e.getMessage(), e);
- throw new AuthSSLInitializationError("Unsupported algorithm exception: " + e.getMessage());
- } catch (KeyStoreException e) {
- LOG.error(e.getMessage(), e);
- throw new AuthSSLInitializationError("Keystore exception: " + e.getMessage());
- } catch (GeneralSecurityException e) {
- LOG.error(e.getMessage(), e);
- throw new AuthSSLInitializationError("Key management exception: " + e.getMessage());
- } catch (IOException e) {
- LOG.error(e.getMessage(), e);
- throw new AuthSSLInitializationError("I/O error reading keystore/truststore file: " + e.getMessage());
- }
- }
-
- private SSLContext getSSLContext() {
- if (this.sslcontext == null) {
- this.sslcontext = createSSLContext();
- }
- return this.sslcontext;
- }
-
- /**
- * Attempts to get a new socket connection to the given host within the given time limit.
- *
- * To circumvent the limitations of older JREs that do not support connect timeout a
- * controller thread is executed. The controller thread attempts to create a new socket
- * within the given limit of time. If socket constructor does not return until the
- * timeout expires, the controller terminates and throws an {@link ConnectTimeoutException}
- *
- *
- * @param host the host name/IP
- * @param port the port on the host
- * @param localAddress the local host name/IP to bind the socket to
- * @param localPort the port on the local machine
- * @param params {@link HttpConnectionParams Http connection parameters}
- *
- * @return Socket a new socket
- *
- * @throws IOException if an I/O error occurs while creating the socket
- * @throws UnknownHostException if the IP address of the host cannot be
- * determined
- */
- public Socket createSocket(
- final String host,
- final int port,
- final InetAddress localAddress,
- final int localPort,
- final HttpConnectionParams params
- ) throws IOException, UnknownHostException, ConnectTimeoutException {
- if (params == null) {
- throw new IllegalArgumentException("Parameters may not be null");
- }
- int timeout = params.getConnectionTimeout();
- SocketFactory socketfactory = getSSLContext().getSocketFactory();
- if (timeout == 0) {
- Socket socket = socketfactory.createSocket(host, port, localAddress, localPort);
- doPreConnectSocketStuff(socket);
- return socket;
- } else {
- Socket socket = socketfactory.createSocket();
- doPreConnectSocketStuff(socket);
- SocketAddress localaddr = new InetSocketAddress(localAddress, localPort);
- SocketAddress remoteaddr = new InetSocketAddress(host, port);
-
- if (timeout > 0 && socket.getSoTimeout() == 0) {
- // force SO timeout if not set so we don't freeze forever
- // during a handshake
- socket.setSoTimeout(timeout);
- }
-
- socket.bind(localaddr);
- socket.connect(remoteaddr, timeout);
- return socket;
- }
- }
-
- /**
- * @see SecureProtocolSocketFactory#createSocket(java.lang.String,int,java.net.InetAddress,int)
- */
- public Socket createSocket(
- String host,
- int port,
- InetAddress clientHost,
- int clientPort)
- throws IOException, UnknownHostException
- {
- Socket socket = getSSLContext().getSocketFactory().createSocket(
- host,
- port,
- clientHost,
- clientPort
- );
- doPreConnectSocketStuff(socket);
- return socket;
- }
-
- /**
- * @see SecureProtocolSocketFactory#createSocket(java.lang.String,int)
- */
- public Socket createSocket(String host, int port)
- throws IOException, UnknownHostException
- {
- Socket socket = getSSLContext().getSocketFactory().createSocket(
- host,
- port
- );
- doPreConnectSocketStuff(socket);
- return socket;
- }
-
- /**
- * @see SecureProtocolSocketFactory#createSocket(java.net.Socket,java.lang.String,int,boolean)
- */
- public Socket createSocket(
- Socket socket,
- String host,
- int port,
- boolean autoClose)
- throws IOException, UnknownHostException
- {
- Socket createdSocket = getSSLContext().getSocketFactory().createSocket(
- socket,
- host,
- port,
- autoClose
- );
- doPreConnectSocketStuff(createdSocket);
- return createdSocket;
- }
-
- private void doPreConnectSocketStuff(Socket socket) {
- if (enabledProtocols != null) {
- if (socket instanceof SSLSocket) {
- SSLSocket sslSocket = (SSLSocket) socket;
- sslSocket.setEnabledProtocols(enabledProtocols);
- }
- }
- }
-}
diff --git a/superfly-integration-test/src/test/java/com/payneteasy/superfly/dao/UserDaoTest.java b/superfly-integration-test/src/test/java/com/payneteasy/superfly/dao/UserDaoTest.java
index faf2ecf7..54dad1d5 100644
--- a/superfly-integration-test/src/test/java/com/payneteasy/superfly/dao/UserDaoTest.java
+++ b/superfly-integration-test/src/test/java/com/payneteasy/superfly/dao/UserDaoTest.java
@@ -239,6 +239,13 @@ public void testUnlockUser() {
assertRoutineResult(result);
}
+ @Test
+ public void testResetPassword() {
+ long userId = getAnyUserId();
+ RoutineResult result = userDao.resetPassword(userId, "new-password-hash");
+ assertRoutineResult(result);
+ }
+
@Test
public void testCloneUser() {
long userId = getAnyUserId();
diff --git a/superfly-remote-api/pom.xml b/superfly-remote-api/pom.xml
index 24b6c29e..57678cb4 100644
--- a/superfly-remote-api/pom.xml
+++ b/superfly-remote-api/pom.xml
@@ -22,16 +22,11 @@
provided
-
+
com.payneteasy.http-clienthttp-client-api
-
-
- com.payneteasy.http-client
- http-client-impl
-
-
+com.google.code.gsongson
diff --git a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/SSOEvent.java b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/SSOEvent.java
new file mode 100644
index 00000000..30e3e323
--- /dev/null
+++ b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/SSOEvent.java
@@ -0,0 +1,64 @@
+package com.payneteasy.superfly.api;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * SSO event
+ */
+public class SSOEvent implements Serializable {
+ private static final long serialVersionUID = 2939579042187840631L;
+ private String eventData;
+ private Long eventId;
+ private String eventTypeCode;
+ private Date eventTime;
+
+ public SSOEvent(Long eventId, Date eventTime, String eventTypeCode, String eventData) {
+ this.eventId = eventId;
+ this.eventTime = eventTime;
+ this.eventTypeCode = eventTypeCode;
+ this.eventData = eventData;
+ }
+
+ public String getEventData() {
+ return eventData;
+ }
+
+ public void setEventData(String eventData) {
+ this.eventData = eventData;
+ }
+
+ public Long getEventId() {
+ return eventId;
+ }
+
+ public void setEventId(Long eventId) {
+ this.eventId = eventId;
+ }
+
+ public String getEventTypeCode() {
+ return eventTypeCode;
+ }
+
+ public void setEventTypeCode(String eventTypeCode) {
+ this.eventTypeCode = eventTypeCode;
+ }
+
+ public Date getEventTime() {
+ return eventTime;
+ }
+
+ public void setEventTime(Date eventTime) {
+ this.eventTime = eventTime;
+ }
+
+ @Override
+ public String toString() {
+ return "SSOEvent{" +
+ "eventData='" + eventData + '\'' +
+ ", eventId=" + eventId +
+ ", eventTypeCode='" + eventTypeCode + '\'' +
+ ", eventTime=" + eventTime +
+ '}';
+ }
+}
diff --git a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/SSOService.java b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/SSOService.java
index 68dab199..0364c800 100644
--- a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/SSOService.java
+++ b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/SSOService.java
@@ -176,4 +176,11 @@ void registerUser(UserRegisterRequest request)
* @param request change user role request containing username, new role, and subsystem hint
*/
void changeUserRole(ChangeUserRoleRequest request);
+
+ /**
+ * Get events from lastEventTime.
+ *
+ * @param request request containing lastEventTime and waitTimeMs
+ */
+ List getEvents(GetEventsRequest request);
}
diff --git a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/SSOUser.java b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/SSOUser.java
index 7faee5d1..28c2c0bb 100644
--- a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/SSOUser.java
+++ b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/SSOUser.java
@@ -128,6 +128,28 @@ public void setActionsMap(Map actionsMap) {
this.actionsMap = actionsMap;
}
+ /**
+ * Checks if the user has the specified action.
+ *
+ * @param actionName action name to check
+ * @return true if the user has the action
+ */
+ public boolean hasAction(String actionName) {
+ if (actionsMap == null) {
+ return false;
+ }
+ for (SSOAction[] actions : actionsMap.values()) {
+ if (actions != null) {
+ for (SSOAction action : actions) {
+ if (action != null && actionName.equals(action.getName())) {
+ return true;
+ }
+ }
+ }
+ }
+ return false;
+ }
+
/**
* Returns preferences.
*
diff --git a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/client/Endpoint.java b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/client/Endpoint.java
new file mode 100644
index 00000000..3f6f2c8b
--- /dev/null
+++ b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/client/Endpoint.java
@@ -0,0 +1,49 @@
+package com.payneteasy.superfly.api.client;
+
+/**
+ * Идентификатор REST-эндпоинта SSO API.
+ *
+ *
Используется как type-safe ключ для {@link SSOClientConfig#parametersFor(Endpoint)}
+ * (per-endpoint timeout override) и для построения URL через {@link SSOClientConfig#urlFor(Endpoint)}.
+ *
+ *
Содержит координаты SSO-сервера (baseUrl, subsystem name/token), дефолтные параметры HTTP-запроса
+ * и опциональный per-endpoint override таймаутов через {@link #endpointParameters}.
+ *
+ *
Per-endpoint timeouts. {@code defaultParameters} применяется ко всем эндпоинтам,
+ * для которых не задан override. Если в {@code endpointParameters} есть запись для конкретного
+ * {@link Endpoint} — используется она. Это позволяет разнести профили вызовов:
+ *
+ *
Безопасность URL. По умолчанию принимаются только {@code https://} URL — соответствие
+ * PCI DSS 4.2.1. HTTP-схему можно разрешить через JVM property
+ * {@code -Dsuperfly.client.allowInsecureScheme=true} (выводится WARN); этот режим — только для
+ * локальной разработки/тестов, использовать в production запрещено.
+ */
+@Getter
+@Slf4j
+public final class SSOClientConfig {
+
+ /**
+ * Имя JVM property для разрешения HTTP-схемы в {@code baseUrl}. По умолчанию недопустима.
+ */
+ public static final String ALLOW_INSECURE_PROPERTY = "superfly.client.allowInsecureScheme";
+
+ private final String baseUrl;
+ private final String subsystemName;
+ @Nullable
+ private final String subsystemToken;
+ private final HttpRequestParameters defaultParameters;
+ private final Map endpointParameters;
+
+ @Builder
+ private SSOClientConfig(
+ String baseUrl,
+ String subsystemName,
+ @Nullable String subsystemToken,
+ HttpRequestParameters defaultParameters,
+ @Singular("endpointParameter") Map endpointParameters
+ ) {
+ this.baseUrl = normalizeBaseUrl(baseUrl);
+ this.subsystemName = Objects.requireNonNull(subsystemName, "subsystemName must not be null");
+ this.subsystemToken = subsystemToken;
+ this.defaultParameters = Objects.requireNonNull(defaultParameters, "defaultParameters must not be null");
+ this.endpointParameters = endpointParameters == null ? Map.of() : Map.copyOf(endpointParameters);
+ log.debug("SSOClientConfig built: baseUrl={} subsystem={} overrides={}",
+ this.baseUrl, this.subsystemName, this.endpointParameters.keySet());
+ }
+
+ /**
+ * Возвращает {@link HttpRequestParameters} для заданного эндпоинта.
+ * Если в {@link #endpointParameters} есть override — используется он, иначе {@link #defaultParameters}.
+ */
+ public HttpRequestParameters parametersFor(Endpoint endpoint) {
+ HttpRequestParameters override = endpointParameters.get(endpoint);
+ HttpRequestParameters params = override != null ? override : defaultParameters;
+ if (log.isDebugEnabled()) {
+ HttpTimeouts t = params.getTimeouts();
+ if (t != null) {
+ log.debug("Resolved endpoint={} → {}(connect={}ms read={}ms)",
+ endpoint, override != null ? "override" : "default",
+ t.getConnectTimeoutMs(), t.getReadTimeoutMs());
+ } else {
+ log.debug("Resolved endpoint={} → {} (no timeouts set)",
+ endpoint, override != null ? "override" : "default");
+ }
+ }
+ return params;
+ }
+
+ /**
+ * @return полный URL для эндпоинта: {@code baseUrl + endpoint.path()}.
+ */
+ public String urlFor(Endpoint endpoint) {
+ return baseUrl + endpoint.path();
+ }
+
+ private static String normalizeBaseUrl(String url) {
+ if (url == null || url.isBlank()) {
+ throw new IllegalArgumentException("baseUrl must not be null or empty");
+ }
+ if (url.startsWith("http://")) {
+ if (Boolean.getBoolean(ALLOW_INSECURE_PROPERTY)) {
+ log.warn("Insecure HTTP scheme in baseUrl='{}' — allowed via -D{}=true (PCI DSS 4.2.1 violation)",
+ url, ALLOW_INSECURE_PROPERTY);
+ } else {
+ throw new IllegalArgumentException(
+ "baseUrl must use HTTPS (PCI DSS 4.2.1). Got: " + url
+ + ". To allow HTTP set -D" + ALLOW_INSECURE_PROPERTY + "=true");
+ }
+ }
+ return url.endsWith("/") ? url.substring(0, url.length() - 1) : url;
+ }
+}
diff --git a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/client/SSOHttpServiceApiClient.java b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/client/SSOHttpServiceApiClient.java
index 4a1b8121..1601178e 100644
--- a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/client/SSOHttpServiceApiClient.java
+++ b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/client/SSOHttpServiceApiClient.java
@@ -2,14 +2,59 @@
import com.google.gson.JsonSyntaxException;
import com.google.gson.reflect.TypeToken;
-import com.payneteasy.http.client.api.*;
+import com.payneteasy.http.client.api.HttpHeader;
+import com.payneteasy.http.client.api.HttpHeaders;
+import com.payneteasy.http.client.api.HttpMethod;
+import com.payneteasy.http.client.api.HttpRequest;
+import com.payneteasy.http.client.api.HttpRequestParameters;
+import com.payneteasy.http.client.api.HttpResponse;
+import com.payneteasy.http.client.api.IHttpClient;
import com.payneteasy.http.client.api.exceptions.HttpConnectException;
import com.payneteasy.http.client.api.exceptions.HttpReadException;
import com.payneteasy.http.client.api.exceptions.HttpWriteException;
-import com.payneteasy.http.client.impl.HttpClientImpl;
-import com.payneteasy.superfly.api.*;
-import com.payneteasy.superfly.api.exceptions.*;
-import com.payneteasy.superfly.api.request.*;
+import com.payneteasy.superfly.api.SSOEvent;
+import com.payneteasy.superfly.api.SSOService;
+import com.payneteasy.superfly.api.SSOUser;
+import com.payneteasy.superfly.api.SSOUserWithActions;
+import com.payneteasy.superfly.api.UserDescription;
+import com.payneteasy.superfly.api.UserRegisterRequest;
+import com.payneteasy.superfly.api.UserStatus;
+import com.payneteasy.superfly.api.exceptions.BadPublicKeyException;
+import com.payneteasy.superfly.api.exceptions.MessageSendException;
+import com.payneteasy.superfly.api.exceptions.PolicyValidationException;
+import com.payneteasy.superfly.api.exceptions.SsoBadRequestException;
+import com.payneteasy.superfly.api.exceptions.SsoClientException;
+import com.payneteasy.superfly.api.exceptions.SsoConflictException;
+import com.payneteasy.superfly.api.exceptions.SsoConnectionException;
+import com.payneteasy.superfly.api.exceptions.SsoException;
+import com.payneteasy.superfly.api.exceptions.SsoForbiddenException;
+import com.payneteasy.superfly.api.exceptions.SsoNotFoundException;
+import com.payneteasy.superfly.api.exceptions.SsoParseException;
+import com.payneteasy.superfly.api.exceptions.SsoServerException;
+import com.payneteasy.superfly.api.exceptions.SsoUnauthorizedException;
+import com.payneteasy.superfly.api.exceptions.UserExistsException;
+import com.payneteasy.superfly.api.UserNotFoundException;
+import com.payneteasy.superfly.api.exceptions.SsoDecryptException;
+import com.payneteasy.superfly.api.request.AuthenticateRequest;
+import com.payneteasy.superfly.api.request.ChangeTempPasswordRequest;
+import com.payneteasy.superfly.api.request.ChangeUserRoleRequest;
+import com.payneteasy.superfly.api.request.CheckOtpRequest;
+import com.payneteasy.superfly.api.request.CompleteUserRequest;
+import com.payneteasy.superfly.api.request.ExchangeSubsystemTokenRequest;
+import com.payneteasy.superfly.api.request.GetEventsRequest;
+import com.payneteasy.superfly.api.request.GetGoogleAuthQrCodeRequest;
+import com.payneteasy.superfly.api.request.GetUserDescriptionRequest;
+import com.payneteasy.superfly.api.request.GetUserStatusesRequest;
+import com.payneteasy.superfly.api.request.GetUsersWithActionsRequest;
+import com.payneteasy.superfly.api.request.HasOtpMasterKeyRequest;
+import com.payneteasy.superfly.api.request.PasswordResetRequest;
+import com.payneteasy.superfly.api.request.PseudoAuthenticateRequest;
+import com.payneteasy.superfly.api.request.ResetGoogleAuthMasterKeyRequest;
+import com.payneteasy.superfly.api.request.SendSystemDataRequest;
+import com.payneteasy.superfly.api.request.TouchSessionsRequest;
+import com.payneteasy.superfly.api.request.UpdateUserDescriptionRequest;
+import com.payneteasy.superfly.api.request.UpdateUserIsOtpOptionalValueRequest;
+import com.payneteasy.superfly.api.request.UpdateUserOtpTypeRequest;
import com.payneteasy.superfly.api.serialization.ApiSerializationManager;
import com.payneteasy.superfly.api.serialization.ExceptionSerializationHelper;
import com.payneteasy.superfly.api.serialization.ExceptionWrapper;
@@ -24,8 +69,33 @@
import static com.payneteasy.superfly.api.serialization.ApiSerializer.CONTENT_TYPE_JSON;
+/**
+ * HTTP-реализация {@link SSOService} над transport-абстракцией {@link IHttpClient}.
+ *
+ *
Thread-safety
+ * Класс immutable после конструирования — все поля {@code final}, конфигурация и заголовки
+ * прекомпилированы один раз. Безопасен для использования из нескольких потоков, при условии
+ * что нижележащий {@link IHttpClient} thread-safe.
+ *
+ *
Lifecycle
+ * Реализует {@link AutoCloseable}: {@link #close()} делегирует {@code close()} в нижележащий
+ * transport, если тот тоже {@link AutoCloseable}. Используйте try-with-resources в local scope
+ * или явно вызывайте {@code close()} при остановке приложения (через Spring {@code DisposableBean}
+ * или DI-контейнер).
+ *
+ *
Per-endpoint таймауты
+ * Каждый вызов резолвит {@link HttpRequestParameters} через {@link SSOClientConfig#parametersFor(Endpoint)}.
+ * Это позволяет назначать разные таймауты для критичного {@link Endpoint#AUTHENTICATE} (короткий)
+ * и для {@link Endpoint#GET_EVENTS} (long-poll, длинный). См. Javadoc {@link SSOClientConfig}.
+ *
+ *
Decode-path
+ * Любой не-200 статус сначала проверяется на {@link ExceptionWrapper} в теле ответа — если сервер
+ * прислал типизированное доменное исключение ({@link UserExistsException}, {@link PolicyValidationException}
+ * и др.), оно прокидывается как есть. Если wrapper не распознан — fallback к
+ * status-based исключению ({@link SsoBadRequestException}, {@link SsoUnauthorizedException}, …).
+ */
@Slf4j
-public class SSOHttpServiceApiClient implements SSOService {
+public final class SSOHttpServiceApiClient implements SSOService, AutoCloseable {
private static final String HEADER_SUBSYSTEM_NAME = "X-Subsystem-Name";
private static final String HEADER_SUBSYSTEM_TOKEN = "X-Subsystem-Token";
@@ -33,285 +103,296 @@ public class SSOHttpServiceApiClient implements SSOService {
private static final String HEADER_ACCEPT = "Accept";
private final IHttpClient httpClient;
- private final HttpRequestParameters parameters;
- private final String baseUrl;
- private final String subsystemName;
- private final String subsystemToken;
+ private final SSOClientConfig config;
private final ApiSerializationManager serializationManager;
+ private final HttpHeaders staticHeaders;
/**
- * @param subsystemToken can be null if use x509 authentication
- * @param baseUrl for example https://superfly.payneteasy.com/superfly/remoting/sso.service
+ * Primary конструктор с явной инъекцией зависимостей.
+ *
+ * @param httpClient transport-адаптер (например {@code HttpClientImpl} или Apache HC5).
+ * @param config конфигурация SSO-клиента (URL, subsystem, per-endpoint params).
+ * @param serializationManager сериализатор тела запросов/ответов.
*/
public SSOHttpServiceApiClient(
- HttpRequestParameters httpRequestParameters,
- String baseUrl,
- String subsystemName,
- @Nullable String subsystemToken,
+ IHttpClient httpClient,
+ SSOClientConfig config,
ApiSerializationManager serializationManager
) {
- this.httpClient = getHttpClient();
-
- this.parameters = Objects.requireNonNull(httpRequestParameters, "httpRequestParameters must not be null");
- this.baseUrl = validateUrl(baseUrl);
- this.subsystemName = Objects.requireNonNull(subsystemName, "subsystemName must not be null");
- this.subsystemToken = subsystemToken;
- this.serializationManager = serializationManager;
- }
-
- protected IHttpClient getHttpClient() {
- return new HttpClientImpl();
- }
-
- private String validateUrl(String url) {
- if (url == null || url.isBlank()) {
- throw new IllegalArgumentException("baseUrl must not be null or empty");
- }
- return url.endsWith("/") ? url.substring(0, url.length() - 1) : url;
+ this.httpClient = Objects.requireNonNull(httpClient, "httpClient must not be null");
+ this.config = Objects.requireNonNull(config, "config must not be null");
+ this.serializationManager = Objects.requireNonNull(serializationManager, "serializationManager must not be null");
+ this.staticHeaders = buildStaticHeaders(config);
+ log.debug("SSOHttpServiceApiClient initialized: baseUrl={} subsystem={} transport={}",
+ config.getBaseUrl(), config.getSubsystemName(), httpClient.getClass().getSimpleName());
}
- // Interface method implementations
+ // ====================================================================
+ // SSOService interface methods — все делегируют в invoke(Endpoint, ...)
+ // ====================================================================
@Override
- public SSOUser authenticate(AuthenticateRequest request) throws SsoAuthException {
- return post("/authenticate", request, SSOUser.class);
+ public SSOUser authenticate(AuthenticateRequest request) {
+ return invoke(Endpoint.AUTHENTICATE, request, SSOUser.class);
}
@Override
- public boolean checkOtp(CheckOtpRequest request) throws SsoAuthException {
- return post("/checkOtp", request, Boolean.class);
+ public boolean checkOtp(CheckOtpRequest request) throws SsoDecryptException {
+ return invoke(Endpoint.CHECK_OTP, request, Boolean.class);
}
@Override
- public boolean hasOtpMasterKey(HasOtpMasterKeyRequest request) throws SsoAuthException {
- return post("/hasOtpMasterKey", request, Boolean.class);
+ public boolean hasOtpMasterKey(HasOtpMasterKeyRequest request) {
+ return invoke(Endpoint.HAS_OTP_MASTER_KEY, request, Boolean.class);
}
@Override
- public SSOUser pseudoAuthenticate(PseudoAuthenticateRequest request) throws SsoAuthException {
- return post("/hasOtpMasterKey", request, SSOUser.class);
+ public SSOUser pseudoAuthenticate(PseudoAuthenticateRequest request) {
+ return invoke(Endpoint.PSEUDO_AUTHENTICATE, request, SSOUser.class);
}
@Override
- public void sendSystemData(SendSystemDataRequest request) throws SsoSystemException {
- post("/sendSystemData", request, Void.class);
+ public void sendSystemData(SendSystemDataRequest request) {
+ invoke(Endpoint.SEND_SYSTEM_DATA, request, Void.class);
}
@Override
- public List getUsersWithActions(GetUsersWithActionsRequest request) throws SsoDataException {
- return post(
- "/getUsersWithActions",
- request,
- new TypeToken<>() {
- }
- );
+ public List getUsersWithActions(GetUsersWithActionsRequest request) {
+ return invoke(Endpoint.GET_USERS_WITH_ACTIONS, request, new TypeToken<>() {});
}
@Override
- public void updateUserOtpType(UpdateUserOtpTypeRequest request) throws SsoUserException {
- post("/updateUserOtpType", request, Void.class);
+ public void updateUserOtpType(UpdateUserOtpTypeRequest request) {
+ invoke(Endpoint.UPDATE_USER_OTP_TYPE, request, Void.class);
}
@Override
- public void registerUser(UserRegisterRequest request) throws UserExistsException, PolicyValidationException,
- BadPublicKeyException, MessageSendException {
- // Simply pass the request, exceptions will be handled automatically
- // through the ExceptionWrapper mechanism
- post("/registerUser", request, Void.class);
+ public void registerUser(UserRegisterRequest request)
+ throws UserExistsException, PolicyValidationException, BadPublicKeyException, MessageSendException {
+ invoke(Endpoint.REGISTER_USER, request, Void.class);
}
@Override
public void changeTempPassword(ChangeTempPasswordRequest request) throws PolicyValidationException {
- post("/changeTempPassword", request, Void.class);
+ invoke(Endpoint.CHANGE_TEMP_PASSWORD, request, Void.class);
}
@Override
- public UserDescription getUserDescription(GetUserDescriptionRequest request) throws SsoUserException {
- return post("/getUserDescription", request, UserDescription.class);
+ public UserDescription getUserDescription(GetUserDescriptionRequest request) {
+ return invoke(Endpoint.GET_USER_DESCRIPTION, request, UserDescription.class);
}
@Override
public String resetGoogleAuthMasterKey(ResetGoogleAuthMasterKeyRequest request)
throws UserNotFoundException, SsoDecryptException {
- return post("/resetGoogleAuthMasterKey", request, String.class);
+ return invoke(Endpoint.RESET_GOOGLE_AUTH_MASTER_KEY, request, String.class);
}
@Override
public String getUrlToGoogleAuthQrCode(GetGoogleAuthQrCodeRequest request) {
- return post("/getUrlToGoogleAuthQrCode", request, String.class);
+ return invoke(Endpoint.GET_URL_TO_GOOGLE_AUTH_QR_CODE, request, String.class);
}
@Override
- public void updateUserIsOtpOptionalValue(UpdateUserIsOtpOptionalValueRequest request) throws SsoUserException {
- post("/updateUserIsOtpOptionalValue", request, Void.class);
+ public void updateUserIsOtpOptionalValue(UpdateUserIsOtpOptionalValueRequest request) {
+ invoke(Endpoint.UPDATE_USER_IS_OTP_OPTIONAL_VALUE, request, Void.class);
}
@Override
public void updateUserDescription(UpdateUserDescriptionRequest request)
throws UserNotFoundException, BadPublicKeyException {
- post("/updateUserDescription", request, Void.class);
+ invoke(Endpoint.UPDATE_USER_DESCRIPTION, request, Void.class);
}
@Override
- public void resetPassword(PasswordResetRequest reset) throws UserNotFoundException, PolicyValidationException {
- post("/resetPassword", reset, Void.class);
+ public void resetPassword(PasswordResetRequest reset)
+ throws UserNotFoundException, PolicyValidationException {
+ invoke(Endpoint.RESET_PASSWORD, reset, Void.class);
}
@Override
- public List getUserStatuses(GetUserStatusesRequest request) throws SsoDataException {
- return post(
- "/getUserStatuses",
- request,
- new TypeToken<>() {
- }
- );
+ public List getUserStatuses(GetUserStatusesRequest request) {
+ return invoke(Endpoint.GET_USER_STATUSES, request, new TypeToken<>() {});
}
@Override
- public SSOUser exchangeSubsystemToken(ExchangeSubsystemTokenRequest request) throws SsoAuthException {
- return post("/exchangeSubsystemToken", request, SSOUser.class);
+ public SSOUser exchangeSubsystemToken(ExchangeSubsystemTokenRequest request) {
+ return invoke(Endpoint.EXCHANGE_SUBSYSTEM_TOKEN, request, SSOUser.class);
}
@Override
- public void touchSessions(TouchSessionsRequest request) throws SsoSystemException {
- post("/touchSessions", request, Void.class);
+ public void touchSessions(TouchSessionsRequest request) {
+ invoke(Endpoint.TOUCH_SESSIONS, request, Void.class);
}
@Override
- public void completeUser(CompleteUserRequest request) throws SsoUserException {
- post("/completeUser", request, Void.class);
+ public void completeUser(CompleteUserRequest request) {
+ invoke(Endpoint.COMPLETE_USER, request, Void.class);
}
@Override
- public void changeUserRole(ChangeUserRoleRequest request) throws SsoUserException {
- post("/changeUserRole", request, Void.class);
+ public void changeUserRole(ChangeUserRoleRequest request) {
+ invoke(Endpoint.CHANGE_USER_ROLE, request, Void.class);
}
- private T post(String endpoint, Object request, Class responseClass) throws SsoClientException {
- return post(endpoint, request, TypeToken.get(responseClass));
+ @Override
+ public List getEvents(GetEventsRequest request) {
+ return invoke(Endpoint.GET_EVENTS, request, new TypeToken<>() {});
}
- private T post(String endpoint, Object request, TypeToken typeToken) throws SsoClientException {
- String url = baseUrl + endpoint;
- String requestBody = serializationManager.serialize(request);
+ // ====================================================================
+ // Core: invoke → send → decode
+ // ====================================================================
- if (log.isDebugEnabled()) {
- log.debug("Sending request to {}: {}", url, requestBody);
- }
+ private T invoke(Endpoint endpoint, Object body, Class responseClass) {
+ return invoke(endpoint, body, TypeToken.get(responseClass));
+ }
+
+ private T invoke(Endpoint endpoint, Object body, TypeToken typeToken) {
+ HttpRequestParameters parameters = config.parametersFor(endpoint);
+ HttpResponse response = send(endpoint, body, parameters);
+ return decode(endpoint, response, typeToken);
+ }
+
+ private HttpResponse send(Endpoint endpoint, Object body, HttpRequestParameters parameters) {
+ String url = config.urlFor(endpoint);
+ String requestBody = serializationManager.serialize(body);
+
+ log.debug("invoke endpoint={} url={} body.length={}", endpoint, url, requestBody.length());
+
+ HttpRequest request = HttpRequest.builder()
+ .url(url)
+ .method(HttpMethod.POST)
+ .headers(staticHeaders)
+ .body(requestBody.getBytes(StandardCharsets.UTF_8))
+ .build();
- HttpResponse response;
try {
- response = httpClient.send(
- buildRequest(url, requestBody),
- parameters
- );
- if (log.isDebugEnabled()) {
- log.debug("Get response status: {}, body: {}", response.getStatusCode(), new String(response.getBody(), StandardCharsets.UTF_8));
- }
+ HttpResponse response = httpClient.send(request, parameters);
+ log.debug("Response endpoint={} status={} body.length={}",
+ endpoint, response.getStatusCode(),
+ response.getBody() == null ? 0 : response.getBody().length);
+ return response;
} catch (HttpConnectException | HttpWriteException | HttpReadException e) {
- throw new SsoConnectionException("Connection error: " + e.getMessage(), e);
+ log.debug("Connection error endpoint={}: {}", endpoint, e.getMessage());
+ throw new SsoConnectionException("Connection error to " + endpoint + ": " + e.getMessage(), e);
}
-
- validateResponse(response, url);
- return parseResponse(response, typeToken.getType());
- }
-
- private HttpRequest buildRequest(String url, String body) {
- return HttpRequest.builder()
- .url(url)
- .method(HttpMethod.POST)
- .headers(createHeaders())
- .body(body.getBytes(StandardCharsets.UTF_8))
- .build();
}
- private HttpHeaders createHeaders() {
- List headers = new ArrayList<>(List.of(
- new HttpHeader(HEADER_SUBSYSTEM_NAME, subsystemName),
- new HttpHeader(HEADER_CONTENT_TYPE, CONTENT_TYPE_JSON),
- new HttpHeader(HEADER_ACCEPT, CONTENT_TYPE_JSON)
- ));
+ /**
+ * Унифицированный decode-path:
+ *
+ *
{@code status == 200} → десериализация тела как {@code T}.
+ *
Любой другой статус (включая 4xx/5xx и 2xx-non-200) → попытка распарсить тело как
+ * {@link ExceptionWrapper}; если успешно — пробросить типизированное серверное исключение
+ * через {@link ExceptionSerializationHelper#createException}.
+ *
Иначе fallback к status-based исключению ({@link SsoBadRequestException},
+ * {@link SsoUnauthorizedException}, …, {@link SsoServerException}, generic {@link SsoClientException}).
+ *
+ *
+ *
Bug fix: Предыдущая реализация бросала generic status-based исключение до того,
+ * как могла попытаться распарсить {@link ExceptionWrapper} для 4xx/5xx — серверные доменные
+ * исключения ({@link UserExistsException}, {@link PolicyValidationException}) терялись и
+ * заменялись на generic {@link SsoBadRequestException}. Теперь wrapper парсится первым.
+ */
+ private T decode(Endpoint endpoint, HttpResponse response, TypeToken typeToken) {
+ int status = response.getStatusCode();
+ String body = response.getBody() == null ? "" : new String(response.getBody(), StandardCharsets.UTF_8);
+ String contentType = resolveContentType(response);
+
+ if (status == 200) {
+ log.debug("decode endpoint={} status=200 → success path", endpoint);
+ return deserialize(body, typeToken.getType(), contentType);
+ }
- if (subsystemToken != null) {
- headers.add(new HttpHeader(HEADER_SUBSYSTEM_TOKEN, subsystemToken));
+ ExceptionWrapper wrapper = tryParseExceptionWrapper(body, contentType);
+ if (wrapper != null && wrapper.getExceptionClass() != null) {
+ log.debug("decode endpoint={} status={} → ExceptionWrapper class={} message={}",
+ endpoint, status, wrapper.getExceptionClass(), wrapper.getMessage());
+ Throwable recreated = ExceptionSerializationHelper.createException(wrapper);
+ if (recreated instanceof RuntimeException re) {
+ throw re;
+ }
+ throw new SsoParseException("Server exception is not a RuntimeException: " + recreated.getMessage(), recreated);
}
- return new HttpHeaders(headers);
+ log.debug("decode endpoint={} status={} → no wrapper, fallback to status-based exception", endpoint, status);
+ throw statusToException(endpoint, status, body);
}
- private void validateResponse(HttpResponse response, String url) throws SsoClientException {
- int statusCode = response.getStatusCode();
-
- if (statusCode >= 200 && statusCode < 300) {
- return;
+ @Nullable
+ private ExceptionWrapper tryParseExceptionWrapper(String body, String contentType) {
+ if (body == null || body.isBlank()) {
+ return null;
}
-
- String body = new String(response.getBody(), StandardCharsets.UTF_8);
- String message = String.format("HTTP error %d from %s: %s", statusCode, url, body);
-
- switch (statusCode) {
- case 400:
- throw new SsoBadRequestException(message);
- case 401:
- throw new SsoUnauthorizedException(message);
- case 403:
- throw new SsoForbiddenException(message);
- case 404:
- throw new SsoNotFoundException(message);
- case 409:
- throw new SsoConflictException(message);
- default:
- if (statusCode >= 500) {
- throw new SsoServerException(message);
- }
- throw new SsoClientException(statusCode, message);
+ try {
+ Object parsed = serializationManager.deserialize(body, ExceptionWrapper.class, contentType);
+ return parsed instanceof ExceptionWrapper ew ? ew : null;
+ } catch (JsonSyntaxException e) {
+ log.debug("Body is not valid ExceptionWrapper JSON: {}", e.getMessage());
+ return null;
+ } catch (Exception e) {
+ log.debug("Failed to parse body as ExceptionWrapper: {}", e.getMessage());
+ return null;
}
}
- private T parseResponse(HttpResponse response, Type type) throws SsoParseException {
- String responseText = new String(response.getBody(), StandardCharsets.UTF_8);
- String contentType = response
- .getHeaders()
- .stream()
- .filter(s -> s.getName().equalsIgnoreCase(HEADER_CONTENT_TYPE))
- .findFirst()
- .orElseGet(() -> new HttpHeader(HEADER_CONTENT_TYPE, serializationManager.getDefaultContentType()))
- .getValue()
- ;
-
-
- if (response.getStatusCode() != 200) {
- ExceptionWrapper exceptionWrapper;
- try {
- exceptionWrapper = (ExceptionWrapper) serializationManager.deserialize(
- responseText, ExceptionWrapper.class, contentType);
- } catch (JsonSyntaxException e) {
- throw new SsoParseException("Failed to parse response: " + e.getMessage(), e);
- }
-
- if (exceptionWrapper != null && exceptionWrapper.getExceptionClass() != null) {
- // Recreate the exception on the client side
- Throwable exception = ExceptionSerializationHelper.createException(exceptionWrapper);
- if (exception instanceof RuntimeException) {
- throw (RuntimeException) exception;
- } else {
- throw new SsoParseException("Received exception: " + exception.getMessage(), exception);
- }
- }
- }
+ private T deserialize(String body, Type type, String contentType) {
try {
- // Check if the response contains exception information
@SuppressWarnings("unchecked")
- T result = (T) serializationManager.deserialize(responseText, type, contentType);
+ T result = (T) serializationManager.deserialize(body, type, contentType);
return result;
} catch (SsoException e) {
- // Propagate SsoException exceptions further
throw e;
} catch (Exception e) {
throw new SsoParseException("Failed to parse response: " + e.getMessage(), e);
}
}
- // handleSpecificExceptions removed, as the ExceptionWrapper mechanism is now used
+ private static RuntimeException statusToException(Endpoint endpoint, int status, String body) {
+ String message = String.format("HTTP error %d from %s: %s", status, endpoint, body);
+ return switch (status) {
+ case 400 -> new SsoBadRequestException(message);
+ case 401 -> new SsoUnauthorizedException(message);
+ case 403 -> new SsoForbiddenException(message);
+ case 404 -> new SsoNotFoundException(message);
+ case 409 -> new SsoConflictException(message);
+ default -> status >= 500 ? new SsoServerException(message) : new SsoClientException(status, message);
+ };
+ }
+ private String resolveContentType(HttpResponse response) {
+ return response.getHeaders().stream()
+ .filter(h -> h.getName().equalsIgnoreCase(HEADER_CONTENT_TYPE))
+ .findFirst()
+ .map(HttpHeader::getValue)
+ .orElseGet(serializationManager::getDefaultContentType);
+ }
+
+ private static HttpHeaders buildStaticHeaders(SSOClientConfig config) {
+ List headers = new ArrayList<>(4);
+ headers.add(new HttpHeader(HEADER_SUBSYSTEM_NAME, config.getSubsystemName()));
+ headers.add(new HttpHeader(HEADER_CONTENT_TYPE, CONTENT_TYPE_JSON));
+ headers.add(new HttpHeader(HEADER_ACCEPT, CONTENT_TYPE_JSON));
+ if (config.getSubsystemToken() != null) {
+ headers.add(new HttpHeader(HEADER_SUBSYSTEM_TOKEN, config.getSubsystemToken()));
+ }
+ return new HttpHeaders(headers);
+ }
+
+ // ====================================================================
+ // AutoCloseable
+ // ====================================================================
+
+ /**
+ * Закрывает нижележащий transport, если он реализует {@link AutoCloseable}.
+ * Без побочных эффектов, если transport не {@link AutoCloseable}.
+ */
+ @Override
+ public void close() throws Exception {
+ log.debug("Closing SSO client, transport={}", httpClient.getClass().getSimpleName());
+ if (httpClient instanceof AutoCloseable closeable) {
+ closeable.close();
+ }
+ }
}
diff --git a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/AuthenticateRequest.java b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/AuthenticateRequest.java
index e9eb8149..90ca7137 100644
--- a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/AuthenticateRequest.java
+++ b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/AuthenticateRequest.java
@@ -5,12 +5,13 @@
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
+import java.io.Serializable;
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
-public class AuthenticateRequest {
+public class AuthenticateRequest implements Serializable {
private String username;
private String password;
private AuthenticationRequestInfo authRequestInfo;
diff --git a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/ChangeTempPasswordRequest.java b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/ChangeTempPasswordRequest.java
index 5f68b207..ad766313 100644
--- a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/ChangeTempPasswordRequest.java
+++ b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/ChangeTempPasswordRequest.java
@@ -4,12 +4,13 @@
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
+import java.io.Serializable;
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
-public class ChangeTempPasswordRequest {
+public class ChangeTempPasswordRequest implements Serializable {
private String username;
private String newPassword;
}
diff --git a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/ChangeUserRoleRequest.java b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/ChangeUserRoleRequest.java
index 06f20e35..fc514591 100644
--- a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/ChangeUserRoleRequest.java
+++ b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/ChangeUserRoleRequest.java
@@ -4,12 +4,13 @@
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
+import java.io.Serializable;
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
-public class ChangeUserRoleRequest {
+public class ChangeUserRoleRequest implements Serializable {
private String username;
private String newRole;
private String subsystemHint;
diff --git a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/CheckOtpRequest.java b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/CheckOtpRequest.java
index 1bfb5b45..d823b763 100644
--- a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/CheckOtpRequest.java
+++ b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/CheckOtpRequest.java
@@ -5,11 +5,12 @@
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
+import java.io.Serializable;
@Data
@Builder
@NoArgsConstructor
-public class CheckOtpRequest {
+public class CheckOtpRequest implements Serializable {
private String userName;
private String code;
private OTPType otpType;
diff --git a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/CompleteUserRequest.java b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/CompleteUserRequest.java
index eb08ce3d..e5080dac 100644
--- a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/CompleteUserRequest.java
+++ b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/CompleteUserRequest.java
@@ -4,11 +4,12 @@
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
+import java.io.Serializable;
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
-public class CompleteUserRequest {
+public class CompleteUserRequest implements Serializable {
private String username;
}
diff --git a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/ExchangeSubsystemTokenRequest.java b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/ExchangeSubsystemTokenRequest.java
index 4934277f..18845641 100644
--- a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/ExchangeSubsystemTokenRequest.java
+++ b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/ExchangeSubsystemTokenRequest.java
@@ -4,11 +4,12 @@
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
+import java.io.Serializable;
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
-public class ExchangeSubsystemTokenRequest {
+public class ExchangeSubsystemTokenRequest implements Serializable {
private String subsystemToken;
}
diff --git a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/GetEventsRequest.java b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/GetEventsRequest.java
new file mode 100644
index 00000000..a1c5bbb8
--- /dev/null
+++ b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/GetEventsRequest.java
@@ -0,0 +1,19 @@
+package com.payneteasy.superfly.api.request;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.Date;
+import java.io.Serializable;
+
+@Data
+@Builder
+@AllArgsConstructor
+@NoArgsConstructor
+public class GetEventsRequest implements Serializable {
+ private Date lastEventTime;
+ private long waitTimeMs;
+ private String subsystemName;
+}
diff --git a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/GetGoogleAuthQrCodeRequest.java b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/GetGoogleAuthQrCodeRequest.java
index 68ec16ee..4582a54c 100644
--- a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/GetGoogleAuthQrCodeRequest.java
+++ b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/GetGoogleAuthQrCodeRequest.java
@@ -4,12 +4,13 @@
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
+import java.io.Serializable;
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
-public class GetGoogleAuthQrCodeRequest {
+public class GetGoogleAuthQrCodeRequest implements Serializable {
private String secretKey;
private String issuer;
private String accountName;
diff --git a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/GetUserDescriptionRequest.java b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/GetUserDescriptionRequest.java
index 46b4f3cd..d3cc7a0b 100644
--- a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/GetUserDescriptionRequest.java
+++ b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/GetUserDescriptionRequest.java
@@ -4,11 +4,12 @@
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
+import java.io.Serializable;
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
-public class GetUserDescriptionRequest {
+public class GetUserDescriptionRequest implements Serializable {
private String username;
}
diff --git a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/GetUserStatusesRequest.java b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/GetUserStatusesRequest.java
index e660ef51..f3388120 100644
--- a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/GetUserStatusesRequest.java
+++ b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/GetUserStatusesRequest.java
@@ -6,11 +6,12 @@
import lombok.NoArgsConstructor;
import java.util.List;
+import java.io.Serializable;
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
-public class GetUserStatusesRequest {
+public class GetUserStatusesRequest implements Serializable {
private List userNames;
}
diff --git a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/GetUsersWithActionsRequest.java b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/GetUsersWithActionsRequest.java
index 678c8579..7abf5d9a 100644
--- a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/GetUsersWithActionsRequest.java
+++ b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/GetUsersWithActionsRequest.java
@@ -4,11 +4,12 @@
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
+import java.io.Serializable;
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
-public class GetUsersWithActionsRequest {
+public class GetUsersWithActionsRequest implements Serializable {
private String subsystemIdentifier;
}
diff --git a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/HasOtpMasterKeyRequest.java b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/HasOtpMasterKeyRequest.java
index c211b3c9..f795c28a 100644
--- a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/HasOtpMasterKeyRequest.java
+++ b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/HasOtpMasterKeyRequest.java
@@ -4,11 +4,12 @@
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
+import java.io.Serializable;
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
-public class HasOtpMasterKeyRequest {
+public class HasOtpMasterKeyRequest implements Serializable {
private String username;
}
diff --git a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/PseudoAuthenticateRequest.java b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/PseudoAuthenticateRequest.java
index fb84640a..2084d96f 100644
--- a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/PseudoAuthenticateRequest.java
+++ b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/PseudoAuthenticateRequest.java
@@ -4,12 +4,13 @@
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
+import java.io.Serializable;
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
-public class PseudoAuthenticateRequest {
+public class PseudoAuthenticateRequest implements Serializable {
private String username;
private String subsystemIdentifier;
}
diff --git a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/RegisterUserRequest.java b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/RegisterUserRequest.java
index bb5f1b2f..91391d5b 100644
--- a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/RegisterUserRequest.java
+++ b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/RegisterUserRequest.java
@@ -5,12 +5,13 @@
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
+import java.io.Serializable;
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
-public class RegisterUserRequest {
+public class RegisterUserRequest implements Serializable {
private String username;
private String password;
private String email;
diff --git a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/ResetGoogleAuthMasterKeyRequest.java b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/ResetGoogleAuthMasterKeyRequest.java
index 77b7f81a..04d5bcc1 100644
--- a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/ResetGoogleAuthMasterKeyRequest.java
+++ b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/ResetGoogleAuthMasterKeyRequest.java
@@ -4,11 +4,12 @@
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
+import java.io.Serializable;
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
-public class ResetGoogleAuthMasterKeyRequest {
+public class ResetGoogleAuthMasterKeyRequest implements Serializable {
private String username;
}
diff --git a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/SendSystemDataRequest.java b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/SendSystemDataRequest.java
index 69dac9f2..34eca00c 100644
--- a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/SendSystemDataRequest.java
+++ b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/SendSystemDataRequest.java
@@ -7,12 +7,13 @@
import lombok.NoArgsConstructor;
import java.util.List;
+import java.io.Serializable;
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
-public class SendSystemDataRequest {
+public class SendSystemDataRequest implements Serializable {
private String subsystemIdentifier;
private List actionDescriptions;
}
diff --git a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/TouchSessionsRequest.java b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/TouchSessionsRequest.java
index 5a81d6d1..c2572909 100644
--- a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/TouchSessionsRequest.java
+++ b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/TouchSessionsRequest.java
@@ -6,11 +6,12 @@
import lombok.NoArgsConstructor;
import java.util.List;
+import java.io.Serializable;
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
-public class TouchSessionsRequest {
+public class TouchSessionsRequest implements Serializable {
private List sessionIds;
}
diff --git a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/UpdateUserDescriptionRequest.java b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/UpdateUserDescriptionRequest.java
index 6069aa93..58fa757c 100644
--- a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/UpdateUserDescriptionRequest.java
+++ b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/UpdateUserDescriptionRequest.java
@@ -5,11 +5,12 @@
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
+import java.io.Serializable;
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
-public class UpdateUserDescriptionRequest {
+public class UpdateUserDescriptionRequest implements Serializable {
private UserDescription userDescription;
}
diff --git a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/UpdateUserIsOtpOptionalValueRequest.java b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/UpdateUserIsOtpOptionalValueRequest.java
index b6a2c85a..429e4bad 100644
--- a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/UpdateUserIsOtpOptionalValueRequest.java
+++ b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/UpdateUserIsOtpOptionalValueRequest.java
@@ -4,12 +4,13 @@
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
+import java.io.Serializable;
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
-public class UpdateUserIsOtpOptionalValueRequest {
+public class UpdateUserIsOtpOptionalValueRequest implements Serializable {
private String username;
private boolean isOtpOptional;
}
diff --git a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/UpdateUserOtpTypeRequest.java b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/UpdateUserOtpTypeRequest.java
index 8022acf4..a3bed63b 100644
--- a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/UpdateUserOtpTypeRequest.java
+++ b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/UpdateUserOtpTypeRequest.java
@@ -4,12 +4,13 @@
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
+import java.io.Serializable;
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
-public class UpdateUserOtpTypeRequest {
+public class UpdateUserOtpTypeRequest implements Serializable {
private String username;
private String otpType;
}
diff --git a/superfly-remote-api/src/test/java/com/payneteasy/superfly/api/SuperflyDepsAssertionTest.java b/superfly-remote-api/src/test/java/com/payneteasy/superfly/api/SuperflyDepsAssertionTest.java
new file mode 100644
index 00000000..1728cf8b
--- /dev/null
+++ b/superfly-remote-api/src/test/java/com/payneteasy/superfly/api/SuperflyDepsAssertionTest.java
@@ -0,0 +1,27 @@
+package com.payneteasy.superfly.api;
+
+import org.junit.Test;
+
+import static org.junit.Assert.fail;
+
+/**
+ * Защита от случайного возврата observability/cross-cutting зависимостей
+ * в classpath superfly-remote-api.
+ *
+ *
Spring Security публикует {@link AbstractAuthenticationEvent} автоматически после
+ * каждого вызова {@code AuthenticationManager.authenticate()}, поэтому слушатель даёт
+ * единую точку аудита без изменения фильтров или провайдеров.
+ *
+ *
Каждое событие пишется в два канала:
+ *
+ *
{@code log.info/warn} — попадает в обычный application log (видно ops-команде)
+ *
{@link LoggerSink#info} — попадает в отдельный structured audit log
+ * (PCI DSS 10.2.1: требует отдельного, защищённого от изменения audit trail)
+ *
+ *
+ */
+@Component
+public class SecurityAuditApplicationListener implements ApplicationListener {
+
+ private static final Logger log = LoggerFactory.getLogger(SecurityAuditApplicationListener.class);
+
+ private final LoggerSink loggerSink;
+
+ public SecurityAuditApplicationListener(LoggerSink loggerSink) {
+ this.loggerSink = loggerSink;
+ }
+
+ @Override
+ public void onApplicationEvent(AbstractAuthenticationEvent event) {
+ Authentication auth = event.getAuthentication();
+
+ // Аудируем только наши два канала; форм-логин и остальные провайдеры — вне scope
+ if (!isAuditedType(auth)) {
+ return;
+ }
+
+ String eventType = resolveEventType(auth);
+ String identity = extractIdentity(auth);
+ String sourceIp = resolveSourceIp();
+
+ if (event instanceof AuthenticationSuccessEvent) {
+ log.info("Auth success type={} identity={} ip={}", eventType, identity, sourceIp);
+ loggerSink.info(log, eventType, true, identity);
+ } else if (event instanceof AbstractAuthenticationFailureEvent failure) {
+ log.warn("Auth failure type={} identity={} ip={} cause={}",
+ eventType, identity, sourceIp, failure.getException().getMessage());
+ loggerSink.info(log, eventType, false, identity);
+ }
+ }
+
+ private boolean isAuditedType(Authentication auth) {
+ return auth instanceof SubsystemAuthenticationToken
+ || auth instanceof PreAuthenticatedAuthenticationToken;
+ }
+
+ private String resolveEventType(Authentication auth) {
+ if (auth instanceof SubsystemAuthenticationToken) return "SUBSYSTEM_AUTH";
+ if (auth instanceof PreAuthenticatedAuthenticationToken) return "X509_AUTH";
+ return "AUTH";
+ }
+
+ private String extractIdentity(Authentication auth) {
+ if (auth instanceof SubsystemAuthenticationToken) {
+ return "subsystem=" + auth.getName();
+ }
+ // X509: Spring передаёт полный SubjectDN ("CN=billing, O=Acme, C=US") —
+ // логируем только CN, чтобы не утекала оргинформация из сертификата
+ return "cn=" + extractCn(auth.getName());
+ }
+
+ private String extractCn(String dn) {
+ if (dn == null) return "unknown";
+ String upper = dn.toUpperCase();
+ int idx = upper.indexOf("CN=");
+ if (idx < 0) return dn;
+ int end = dn.indexOf(',', idx);
+ String cn = end < 0 ? dn.substring(idx + 3) : dn.substring(idx + 3, end);
+ return cn.trim();
+ }
+
+ private String resolveSourceIp() {
+ try {
+ // RequestContextHolder заполнен только в request-потоке (не в async/scheduled)
+ ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
+ return attrs != null ? attrs.getRequest().getRemoteAddr() : "unknown";
+ } catch (Exception e) {
+ return "unknown";
+ }
+ }
+}
diff --git a/superfly-web/src/main/java/com/payneteasy/superfly/web/security/SubsystemAuthenticationConverter.java b/superfly-web/src/main/java/com/payneteasy/superfly/web/security/SubsystemAuthenticationConverter.java
index 3bebfcf9..9cd66416 100644
--- a/superfly-web/src/main/java/com/payneteasy/superfly/web/security/SubsystemAuthenticationConverter.java
+++ b/superfly-web/src/main/java/com/payneteasy/superfly/web/security/SubsystemAuthenticationConverter.java
@@ -1,71 +1,42 @@
package com.payneteasy.superfly.web.security;
-import com.payneteasy.superfly.model.ui.subsystem.UISubsystem;
-import com.payneteasy.superfly.security.exception.InsufficientAuthenticationException;
-import com.payneteasy.superfly.service.SubsystemService;
-import com.payneteasy.superfly.web.security.exception.SubsystemNotAllowedHostException;
-import com.payneteasy.superfly.web.security.exception.SubsystemNotFoundException;
import jakarta.servlet.http.HttpServletRequest;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.AuthenticationConverter;
-import java.net.URI;
-
public class SubsystemAuthenticationConverter implements AuthenticationConverter {
- private final SubsystemService subsystemService;
-
- public SubsystemAuthenticationConverter(SubsystemService subsystemService) {
- this.subsystemService = subsystemService;
- }
+ private static final Logger log = LoggerFactory.getLogger(SubsystemAuthenticationConverter.class);
@Override
public Authentication convert(HttpServletRequest request) {
String subsystemName = request.getHeader("X-Subsystem-Name");
String subsystemToken = request.getHeader("X-Subsystem-Token");
- if (subsystemName == null || subsystemToken == null) {
+ if (subsystemName == null || subsystemName.isBlank()
+ || subsystemToken == null || subsystemToken.isBlank()) {
+ log.debug("Subsystem headers missing or blank, skipping subsystem auth");
return null;
}
- UISubsystem subsystem = subsystemService.getSubsystemByName(subsystemName);
- if (subsystem == null) {
- throw new SubsystemNotFoundException("Subsystem '" + subsystemName + "' not found");
- }
-
- if (!subsystem.getSubsystemToken().equals(subsystemToken)) {
- throw new SubsystemNotAllowedHostException("Not allowed for your subsystem");
+ if (containsControlChars(subsystemName) || containsControlChars(subsystemToken)) {
+ log.warn("Subsystem headers contain control characters, rejecting");
+ return null;
}
- // checkRequestHost(request, subsystemName);
+ log.debug("Subsystem auth requested for subsystem={}", subsystemName);
return new SubsystemAuthenticationToken(subsystemName, subsystemToken);
}
- private void checkRequestHost(HttpServletRequest request, UISubsystem subsystem) {
-
- String landingUrl = subsystem.getLandingUrl();
- String host = getClientHost(request);
-
- if (!isHostAllowed(landingUrl, host)) {
- throw new SubsystemNotAllowedHostException("Host not allowed for your subsystem");
- }
- }
-
- private String getClientHost(HttpServletRequest request) {
- String host = request.getHeader("X-Forwarded-Host");
- return host != null ? host : request.getHeader("Host");
- }
-
- private boolean isHostAllowed(String landingUrl, String host) {
- if (landingUrl == null || host == null) {
- return false;
- }
- try {
- URI landingUri = new URI(landingUrl);
- URI refererUri = new URI(host);
- return landingUri.getHost().equals(refererUri.getHost());
- } catch (Exception e) {
- return false;
+ private static boolean containsControlChars(String value) {
+ for (int i = 0; i < value.length(); i++) {
+ char c = value.charAt(i);
+ if (c == '\r' || c == '\n' || c == '\t' || c < 0x20) {
+ return true;
+ }
}
+ return false;
}
}
diff --git a/superfly-web/src/main/java/com/payneteasy/superfly/web/security/SubsystemAuthenticationFilter.java b/superfly-web/src/main/java/com/payneteasy/superfly/web/security/SubsystemAuthenticationFilter.java
index 9e87c283..2c2ec2be 100644
--- a/superfly-web/src/main/java/com/payneteasy/superfly/web/security/SubsystemAuthenticationFilter.java
+++ b/superfly-web/src/main/java/com/payneteasy/superfly/web/security/SubsystemAuthenticationFilter.java
@@ -1,7 +1,5 @@
package com.payneteasy.superfly.web.security;
-import com.payneteasy.superfly.service.SubsystemService;
-import com.payneteasy.superfly.web.security.handler.JsonAuthenticationFailureHandler;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.web.authentication.AuthenticationFilter;
import org.springframework.security.web.util.matcher.RequestMatcher;
@@ -10,10 +8,9 @@ public class SubsystemAuthenticationFilter extends AuthenticationFilter {
public SubsystemAuthenticationFilter(
RequestMatcher requiresAuthenticationRequestMatcher,
- AuthenticationManager authenticationManager,
- SubsystemService subsystemService
+ AuthenticationManager authenticationManager
) {
- super(authenticationManager, new SubsystemAuthenticationConverter(subsystemService));
+ super(authenticationManager, new SubsystemAuthenticationConverter());
setRequestMatcher(requiresAuthenticationRequestMatcher);
}
}
diff --git a/superfly-web/src/main/java/com/payneteasy/superfly/web/security/SubsystemAuthenticationProvider.java b/superfly-web/src/main/java/com/payneteasy/superfly/web/security/SubsystemAuthenticationProvider.java
index df1c7617..4eee22ee 100644
--- a/superfly-web/src/main/java/com/payneteasy/superfly/web/security/SubsystemAuthenticationProvider.java
+++ b/superfly-web/src/main/java/com/payneteasy/superfly/web/security/SubsystemAuthenticationProvider.java
@@ -1,16 +1,23 @@
package com.payneteasy.superfly.web.security;
import lombok.extern.slf4j.Slf4j;
+import org.springframework.security.authentication.AccountStatusUserDetailsChecker;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
+import org.springframework.security.core.userdetails.UserDetailsChecker;
import org.springframework.security.core.userdetails.UserDetailsService;
+import org.springframework.security.core.userdetails.UsernameNotFoundException;
+
+import java.security.MessageDigest;
+import java.nio.charset.StandardCharsets;
@Slf4j
public class SubsystemAuthenticationProvider implements AuthenticationProvider {
private final UserDetailsService subsystemDetailsService;
+ private final UserDetailsChecker userDetailsChecker = new AccountStatusUserDetailsChecker();
public SubsystemAuthenticationProvider(UserDetailsService subsystemDetailsService) {
this.subsystemDetailsService = subsystemDetailsService;
@@ -21,28 +28,49 @@ public Authentication authenticate(Authentication authentication) {
if (!supports(authentication.getClass())) {
return null;
}
- log.debug("Subsystem authentication request: {}", authentication);
+ log.debug("Subsystem authentication request for principal={}", sanitize(authentication.getPrincipal()));
+
if (authentication.getPrincipal() == null) {
- log.debug("No subsystem principal found in request.");
+ log.warn("Subsystem auth rejected: no principal in request");
throw new BadCredentialsException("No subsystem principal found in request.");
}
if (authentication.getCredentials() == null) {
- log.debug("No subsystem token found in request.");
+ log.warn("Subsystem auth rejected: no token in request");
throw new BadCredentialsException("No subsystem token found in request.");
}
String subsystemName = (String) authentication.getPrincipal();
String credentials = (String) authentication.getCredentials();
- UserDetails userDetails = subsystemDetailsService.loadUserByUsername(subsystemName);
+ if (credentials.isEmpty()) {
+ log.warn("Subsystem auth rejected: empty token in request");
+ throw new BadCredentialsException("No subsystem token found in request.");
+ }
+
+ log.debug("Loading subsystem details for subsystem={}", sanitize(subsystemName));
+ UserDetails userDetails;
+ try {
+ userDetails = subsystemDetailsService.loadUserByUsername(subsystemName);
+ } catch (UsernameNotFoundException e) {
+ // Намеренно не различаем "не найден" и "неверный токен" — предотвращаем enumeration subsystem-ов
+ log.warn("Subsystem auth rejected: invalid token for subsystem={}", sanitize(subsystemName));
+ throw new BadCredentialsException("Invalid subsystem token");
+ }
+
+ userDetailsChecker.check(userDetails);
- if (!userDetails.getPassword().equals(credentials)) {
+ String storedToken = userDetails.getPassword();
+ if (storedToken == null || storedToken.isEmpty() || !MessageDigest.isEqual(
+ storedToken.getBytes(StandardCharsets.UTF_8),
+ credentials.getBytes(StandardCharsets.UTF_8))) {
+ log.warn("Subsystem auth rejected: invalid token for subsystem={}", sanitize(subsystemName));
throw new BadCredentialsException("Invalid subsystem token");
}
+ log.info("Subsystem auth granted for subsystem={}", sanitize(subsystemName));
return new SubsystemAuthenticationToken(
- userDetails,
- authentication.getCredentials(),
+ subsystemName,
+ null,
userDetails.getAuthorities()
);
}
@@ -51,4 +79,9 @@ public Authentication authenticate(Authentication authentication) {
public boolean supports(Class> authentication) {
return SubsystemAuthenticationToken.class.isAssignableFrom(authentication);
}
+
+ private static String sanitize(Object value) {
+ if (value == null) return null;
+ return value.toString().replaceAll("[\\r\\n\\t]", "_");
+ }
}
diff --git a/superfly-web/src/main/java/com/payneteasy/superfly/web/security/securehandler/BasicAuthorizationParser.java b/superfly-web/src/main/java/com/payneteasy/superfly/web/security/securehandler/BasicAuthorizationParser.java
index 4ec877ee..c00d5720 100644
--- a/superfly-web/src/main/java/com/payneteasy/superfly/web/security/securehandler/BasicAuthorizationParser.java
+++ b/superfly-web/src/main/java/com/payneteasy/superfly/web/security/securehandler/BasicAuthorizationParser.java
@@ -1,8 +1,7 @@
package com.payneteasy.superfly.web.security.securehandler;
import java.nio.charset.Charset;
-
-import static org.apache.commons.ssl.Base64.decodeBase64;
+import java.util.Base64;
public class BasicAuthorizationParser implements IAuthorizationParser {
@@ -18,7 +17,7 @@ public AuthorizationBearer parse(String aText) throws AuthorizationException {
String base64 = tokenizer.next("base64");
- Tokenizer userPassTokenizer = new Tokenizer(new String(decodeBase64(base64), Charset.defaultCharset()), ":");
+ Tokenizer userPassTokenizer = new Tokenizer(new String(Base64.getDecoder().decode(base64), Charset.defaultCharset()), ":");
return new AuthorizationBearer(
userPassTokenizer.next("user")
, userPassTokenizer.next("password")
diff --git a/superfly-web/src/main/java/com/payneteasy/superfly/web/spring/QuartzConfig.java b/superfly-web/src/main/java/com/payneteasy/superfly/web/spring/QuartzConfig.java
deleted file mode 100644
index 8e54a6e2..00000000
--- a/superfly-web/src/main/java/com/payneteasy/superfly/web/spring/QuartzConfig.java
+++ /dev/null
@@ -1,149 +0,0 @@
-package com.payneteasy.superfly.web.spring;
-
-import com.payneteasy.superfly.service.SessionService;
-import com.payneteasy.superfly.service.UserService;
-import org.quartz.JobDetail;
-import org.quartz.SimpleTrigger;
-import org.quartz.Trigger;
-import org.springframework.beans.factory.annotation.Qualifier;
-import org.springframework.beans.factory.annotation.Value;
-import org.springframework.context.annotation.Bean;
-import org.springframework.context.annotation.Configuration;
-import org.springframework.scheduling.quartz.MethodInvokingJobDetailFactoryBean;
-import org.springframework.scheduling.quartz.SchedulerFactoryBean;
-import org.springframework.scheduling.quartz.SimpleTriggerFactoryBean;
-
-@Configuration
-//@EnableScheduling // Если планируется использовать и @Scheduled
-public class QuartzConfig {
-
- // region Job Details
- @Bean
- public MethodInvokingJobDetailFactoryBean expireSessionsJobDetail(SessionService sessionService) {
- return createJobDetail(sessionService, "deleteExpiredAndOldSessionsAndNotify", 86400);
- }
-
- @Bean
- public MethodInvokingJobDetailFactoryBean expirePasswordJobDetail(UserService userService) {
- return createJobDetail(userService, "expirePasswords", 90);
- }
-
- @Bean
- public MethodInvokingJobDetailFactoryBean suspendUsersJobDetail(UserService userService) {
- return createJobDetail(userService, "suspendUsers", 90);
- }
-
- @Bean
- public MethodInvokingJobDetailFactoryBean expireSSOSessionsJobDetail(
- SessionService sessionService,
- @Value("#{contextParameters['superfly-max-sso-session-age-minutes']}") int ssoSessionAge) {
-
- MethodInvokingJobDetailFactoryBean jobDetail = new MethodInvokingJobDetailFactoryBean();
- jobDetail.setTargetObject(sessionService);
- jobDetail.setTargetMethod("deleteExpiredSSOSessions");
- jobDetail.setArguments(new Object[]{ssoSessionAge});
- return jobDetail;
- }
-
- @Bean
- public MethodInvokingJobDetailFactoryBean expireTokensJobDetail(SessionService sessionService) {
- return createJobDetail(sessionService, "deleteExpiredTokens", 5);
- }
- // endregion
-
- // region Triggers
- @Bean
- public SimpleTriggerFactoryBean expireSessionsTrigger(
- @Qualifier("expireSessionsJobDetail") JobDetail jobDetail) {
-
- return createTrigger(jobDetail, 60_000); // 1 minute
- }
-
- @Bean
- public SimpleTriggerFactoryBean expirePasswordsTrigger(
- @Qualifier("expirePasswordJobDetail") JobDetail jobDetail) {
-
- return createTrigger(jobDetail, 86_400_000); // 24 hours
- }
-
- @Bean
- public SimpleTriggerFactoryBean suspendUsersTrigger(
- @Qualifier("suspendUsersJobDetail") JobDetail jobDetail) {
-
- return createTrigger(jobDetail, 86_400_000); // 24 hours
- }
-
- @Bean
- public SimpleTriggerFactoryBean expireSSOSessionsTrigger(
- @Qualifier("expireSSOSessionsJobDetail") JobDetail jobDetail) {
-
- return createTrigger(jobDetail, 60_000); // 1 minute
- }
-
- @Bean
- public SimpleTriggerFactoryBean expireTokensTrigger(
- @Qualifier("expireTokensJobDetail") JobDetail jobDetail) {
-
- return createTrigger(jobDetail, 60_000); // 1 minute
- }
- // endregion
-
- // region Schedulers
- @Bean
- public Trigger[] triggers(
- @Qualifier("expireSessionsTrigger") Trigger expireSessionsTrigger,
- @Qualifier("expirePasswordsTrigger") Trigger expirePasswordsTrigger,
- @Qualifier("suspendUsersTrigger") Trigger suspendUsersTrigger,
- @Qualifier("expireSSOSessionsTrigger") Trigger expireSSOSessionsTrigger,
- @Qualifier("expireTokensTrigger") Trigger expireTokensTrigger
- ) {
- return new Trigger[]{
- expireSessionsTrigger,
- expirePasswordsTrigger,
- suspendUsersTrigger,
- expireSSOSessionsTrigger,
- expireTokensTrigger
- };
- }
-//
-// @Bean
-// public NotificationSendStrategy sendStrategy(HttpClient httpClient) {
-// SimpleSendStrategy sendStrategy = new SimpleSendStrategy();
-// sendStrategy.setHttpClient(httpClient);
-// return sendStrategy;
-// }
-//
-// @Bean
-// public SchedulerFactoryBean scheduler(Trigger[] triggers) {
-// SchedulerFactoryBean scheduler = new SchedulerFactoryBean();
-// scheduler.setTriggers(triggers);
-// return scheduler;
-// }
-
- @Bean
- public SchedulerFactoryBean persistentScheduler(Trigger[] triggers) {
- SchedulerFactoryBean scheduler = new SchedulerFactoryBean();
- scheduler.setApplicationContextSchedulerContextKey("applicationContext");
- scheduler.setTriggers(triggers);
- return scheduler;
- }
- // endregion
-
- // region Helper Methods
- private MethodInvokingJobDetailFactoryBean createJobDetail(Object target, String method, int arg) {
- MethodInvokingJobDetailFactoryBean jobDetail = new MethodInvokingJobDetailFactoryBean();
- jobDetail.setTargetObject(target);
- jobDetail.setTargetMethod(method);
- jobDetail.setArguments(arg);
- return jobDetail;
- }
-
- private SimpleTriggerFactoryBean createTrigger(JobDetail jobDetail, long interval) {
- SimpleTriggerFactoryBean trigger = new SimpleTriggerFactoryBean();
- trigger.setJobDetail(jobDetail);
- trigger.setRepeatInterval(interval);
- trigger.setRepeatCount(SimpleTrigger.REPEAT_INDEFINITELY);
- return trigger;
- }
- // endregion
-}
diff --git a/superfly-web/src/main/java/com/payneteasy/superfly/web/spring/ScheduledTasksConfig.java b/superfly-web/src/main/java/com/payneteasy/superfly/web/spring/ScheduledTasksConfig.java
new file mode 100644
index 00000000..3a8a3dcc
--- /dev/null
+++ b/superfly-web/src/main/java/com/payneteasy/superfly/web/spring/ScheduledTasksConfig.java
@@ -0,0 +1,90 @@
+package com.payneteasy.superfly.web.spring;
+
+import com.payneteasy.superfly.service.SessionService;
+import com.payneteasy.superfly.service.UserService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.scheduling.annotation.Scheduled;
+
+/**
+ * Background maintenance tasks, previously driven by Quartz
+ * ({@code MethodInvokingJobDetailFactoryBean} + {@code SimpleTrigger}).
+ *
+ *
Migrated to Spring {@link Scheduled} fixed-rate tasks backed by the shared
+ * {@code taskScheduler} bean from {@link TaskSchedulerConfig}, so no Quartz
+ * runtime is required anymore.
+ *
+ *
Intervals and arguments mirror the previous Quartz triggers exactly.
+ */
+@Configuration
+public class ScheduledTasksConfig {
+
+ private static final Logger log = LoggerFactory.getLogger(ScheduledTasksConfig.class);
+
+ /** Repeat intervals (ms), identical to the former Quartz SimpleTrigger values. */
+ private static final long ONE_MINUTE_MS = 60_000L;
+ private static final long ONE_DAY_MS = 86_400_000L;
+
+ /** Method arguments, identical to the former Quartz MethodInvokingJobDetailFactoryBean values. */
+ private static final int EXPIRED_SESSION_AGE_SECONDS = 86_400;
+ private static final int PASSWORD_EXPIRY_DAYS = 90;
+ private static final int SUSPEND_USERS_DAYS = 90;
+ private static final int EXPIRED_TOKEN_AGE_SECONDS = 5;
+
+ private final SessionService sessionService;
+ private final UserService userService;
+ private final int ssoSessionAge;
+
+ public ScheduledTasksConfig(
+ SessionService sessionService,
+ UserService userService,
+ @Value("#{contextParameters['superfly-max-sso-session-age-minutes']}") int ssoSessionAge) {
+ this.sessionService = sessionService;
+ this.userService = userService;
+ this.ssoSessionAge = ssoSessionAge;
+ log.debug("ScheduledTasksConfig initialized (ssoSessionAge={})", ssoSessionAge);
+ }
+
+ @Scheduled(fixedRate = ONE_MINUTE_MS)
+ public void expireSessions() {
+ runQuietly("expireSessions",
+ () -> sessionService.deleteExpiredAndOldSessionsAndNotify(EXPIRED_SESSION_AGE_SECONDS));
+ }
+
+ @Scheduled(fixedRate = ONE_DAY_MS)
+ public void expirePasswords() {
+ runQuietly("expirePasswords", () -> userService.expirePasswords(PASSWORD_EXPIRY_DAYS));
+ }
+
+ @Scheduled(fixedRate = ONE_DAY_MS)
+ public void suspendUsers() {
+ runQuietly("suspendUsers", () -> userService.suspendUsers(SUSPEND_USERS_DAYS));
+ }
+
+ @Scheduled(fixedRate = ONE_MINUTE_MS)
+ public void expireSsoSessions() {
+ runQuietly("expireSsoSessions", () -> sessionService.deleteExpiredSSOSessions(ssoSessionAge));
+ }
+
+ @Scheduled(fixedRate = ONE_MINUTE_MS)
+ public void expireTokens() {
+ runQuietly("expireTokens", () -> sessionService.deleteExpiredTokens(EXPIRED_TOKEN_AGE_SECONDS));
+ }
+
+ /**
+ * Runs a maintenance task, logging entry/exit at DEBUG and swallowing
+ * exceptions so a single failed run never kills the scheduler thread
+ * (Quartz isolated job failures the same way).
+ */
+ private void runQuietly(String taskName, Runnable task) {
+ log.debug("Scheduled task '{}' starting", taskName);
+ try {
+ task.run();
+ log.debug("Scheduled task '{}' finished", taskName);
+ } catch (Exception e) {
+ log.error("Scheduled task '{}' failed", taskName, e);
+ }
+ }
+}
diff --git a/superfly-web/src/main/java/com/payneteasy/superfly/web/spring/SpringRootConfiguration.java b/superfly-web/src/main/java/com/payneteasy/superfly/web/spring/SpringRootConfiguration.java
index f684ca4d..52c704a2 100644
--- a/superfly-web/src/main/java/com/payneteasy/superfly/web/spring/SpringRootConfiguration.java
+++ b/superfly-web/src/main/java/com/payneteasy/superfly/web/spring/SpringRootConfiguration.java
@@ -16,7 +16,8 @@
WebConfig.class,
SpringSecurityConfiguration.class,
AopConfig.class,
- QuartzConfig.class,
+ TaskSchedulerConfig.class,
+ ScheduledTasksConfig.class,
})
@EnableTransactionManagement
public class SpringRootConfiguration {
diff --git a/superfly-web/src/main/java/com/payneteasy/superfly/web/spring/SpringServiceConfiguration.java b/superfly-web/src/main/java/com/payneteasy/superfly/web/spring/SpringServiceConfiguration.java
index 7a3eecdb..35f8e49d 100644
--- a/superfly-web/src/main/java/com/payneteasy/superfly/web/spring/SpringServiceConfiguration.java
+++ b/superfly-web/src/main/java/com/payneteasy/superfly/web/spring/SpringServiceConfiguration.java
@@ -3,6 +3,9 @@
import com.payneteasy.superfly.crypto.CryptoService;
import com.payneteasy.superfly.crypto.CryptoServiceImpl;
import com.payneteasy.superfly.common.SuperflyProperties;
+import com.payneteasy.superfly.hotp.HOTPProviderUtils;
+import com.payneteasy.superfly.hotp.NullHOTPProvider;
+import com.payneteasy.superfly.spi.HOTPProvider;
import lombok.AllArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
@@ -21,4 +24,10 @@ public CryptoService cryptoService() {
properties.cryptoSalt()
);
}
+
+ @Bean
+ public HOTPProvider hotpProvider() {
+ HOTPProvider provider = HOTPProviderUtils.instantiateProvider(false);
+ return provider != null ? provider : new NullHOTPProvider();
+ }
}
diff --git a/superfly-web/src/main/java/com/payneteasy/superfly/web/spring/TaskSchedulerConfig.java b/superfly-web/src/main/java/com/payneteasy/superfly/web/spring/TaskSchedulerConfig.java
new file mode 100644
index 00000000..20477e91
--- /dev/null
+++ b/superfly-web/src/main/java/com/payneteasy/superfly/web/spring/TaskSchedulerConfig.java
@@ -0,0 +1,41 @@
+package com.payneteasy.superfly.web.spring;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.scheduling.TaskScheduler;
+import org.springframework.scheduling.annotation.EnableScheduling;
+import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
+
+/**
+ * Shared {@link TaskScheduler} used by {@code @Scheduled} maintenance tasks
+ * ({@link ScheduledTasksConfig}) and by the notification dispatch path
+ * ({@code DefaultNotifier}).
+ *
+ *
Kept dependency-free on purpose: the scheduler is injected into
+ * {@code DefaultNotifier}, which sits on the {@code sessionService -> notifier}
+ * wiring path. Defining the bean here (rather than on a service-dependent
+ * config) avoids a startup circular reference. {@code @EnableScheduling} picks
+ * up a bean named {@code taskScheduler} automatically.
+ */
+@Configuration
+@EnableScheduling
+public class TaskSchedulerConfig {
+
+ private static final Logger log = LoggerFactory.getLogger(TaskSchedulerConfig.class);
+
+ private static final int POOL_SIZE = 5;
+
+ @Bean
+ public TaskScheduler taskScheduler() {
+ ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
+ scheduler.setPoolSize(POOL_SIZE);
+ scheduler.setThreadNamePrefix("superfly-scheduler-");
+ scheduler.setWaitForTasksToCompleteOnShutdown(true);
+ scheduler.setAwaitTerminationSeconds(10);
+ scheduler.initialize();
+ log.debug("Initialized ThreadPoolTaskScheduler (poolSize={})", POOL_SIZE);
+ return scheduler;
+ }
+}
diff --git a/superfly-web/src/main/java/com/payneteasy/superfly/web/spring/dao/SpringUIDaoConfiguration.java b/superfly-web/src/main/java/com/payneteasy/superfly/web/spring/dao/SpringUIDaoConfiguration.java
index 8f7fbd48..c763a0cd 100644
--- a/superfly-web/src/main/java/com/payneteasy/superfly/web/spring/dao/SpringUIDaoConfiguration.java
+++ b/superfly-web/src/main/java/com/payneteasy/superfly/web/spring/dao/SpringUIDaoConfiguration.java
@@ -109,6 +109,11 @@ public ActionDao getActionDao() {
return createDao(ActionDao.class);
}
+ @Bean
+ public EventDao getEventDao() {
+ return createDao(EventDao.class);
+ }
+
@Bean
public GroupDao getGroupDao() {
return createDao(GroupDao.class);
diff --git a/superfly-web/src/main/java/com/payneteasy/superfly/web/spring/security/SpringSecurityConfiguration.java b/superfly-web/src/main/java/com/payneteasy/superfly/web/spring/security/SpringSecurityConfiguration.java
index 7bdec1f5..179cf63a 100644
--- a/superfly-web/src/main/java/com/payneteasy/superfly/web/spring/security/SpringSecurityConfiguration.java
+++ b/superfly-web/src/main/java/com/payneteasy/superfly/web/spring/security/SpringSecurityConfiguration.java
@@ -2,7 +2,6 @@
import com.payneteasy.superfly.client.ActionDescriptionCollector;
import com.payneteasy.superfly.client.ScanningActionDescriptionCollector;
-import com.payneteasy.superfly.client.XmlActionDescriptionCollector;
import com.payneteasy.superfly.common.SuperflyProperties;
import com.payneteasy.superfly.security.InsufficientAuthenticationHandlingFilter;
import com.payneteasy.superfly.security.MultiStepLoginUrlAuthenticationEntryPoint;
@@ -11,7 +10,6 @@
import com.payneteasy.superfly.security.csrf.CsrfValidator;
import com.payneteasy.superfly.security.csrf.CsrfValidatorImpl;
import com.payneteasy.superfly.service.LoggerSink;
-import com.payneteasy.superfly.service.SubsystemService;
import com.payneteasy.superfly.web.security.LocalNeedOTPToken;
import com.payneteasy.superfly.web.security.SubsystemAuthenticationFilter;
import com.payneteasy.superfly.web.security.SuperflyInitOTPAuthenticationProcessingFilter;
@@ -21,7 +19,6 @@
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
-import org.springframework.core.io.ClassPathResource;
import org.springframework.security.access.AccessDecisionManager;
import org.springframework.security.access.AccessDecisionVoter;
import org.springframework.security.access.annotation.Secured;
@@ -55,18 +52,28 @@ public class SpringSecurityConfiguration {
private final SuperflyProperties properties;
private final LoggerSink loggerSink;
private final AuthenticationManager authenticationManager;
- private final SubsystemService subsystemService;
- public SpringSecurityConfiguration(SuperflyProperties properties, LoggerSink loggerSink, AuthenticationManager authenticationManager, SubsystemService subsystemService) {
+ public SpringSecurityConfiguration(SuperflyProperties properties, LoggerSink loggerSink, AuthenticationManager authenticationManager) {
this.properties = properties;
this.loggerSink = loggerSink;
this.authenticationManager = authenticationManager;
- this.subsystemService = subsystemService;
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.securityMatcher("/**") // Обрабатываем все пути
+ .headers(headers -> headers
+ // X-Content-Type-Options, X-Frame-Options: DENY, HSTS enabled by Spring Security defaults.
+ // CSP: unsafe-inline required for Wicket/jQuery inline scripts; all assets served locally.
+ .contentSecurityPolicy(csp -> csp.policyDirectives(
+ "default-src 'self'; " +
+ "script-src 'self' 'unsafe-inline'; " +
+ "style-src 'self' 'unsafe-inline'; " +
+ "img-src 'self' data:; " +
+ "frame-ancestors 'none'; " +
+ "form-action 'self'"
+ ))
+ )
.authorizeHttpRequests(
auth ->
auth
@@ -79,11 +86,6 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
.permitAll()
.requestMatchers(antPathRequestMatcher("/remoting/sso.service/**"))
.hasAuthority("ROLE_SUBSYSTEM")
- .requestMatchers(
- antPathRequestMatcher("/remoting/oauth2.hessian.service/**"),
- antPathRequestMatcher("/remoting/basic.hessian.service/**")
- )
- .permitAll()
.anyRequest()
.hasAnyAuthority("ROLE_ADMIN", "ROLE_ACTION_TEMP_PASSWORD"))
.exceptionHandling(httpSecurity ->
@@ -96,6 +98,9 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
.logout(logout -> logout
.logoutUrl("/j_spring_security_logout")
.logoutSuccessHandler(logoutSuccessHandler()))
+ // CSRF disabled intentionally: all state-changing REST endpoints use token-based auth
+ // (X-Subsystem-Token or Authorization: Bearer), not cookies. Wicket admin pages are
+ // protected by Wicket's own stateful page-version tokens embedded in action URLs.
.csrf(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
;
@@ -141,8 +146,7 @@ public X509AuthenticationFilter x509AuthenticationFilter() {
public SubsystemAuthenticationFilter subsystemAuthenticationFilter() {
SubsystemAuthenticationFilter filter = new SubsystemAuthenticationFilter(
antPathRequestMatcher("/remoting/sso.service/**"),
- authenticationManager,
- subsystemService
+ authenticationManager
);
filter.setSuccessHandler((request, response, authentication) -> {});
filter.setFailureHandler(new JsonAuthenticationFailureHandler());
@@ -224,10 +228,4 @@ public ActionDescriptionCollector scanningActionDescriptionCollector() {
}
- @Bean
- public ActionDescriptionCollector xmlActionDescriptionCollector() {
- XmlActionDescriptionCollector collector = new XmlActionDescriptionCollector();
- collector.setResource(new ClassPathResource("actions.xml"));
- return collector;
- }
}
diff --git a/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/BaseApplication.java b/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/BaseApplication.java
index 66406d16..cfb2c0d7 100644
--- a/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/BaseApplication.java
+++ b/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/BaseApplication.java
@@ -10,11 +10,10 @@
import org.apache.wicket.request.IRequestMapper;
import org.apache.wicket.request.Request;
import org.apache.wicket.request.Response;
-import org.apache.wicket.request.Url;
import org.apache.wicket.request.mapper.parameter.IPageParametersEncoder;
import org.apache.wicket.request.mapper.parameter.PageParametersEncoder;
import org.apache.wicket.request.mapper.parameter.UrlPathPageParametersEncoder;
-import org.apache.wicket.request.resource.UrlResourceReference;
+import org.apache.wicket.request.resource.ContextRelativeResourceReference;
import org.apache.wicket.settings.DebugSettings;
import org.apache.wicket.spring.injection.annot.SpringComponentInjector;
import org.apache.wicket.util.file.Path;
@@ -52,7 +51,7 @@ protected final void init() {
}
getJavaScriptLibrarySettings().setJQueryReference(
- new UrlResourceReference(Url.parse("https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.0/jquery.min.js"))
+ new ContextRelativeResourceReference("js/jquery-3.7.0.js", false)
);
getComponentInstantiationListeners().add(new SpringComponentInjector(this));
getDebugSettings().setOutputMarkupContainerClassNameStrategy(DebugSettings.ClassOutputStrategy.NONE);
diff --git a/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/SuperflyApplication.java b/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/SuperflyApplication.java
index 5068cb16..0a65870d 100644
--- a/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/SuperflyApplication.java
+++ b/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/SuperflyApplication.java
@@ -42,7 +42,6 @@
import com.payneteasy.superfly.wicket.InterceptionDecisions;
import com.payneteasy.superfly.wicket.PageInterceptingRequestMapper;
import org.apache.wicket.Page;
-import org.apache.wicket.core.request.mapper.CryptoMapper;
import org.apache.wicket.request.IRequestMapper;
import org.apache.wicket.request.Request;
import org.apache.wicket.settings.RequestCycleSettings;
@@ -52,8 +51,8 @@ public class SuperflyApplication extends BaseApplication {
@Override
protected void customInit() {
getSecuritySettings().setAuthorizationStrategy(new SpringSecurityAuthorizationStrategy());
- CryptoMapper requestMapper = new CryptoMapper(getRootRequestMapper(), this);
- setRootRequestMapper(wrapWithInterceptingMapper(requestMapper));
+ // BaseApplication.init() уже создал CryptoMapper, просто оборачиваем его в PageInterceptingRequestMapper
+ setRootRequestMapper(wrapWithInterceptingMapper(getRootRequestMapper()));
mountBookmarkablePageWithPath("/loginbase", LoginPageWithoutHOTP.class);
mountBookmarkablePageWithPath("/login", LoginPasswordStepPage.class);
diff --git a/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/SuperflyApplication.properties b/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/SuperflyApplication.properties
index db285ca2..d7d6cb2c 100644
--- a/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/SuperflyApplication.properties
+++ b/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/SuperflyApplication.properties
@@ -31,6 +31,8 @@ subsystem.edit.callback=Callback Info
subsystem.edit.callback.Required=Callback Info is required
subsystem.edit.send-callbacks=Send callbacks
subsystem.edit.subsystemToken=Token
+subsystem.edit.publicKey=Public key
+subsystem.add.publicKey=Public key
subsystem.edit.subsystemUrl=Subsystem URL
subsystem.edit.subsystemUrl.Required=Subsystem URL is required
subsystem.edit.landingUrl=Landing URL
diff --git a/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/component/otp/GoogleAuthSetupPanel.html b/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/component/otp/GoogleAuthSetupPanel.html
index 9fbc2484..5c19779a 100644
--- a/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/component/otp/GoogleAuthSetupPanel.html
+++ b/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/component/otp/GoogleAuthSetupPanel.html
@@ -1,17 +1,17 @@
-
+
Your secret key for google authenticator:
-
+
-
+
-
+
Learn more about Google
authenticator
diff --git a/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/component/otp/GoogleAuthSetupPanel.java b/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/component/otp/GoogleAuthSetupPanel.java
index c21a973a..be50584d 100644
--- a/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/component/otp/GoogleAuthSetupPanel.java
+++ b/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/component/otp/GoogleAuthSetupPanel.java
@@ -17,7 +17,7 @@
public class GoogleAuthSetupPanel extends Panel {
private static final String TOTP_URI_FORMAT =
- "https://chart.googleapis.com/chart?chs=150x150&chld=M%%7C0&cht=qr&chl=%s";
+ "https://api.qrserver.com/v1/create-qr-code/?size=150x150&data=%s";
private final String username;
private final IModel totpSecret;
diff --git a/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/model/StickyFilters.java b/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/model/StickyFilters.java
index fe8f854e..51f764d2 100644
--- a/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/model/StickyFilters.java
+++ b/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/model/StickyFilters.java
@@ -15,4 +15,5 @@
public class StickyFilters implements Serializable {
private UISubsystemForFilter subsystem;
private String actionNameSubstring = "";
+ private String roleNameSubstring = "";
}
diff --git a/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/page/BasePage.java b/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/page/BasePage.java
index 8bdc622d..1beb8757 100644
--- a/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/page/BasePage.java
+++ b/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/page/BasePage.java
@@ -63,8 +63,6 @@ private void init() {
addNavBarItem("roles", ListRolesPage.class);
addNavBarItem("sessions", ListSessionsPage.class);
addNavBarItem("smtp", ListSmtpServersPage.class);
-
-
}
protected abstract String getTitle();
diff --git a/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/page/role/ListRolesPage.html b/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/page/role/ListRolesPage.html
index afe0fb96..2f1893e3 100644
--- a/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/page/role/ListRolesPage.html
+++ b/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/page/role/ListRolesPage.html
@@ -20,6 +20,9 @@
+