diff --git a/src/main/java/home/gateway/adapters/http/CommandsEndpoint.java b/src/main/java/home/gateway/adapters/http/CommandsEndpoint.java index 22ec30b..714ca35 100644 --- a/src/main/java/home/gateway/adapters/http/CommandsEndpoint.java +++ b/src/main/java/home/gateway/adapters/http/CommandsEndpoint.java @@ -13,6 +13,7 @@ import home.gateway.domain.Setpoint; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; +import java.time.Clock; import java.time.Duration; import java.util.List; import java.util.Objects; @@ -25,6 +26,7 @@ import static home.gateway.adapters.http.GatewayHttpServer.respond; * {@code POST /commands} applies one setpoint change, {@code GET /commands} lists the setpoints * and pending restores. Only the backend may call it (S6): every request needs the shared token. * HTTP status follows the outcome: 200 applied, 422 rejected, 502 failed on the controller side. + * Repeated bad tokens lead to 429 for a while ({@link FailedLoginThrottle}). */ class CommandsEndpoint implements HttpHandler { @@ -45,8 +47,9 @@ class CommandsEndpoint implements HttpHandler { private final Commands commands; private final Snapshot snapshot; private final byte[] token; + private final FailedLoginThrottle throttle; - CommandsEndpoint(Commands commands, Snapshot snapshot, String token) { + CommandsEndpoint(Commands commands, Snapshot snapshot, String token, Clock clock) { if (token == null || token.length() < MIN_TOKEN_LENGTH) { throw new IllegalArgumentException( "command token must be at least " + MIN_TOKEN_LENGTH + " characters"); @@ -54,6 +57,7 @@ class CommandsEndpoint implements HttpHandler { this.commands = commands; this.snapshot = snapshot; this.token = token.getBytes(StandardCharsets.UTF_8); + this.throttle = new FailedLoginThrottle(clock); } @Override @@ -62,7 +66,12 @@ class CommandsEndpoint implements HttpHandler { var method = exchange.getRequestMethod(); if (!exchange.getRequestURI().getPath().equals("/commands")) { respond(exchange, 404, new GatewayHttpServer.Error("not found")); + } else if (throttle.isBlocked()) { + exchange.getResponseHeaders().set("Retry-After", + String.valueOf(FailedLoginThrottle.COOL_DOWN.toSeconds())); + respond(exchange, 429, new GatewayHttpServer.Error("too many failed attempts")); } else if (!authorized(exchange)) { + throttle.recordFailure(); exchange.getResponseHeaders().set("WWW-Authenticate", "Bearer"); respond(exchange, 401, new GatewayHttpServer.Error("unauthorized")); } else if (method.equals("GET")) { diff --git a/src/main/java/home/gateway/adapters/http/FailedLoginThrottle.java b/src/main/java/home/gateway/adapters/http/FailedLoginThrottle.java new file mode 100644 index 0000000..0c1d9f3 --- /dev/null +++ b/src/main/java/home/gateway/adapters/http/FailedLoginThrottle.java @@ -0,0 +1,44 @@ +package home.gateway.adapters.http; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayDeque; +import java.util.Deque; + +/** + * Slows down guessing of the command token: after {@value #MAX_FAILURES} failures within a + * minute, from any source, every request waits out a cool-down before its token is checked. + * One shared counter on purpose: the only legitimate caller is the backend, so there is no + * good client for a lockout to hurt except an attacker's. + */ +final class FailedLoginThrottle { + + static final int MAX_FAILURES = 5; + static final Duration WINDOW = Duration.ofMinutes(1); + static final Duration COOL_DOWN = Duration.ofSeconds(60); + + private final Clock clock; + private final Deque failures = new ArrayDeque<>(); + private Instant blockedUntil = Instant.MIN; + + FailedLoginThrottle(Clock clock) { + this.clock = clock; + } + + synchronized boolean isBlocked() { + return clock.instant().isBefore(blockedUntil); + } + + synchronized void recordFailure() { + var now = clock.instant(); + failures.addLast(now); + while (failures.peekFirst().isBefore(now.minus(WINDOW))) { + failures.removeFirst(); + } + if (failures.size() >= MAX_FAILURES) { + blockedUntil = now.plus(COOL_DOWN); + failures.clear(); + } + } +} diff --git a/src/main/java/home/gateway/adapters/http/GatewayHttpServer.java b/src/main/java/home/gateway/adapters/http/GatewayHttpServer.java index 47249a1..1f36e62 100644 --- a/src/main/java/home/gateway/adapters/http/GatewayHttpServer.java +++ b/src/main/java/home/gateway/adapters/http/GatewayHttpServer.java @@ -59,7 +59,7 @@ public class GatewayHttpServer implements AutoCloseable { * read-only server needs no token; call before {@link #start()}. */ public void serveCommands(Commands commands, String token) { - server.createContext("/commands", new CommandsEndpoint(commands, snapshot, token)); + server.createContext("/commands", new CommandsEndpoint(commands, snapshot, token, clock)); } public void start() { diff --git a/src/test/java/home/gateway/adapters/http/CommandsEndpointTest.java b/src/test/java/home/gateway/adapters/http/CommandsEndpointTest.java index 4c1b792..1629d93 100644 --- a/src/test/java/home/gateway/adapters/http/CommandsEndpointTest.java +++ b/src/test/java/home/gateway/adapters/http/CommandsEndpointTest.java @@ -25,7 +25,9 @@ import java.nio.file.Path; import java.time.Clock; import java.time.Duration; import java.time.Instant; +import java.time.ZoneId; import java.time.ZoneOffset; +import java.util.concurrent.atomic.AtomicReference; import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -42,6 +44,23 @@ class CommandsEndpointTest { private static final Instant T0 = Instant.parse("2026-10-24T10:00:00Z"); private static final Path EXAMPLES = Path.of("src/test/resources/commands"); + private final AtomicReference now = new AtomicReference<>(T0); + private final Clock clock = new Clock() { + @Override + public ZoneOffset getZone() { + return ZoneOffset.UTC; + } + + @Override + public Clock withZone(ZoneId zone) { + return this; + } + + @Override + public Instant instant() { + return now.get(); + } + }; private final ObjectMapper json = new ObjectMapper(); private final HttpClient client = HttpClient.newHttpClient(); private final Snapshot snapshot = new Snapshot(); @@ -72,7 +91,7 @@ class CommandsEndpointTest { @BeforeEach void startServer() throws Exception { - server = new GatewayHttpServer(snapshot, Clock.fixed(T0, ZoneOffset.UTC), 0, Set.of()); + server = new GatewayHttpServer(snapshot, clock, 0, Set.of()); server.serveCommands(commands, BEARER); server.start(); } @@ -245,15 +264,58 @@ class CommandsEndpointTest { @Test void everyMethodNeedsTheToken() throws Exception { for (var method : List.of("GET", "POST", "DELETE")) { - assertEquals(401, send(method, null).statusCode()); - assertEquals(401, send(method, null, "Authorization", "Bearer wrong-token-value-xx").statusCode()); - assertEquals(401, send(method, null, "Authorization", BEARER).statusCode()); - assertEquals(401, send(method, null, "Authorization", "Basic " + BEARER).statusCode()); + assertEquals(401, failedAttempt(method, null)); + assertEquals(401, failedAttempt(method, null, "Authorization", "Bearer wrong-token-value-xx")); + assertEquals(401, failedAttempt(method, null, "Authorization", BEARER)); + assertEquals(401, failedAttempt(method, null, "Authorization", "Basic " + BEARER)); } - assertEquals(401, send("POST", example("request-boost.json")).statusCode()); + assertEquals(401, failedAttempt("POST", example("request-boost.json"))); assertTrue(received.isEmpty()); } + /** A failed request, then enough time passes for the throttle to forget it. */ + private int failedAttempt(String method, String body, String... headers) throws Exception { + var status = send(method, body, headers).statusCode(); + now.updateAndGet(time -> time.plus(Duration.ofMinutes(2))); + return status; + } + + @Test + void fiveFailuresInAMinuteBlockEvenAValidTokenUntilTheCoolDownEnds() throws Exception { + nextResult = new CommandResult(Status.APPLIED, "ok", null); + for (int i = 0; i < 5; i++) { + assertEquals(401, send("GET", null, "Authorization", "Bearer wrong-token-value-xx").statusCode()); + } + + var blocked = authorized("GET", null); + assertEquals(429, blocked.statusCode()); + assertEquals("60", blocked.headers().firstValue("Retry-After").orElseThrow()); + assertEquals(429, authorized("POST", "{\"setpoint\":\"cwu_economy\",\"value\":40}").statusCode()); + assertTrue(received.isEmpty()); + + now.updateAndGet(time -> time.plusSeconds(59)); + assertEquals(429, authorized("GET", null).statusCode()); + now.updateAndGet(time -> time.plusSeconds(2)); + assertEquals(200, authorized("GET", null).statusCode()); + } + + @Test + void failuresSpreadOverMoreThanAMinuteDoNotBlock() throws Exception { + for (int i = 0; i < 10; i++) { + assertEquals(401, send("GET", null).statusCode()); + now.updateAndGet(time -> time.plusSeconds(20)); + } + + assertEquals(200, authorized("GET", null).statusCode()); + } + + @Test + void successfulRequestsNeverCountAsFailures() throws Exception { + for (int i = 0; i < 20; i++) { + assertEquals(200, authorized("GET", null).statusCode()); + } + } + @Test void otherMethodsAre405() throws Exception { var response = authorized("PUT", "{}");