Introduce authentication framework with AuthConfig, AuthGate, and Authenticator classes, alongside comprehensive tests for rules, modes, and schemes.
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
package dev.coph.nextusweb.server.auth;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class AuthConfigTest {
|
||||
|
||||
/** A distinct authenticator instance, identifiable by reference, that authenticates nobody. */
|
||||
private Authenticator marker() {
|
||||
return req -> null;
|
||||
}
|
||||
|
||||
@Test
|
||||
void ruleForReturnsNullWhenUnprotected() {
|
||||
AuthConfig cfg = AuthConfig.builder(marker())
|
||||
.protect("/admin")
|
||||
.build();
|
||||
assertNull(cfg.ruleFor("/public"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void exactPathBeatsPrefix() {
|
||||
Authenticator exactAuth = marker();
|
||||
Authenticator prefixAuth = marker();
|
||||
AuthConfig cfg = AuthConfig.builder(marker())
|
||||
.protect("/api/health", exactAuth)
|
||||
.protectPrefix("/api/", prefixAuth)
|
||||
.build();
|
||||
|
||||
AuthConfig.Rule rule = cfg.ruleFor("/api/health");
|
||||
assertNotNull(rule);
|
||||
assertSame(exactAuth, rule.authenticator());
|
||||
}
|
||||
|
||||
@Test
|
||||
void longerPrefixWins() {
|
||||
Authenticator shortAuth = marker();
|
||||
Authenticator longAuth = marker();
|
||||
AuthConfig cfg = AuthConfig.builder(marker())
|
||||
.protectPrefix("/api/", shortAuth)
|
||||
.protectPrefix("/api/v2/", longAuth)
|
||||
.build();
|
||||
|
||||
assertSame(longAuth, cfg.ruleFor("/api/v2/users").authenticator());
|
||||
assertSame(shortAuth, cfg.ruleFor("/api/v1/users").authenticator());
|
||||
}
|
||||
|
||||
@Test
|
||||
void protectUsesRequiredMode() {
|
||||
AuthConfig cfg = AuthConfig.builder(marker()).protect("/admin").build();
|
||||
assertEquals(AuthConfig.Mode.REQUIRED, cfg.ruleFor("/admin").mode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void optionalUsesOptionalMode() {
|
||||
AuthConfig cfg = AuthConfig.builder(marker()).optional("/feed").build();
|
||||
assertEquals(AuthConfig.Mode.OPTIONAL, cfg.ruleFor("/feed").mode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void requireEverywhereAppliesGlobalRequiredRule() {
|
||||
AuthConfig cfg = AuthConfig.builder(marker())
|
||||
.requireEverywhere()
|
||||
.build();
|
||||
AuthConfig.Rule rule = cfg.ruleFor("/anything");
|
||||
assertNotNull(rule);
|
||||
assertEquals(AuthConfig.Mode.REQUIRED, rule.mode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void optionalEverywhereAppliesGlobalOptionalRule() {
|
||||
AuthConfig cfg = AuthConfig.builder(marker())
|
||||
.optionalEverywhere()
|
||||
.build();
|
||||
AuthConfig.Rule rule = cfg.ruleFor("/anything");
|
||||
assertNotNull(rule);
|
||||
assertEquals(AuthConfig.Mode.OPTIONAL, rule.mode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void specificRuleBeatsGlobal() {
|
||||
Authenticator specific = marker();
|
||||
AuthConfig cfg = AuthConfig.builder(marker())
|
||||
.optionalEverywhere()
|
||||
.protect("/admin", specific)
|
||||
.build();
|
||||
|
||||
AuthConfig.Rule adminRule = cfg.ruleFor("/admin");
|
||||
assertEquals(AuthConfig.Mode.REQUIRED, adminRule.mode());
|
||||
assertSame(specific, adminRule.authenticator());
|
||||
// Everything else still falls through to the optional global rule.
|
||||
assertEquals(AuthConfig.Mode.OPTIONAL, cfg.ruleFor("/other").mode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void defaultAuthenticatorUsedWhenNotOverridden() {
|
||||
Authenticator def = marker();
|
||||
AuthConfig cfg = AuthConfig.builder(def)
|
||||
.protect("/admin")
|
||||
.protectPrefix("/api/")
|
||||
.build();
|
||||
assertSame(def, cfg.ruleFor("/admin").authenticator());
|
||||
assertSame(def, cfg.ruleFor("/api/x").authenticator());
|
||||
}
|
||||
|
||||
@Test
|
||||
void challengeIsStored() {
|
||||
AuthConfig cfg = AuthConfig.builder(marker())
|
||||
.protect("/admin")
|
||||
.challenge("Basic realm=\"api\"")
|
||||
.build();
|
||||
assertEquals("Basic realm=\"api\"", cfg.challenge());
|
||||
}
|
||||
|
||||
@Test
|
||||
void challengeDefaultsToNull() {
|
||||
AuthConfig cfg = AuthConfig.builder(marker()).build();
|
||||
assertNull(cfg.challenge());
|
||||
}
|
||||
|
||||
@Test
|
||||
void builderRejectsNullDefaultAuthenticator() {
|
||||
assertThrows(NullPointerException.class, () -> AuthConfig.builder(null));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package dev.coph.nextusweb.server.auth;
|
||||
|
||||
import dev.coph.nextusweb.server.router.Request;
|
||||
import dev.coph.nextusweb.server.router.Response;
|
||||
import io.netty.buffer.Unpooled;
|
||||
import io.netty.handler.codec.http.DefaultFullHttpRequest;
|
||||
import io.netty.handler.codec.http.FullHttpRequest;
|
||||
import io.netty.handler.codec.http.HttpMethod;
|
||||
import io.netty.handler.codec.http.HttpVersion;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class AuthGateTest {
|
||||
|
||||
private Request request(String apiKey) {
|
||||
FullHttpRequest raw = new DefaultFullHttpRequest(
|
||||
HttpVersion.HTTP_1_1, HttpMethod.GET, "/", Unpooled.EMPTY_BUFFER);
|
||||
if (apiKey != null) raw.headers().set("X-API-Key", apiKey);
|
||||
return new Request(raw, Map.of());
|
||||
}
|
||||
|
||||
private Authenticator apiKeyAuth() {
|
||||
return Authenticator.apiKey("X-API-Key",
|
||||
key -> key.equals("valid") ? Principal.of("user-1") : null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unprotectedPathProceedsWithoutPrincipal() {
|
||||
AuthGate gate = new AuthGate(AuthConfig.builder(apiKeyAuth())
|
||||
.protectPrefix("/admin")
|
||||
.build());
|
||||
Request req = request(null);
|
||||
assertNull(gate.authenticate(req, "/public"));
|
||||
assertNull(req.principal());
|
||||
}
|
||||
|
||||
@Test
|
||||
void protectedPathRejectsMissingCredentials() {
|
||||
AuthGate gate = new AuthGate(AuthConfig.builder(apiKeyAuth())
|
||||
.protectPrefix("/admin")
|
||||
.build());
|
||||
Response rejection = gate.authenticate(request(null), "/admin/users");
|
||||
assertNotNull(rejection);
|
||||
assertEquals(401, rejection.status());
|
||||
}
|
||||
|
||||
@Test
|
||||
void protectedPathRejectsInvalidCredentials() {
|
||||
AuthGate gate = new AuthGate(AuthConfig.builder(apiKeyAuth())
|
||||
.protect("/admin")
|
||||
.build());
|
||||
Response rejection = gate.authenticate(request("wrong"), "/admin");
|
||||
assertNotNull(rejection);
|
||||
assertEquals(401, rejection.status());
|
||||
}
|
||||
|
||||
@Test
|
||||
void protectedPathAttachesPrincipalOnSuccess() {
|
||||
AuthGate gate = new AuthGate(AuthConfig.builder(apiKeyAuth())
|
||||
.protect("/admin")
|
||||
.build());
|
||||
Request req = request("valid");
|
||||
assertNull(gate.authenticate(req, "/admin"));
|
||||
assertNotNull(req.principal());
|
||||
assertEquals("user-1", req.principal().id());
|
||||
}
|
||||
|
||||
@Test
|
||||
void optionalPathProceedsAnonymouslyButAttachesWhenPresent() {
|
||||
AuthGate gate = new AuthGate(AuthConfig.builder(apiKeyAuth())
|
||||
.optionalPrefix("/feed")
|
||||
.build());
|
||||
|
||||
Request anon = request(null);
|
||||
assertNull(gate.authenticate(anon, "/feed"));
|
||||
assertNull(anon.principal());
|
||||
|
||||
Request authed = request("valid");
|
||||
assertNull(gate.authenticate(authed, "/feed"));
|
||||
assertEquals("user-1", authed.principal().id());
|
||||
}
|
||||
|
||||
@Test
|
||||
void challengeHeaderAddedToUnauthorized() {
|
||||
AuthGate gate = new AuthGate(AuthConfig.builder(apiKeyAuth())
|
||||
.protect("/admin")
|
||||
.challenge("ApiKey realm=\"api\"")
|
||||
.build());
|
||||
Response rejection = gate.authenticate(request(null), "/admin");
|
||||
assertEquals("ApiKey realm=\"api\"", rejection.headers().get("WWW-Authenticate"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void authenticatorErrorYields500() {
|
||||
Authenticator boom = req -> {
|
||||
throw new IllegalStateException("db down");
|
||||
};
|
||||
AuthGate gate = new AuthGate(AuthConfig.builder(boom).protect("/admin").build());
|
||||
Response rejection = gate.authenticate(request(null), "/admin");
|
||||
assertNotNull(rejection);
|
||||
assertEquals(500, rejection.status());
|
||||
}
|
||||
|
||||
@Test
|
||||
void exactPathAuthenticatorIsUsedOverPrefix() {
|
||||
// The prefix authenticator never authenticates; the exact-path one accepts the "valid"
|
||||
// key. The exact rule must win so the request on the exact path can succeed.
|
||||
Authenticator prefixDeny = req -> null;
|
||||
AuthGate gate = new AuthGate(AuthConfig.builder(apiKeyAuth())
|
||||
.protect("/api/health", apiKeyAuth())
|
||||
.protectPrefix("/api/", prefixDeny)
|
||||
.build());
|
||||
|
||||
Request exact = request("valid");
|
||||
assertNull(gate.authenticate(exact, "/api/health"));
|
||||
assertEquals("user-1", exact.principal().id());
|
||||
|
||||
// A sibling path under the prefix uses the (always-denying) prefix authenticator.
|
||||
Response rejection = gate.authenticate(request("valid"), "/api/other");
|
||||
assertNotNull(rejection);
|
||||
assertEquals(401, rejection.status());
|
||||
}
|
||||
|
||||
@Test
|
||||
void requireEverywhereRejectsAnyPathWithoutCredentials() {
|
||||
AuthGate gate = new AuthGate(AuthConfig.builder(apiKeyAuth())
|
||||
.requireEverywhere()
|
||||
.build());
|
||||
assertEquals(401, gate.authenticate(request(null), "/whatever").status());
|
||||
assertNull(gate.authenticate(request("valid"), "/whatever"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void anyOfAuthenticatorAcceptsEitherCredential() {
|
||||
Authenticator combined = Authenticator.anyOf(
|
||||
apiKeyAuth(),
|
||||
Authenticator.cookie("sid", s -> s.equals("sess") ? Principal.of("cookie-user") : null));
|
||||
AuthGate gate = new AuthGate(AuthConfig.builder(combined).protect("/admin").build());
|
||||
|
||||
Request viaKey = request("valid");
|
||||
assertNull(gate.authenticate(viaKey, "/admin"));
|
||||
assertEquals("user-1", viaKey.principal().id());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package dev.coph.nextusweb.server.auth;
|
||||
|
||||
import dev.coph.nextusweb.server.router.Request;
|
||||
import io.netty.buffer.Unpooled;
|
||||
import io.netty.handler.codec.http.DefaultFullHttpRequest;
|
||||
import io.netty.handler.codec.http.FullHttpRequest;
|
||||
import io.netty.handler.codec.http.HttpMethod;
|
||||
import io.netty.handler.codec.http.HttpVersion;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Base64;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class AuthenticatorTest {
|
||||
|
||||
private Request request(String header, String value) {
|
||||
FullHttpRequest raw = new DefaultFullHttpRequest(
|
||||
HttpVersion.HTTP_1_1, HttpMethod.GET, "/", Unpooled.EMPTY_BUFFER);
|
||||
if (header != null) raw.headers().set(header, value);
|
||||
return new Request(raw, Map.of());
|
||||
}
|
||||
|
||||
@Test
|
||||
void apiKeyResolvesViaValidator() throws Exception {
|
||||
Authenticator auth = Authenticator.apiKey("X-API-Key",
|
||||
key -> key.equals("good") ? Principal.of("svc") : null);
|
||||
|
||||
assertEquals("svc", auth.authenticate(request("X-API-Key", "good")).id());
|
||||
assertNull(auth.authenticate(request("X-API-Key", "bad")));
|
||||
assertNull(auth.authenticate(request(null, null)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void cookieResolvesViaValidator() throws Exception {
|
||||
Authenticator auth = Authenticator.cookie("sid",
|
||||
sid -> sid.equals("abc") ? Principal.of("u1") : null);
|
||||
|
||||
assertEquals("u1", auth.authenticate(request("Cookie", "sid=abc")).id());
|
||||
assertNull(auth.authenticate(request("Cookie", "sid=zzz")));
|
||||
assertNull(auth.authenticate(request(null, null)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void basicDecodesCredentials() throws Exception {
|
||||
Authenticator auth = Authenticator.basic(
|
||||
(user, pass) -> user.equals("alice") && pass.equals("s3cret") ? Principal.of(user) : null);
|
||||
|
||||
String header = "Basic " + Base64.getEncoder()
|
||||
.encodeToString("alice:s3cret".getBytes(StandardCharsets.UTF_8));
|
||||
assertEquals("alice", auth.authenticate(request("Authorization", header)).id());
|
||||
|
||||
String wrong = "Basic " + Base64.getEncoder()
|
||||
.encodeToString("alice:nope".getBytes(StandardCharsets.UTF_8));
|
||||
assertNull(auth.authenticate(request("Authorization", wrong)));
|
||||
assertNull(auth.authenticate(request("Authorization", "Basic not-base64!!")));
|
||||
assertNull(auth.authenticate(request(null, null)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void anyOfReturnsFirstMatch() throws Exception {
|
||||
Authenticator key = Authenticator.apiKey("X-API-Key",
|
||||
k -> k.equals("k") ? Principal.of("byKey") : null);
|
||||
Authenticator cookie = Authenticator.cookie("sid",
|
||||
s -> Principal.of("byCookie"));
|
||||
Authenticator combined = Authenticator.anyOf(key, cookie);
|
||||
|
||||
assertEquals("byKey", combined.authenticate(request("X-API-Key", "k")).id());
|
||||
assertEquals("byCookie", combined.authenticate(request("Cookie", "sid=x")).id());
|
||||
assertNull(combined.authenticate(request(null, null)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void bearerDecodesToken() throws Exception {
|
||||
Authenticator auth = Authenticator.bearer(
|
||||
token -> token.equals("tok123") ? Principal.of("u") : null);
|
||||
|
||||
assertEquals("u", auth.authenticate(request("Authorization", "Bearer tok123")).id());
|
||||
assertNull(auth.authenticate(request("Authorization", "Bearer wrong")));
|
||||
assertNull(auth.authenticate(request("Authorization", "Bearer ")));
|
||||
assertNull(auth.authenticate(request("Authorization", "Basic abc")));
|
||||
assertNull(auth.authenticate(request(null, null)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void authSchemesAreCaseInsensitive() throws Exception {
|
||||
Authenticator bearer = Authenticator.bearer(t -> Principal.of("b"));
|
||||
assertEquals("b", bearer.authenticate(request("Authorization", "bearer tok")).id());
|
||||
|
||||
Authenticator basic = Authenticator.basic((u, p) -> Principal.of(u));
|
||||
String header = "basic " + Base64.getEncoder()
|
||||
.encodeToString("alice:pw".getBytes(StandardCharsets.UTF_8));
|
||||
assertEquals("alice", basic.authenticate(request("Authorization", header)).id());
|
||||
}
|
||||
|
||||
@Test
|
||||
void basicReturnsNullWhenNoColon() throws Exception {
|
||||
Authenticator auth = Authenticator.basic((u, p) -> Principal.of(u));
|
||||
String header = "Basic " + Base64.getEncoder()
|
||||
.encodeToString("nocolon".getBytes(StandardCharsets.UTF_8));
|
||||
assertNull(auth.authenticate(request("Authorization", header)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void basicAllowsEmptyPassword() throws Exception {
|
||||
Authenticator auth = Authenticator.basic((u, p) -> Principal.of(u + ":" + p));
|
||||
String header = "Basic " + Base64.getEncoder()
|
||||
.encodeToString("user:".getBytes(StandardCharsets.UTF_8));
|
||||
assertEquals("user:", auth.authenticate(request("Authorization", header)).id());
|
||||
}
|
||||
|
||||
@Test
|
||||
void apiKeyEmptyHeaderTreatedAsAbsent() throws Exception {
|
||||
Authenticator auth = Authenticator.apiKey("X-API-Key", k -> Principal.of("never"));
|
||||
assertNull(auth.authenticate(request("X-API-Key", "")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package dev.coph.nextusweb.server.auth;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class PrincipalTest {
|
||||
|
||||
@Test
|
||||
void ofIdOnlyHasNoRolesOrClaims() {
|
||||
Principal p = Principal.of("user-1");
|
||||
assertEquals("user-1", p.id());
|
||||
assertTrue(p.roles().isEmpty());
|
||||
assertTrue(p.claims().isEmpty());
|
||||
assertFalse(p.hasRole("admin"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void ofWithRolesExposesRolesAndHasRole() {
|
||||
Principal p = Principal.of("user-2", Set.of("admin", "ops"));
|
||||
assertEquals("user-2", p.id());
|
||||
assertEquals(Set.of("admin", "ops"), p.roles());
|
||||
assertTrue(p.hasRole("admin"));
|
||||
assertTrue(p.hasRole("ops"));
|
||||
assertFalse(p.hasRole("guest"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rolesAreDefensivelyCopiedAndImmutable() {
|
||||
Set<String> source = new HashSet<>(Set.of("admin"));
|
||||
Principal p = Principal.of("user-3", source);
|
||||
|
||||
// Mutating the source after construction must not affect the principal.
|
||||
source.add("sneaky");
|
||||
assertEquals(Set.of("admin"), p.roles());
|
||||
|
||||
// The exposed set must be unmodifiable.
|
||||
assertThrows(UnsupportedOperationException.class, () -> p.roles().add("x"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void customImplementationIsSupported() {
|
||||
Principal custom = new Principal() {
|
||||
@Override
|
||||
public String id() {
|
||||
return "svc";
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<String> roles() {
|
||||
return Set.of("service");
|
||||
}
|
||||
};
|
||||
assertEquals("svc", custom.id());
|
||||
assertTrue(custom.hasRole("service"));
|
||||
assertTrue(custom.claims().isEmpty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package dev.coph.nextusweb.server.net;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class ClientIpTest {
|
||||
|
||||
@Test
|
||||
void usesSocketIpWhenNoForwardedHeader() {
|
||||
assertEquals("203.0.113.5",
|
||||
ClientIp.resolve("203.0.113.5", null, TrustedProxies.all()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void ignoresForwardedHeaderWhenPeerNotTrusted() {
|
||||
// A direct (untrusted) client cannot spoof its IP via X-Forwarded-For.
|
||||
assertEquals("203.0.113.5",
|
||||
ClientIp.resolve("203.0.113.5", "1.2.3.4", TrustedProxies.none()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void usesForwardedHeaderWhenPeerTrusted() {
|
||||
TrustedProxies trusted = TrustedProxies.of("10.0.0.0/8");
|
||||
assertEquals("1.2.3.4",
|
||||
ClientIp.resolve("10.0.0.1", "1.2.3.4", trusted));
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsFirstUntrustedHopFromTheRight() {
|
||||
// Chain: realClient, edgeProxy, internalProxy(=peer). Both proxies are trusted, so the
|
||||
// resolved client is the first untrusted entry walking from the right.
|
||||
TrustedProxies trusted = TrustedProxies.of("10.0.0.0/8");
|
||||
String xff = "9.9.9.9, 10.0.0.9, 10.0.0.8";
|
||||
assertEquals("9.9.9.9",
|
||||
ClientIp.resolve("10.0.0.8", xff, trusted));
|
||||
}
|
||||
|
||||
@Test
|
||||
void spoofedLeadingEntriesAreIgnored() {
|
||||
// Attacker prepends a fake hop; since the genuine client hop (8.8.8.8) is the first
|
||||
// untrusted from the right, the forged "1.1.1.1" is never returned.
|
||||
TrustedProxies trusted = TrustedProxies.of("10.0.0.0/8");
|
||||
String xff = "1.1.1.1, 8.8.8.8, 10.0.0.8";
|
||||
assertEquals("8.8.8.8",
|
||||
ClientIp.resolve("10.0.0.8", xff, trusted));
|
||||
}
|
||||
|
||||
@Test
|
||||
void allHopsTrustedFallsBackToLeftmost() {
|
||||
TrustedProxies trusted = TrustedProxies.of("10.0.0.0/8");
|
||||
String xff = "10.0.0.7, 10.0.0.8";
|
||||
assertEquals("10.0.0.7",
|
||||
ClientIp.resolve("10.0.0.8", xff, trusted));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package dev.coph.nextusweb.server.net;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class TrustedProxiesTest {
|
||||
|
||||
@Test
|
||||
void noneTrustsNothing() {
|
||||
TrustedProxies tp = TrustedProxies.none();
|
||||
assertFalse(tp.isTrusted("127.0.0.1"));
|
||||
assertFalse(tp.isTrusted("10.0.0.1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void allTrustsEverything() {
|
||||
TrustedProxies tp = TrustedProxies.all();
|
||||
assertTrue(tp.isTrusted("8.8.8.8"));
|
||||
assertTrue(tp.isTrusted("::1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void matchesIpv4Cidr() {
|
||||
TrustedProxies tp = TrustedProxies.of("10.0.0.0/8");
|
||||
assertTrue(tp.isTrusted("10.1.2.3"));
|
||||
assertTrue(tp.isTrusted("10.255.255.255"));
|
||||
assertFalse(tp.isTrusted("11.0.0.1"));
|
||||
assertFalse(tp.isTrusted("192.168.0.1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void matchesBareHostAsSingleAddress() {
|
||||
TrustedProxies tp = TrustedProxies.of("127.0.0.1");
|
||||
assertTrue(tp.isTrusted("127.0.0.1"));
|
||||
assertFalse(tp.isTrusted("127.0.0.2"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void matchesIpv6Cidr() {
|
||||
TrustedProxies tp = TrustedProxies.of("fd00::/8");
|
||||
assertTrue(tp.isTrusted("fd12:3456::1"));
|
||||
assertFalse(tp.isTrusted("fe80::1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void differentFamilyDoesNotMatch() {
|
||||
TrustedProxies tp = TrustedProxies.of("10.0.0.0/8");
|
||||
assertFalse(tp.isTrusted("::1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidAddressIsNotTrusted() {
|
||||
TrustedProxies tp = TrustedProxies.of("10.0.0.0/8");
|
||||
assertFalse(tp.isTrusted("not-an-ip"));
|
||||
assertFalse(tp.isTrusted(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsInvalidCidr() {
|
||||
assertThrows(IllegalArgumentException.class, () -> TrustedProxies.of("10.0.0.0/40"));
|
||||
assertThrows(IllegalArgumentException.class, () -> TrustedProxies.of("garbage"));
|
||||
}
|
||||
}
|
||||
@@ -1,53 +1,70 @@
|
||||
package dev.coph.nextusweb.server.ratelimit;
|
||||
|
||||
import io.netty.handler.codec.http.DefaultHttpRequest;
|
||||
import dev.coph.nextusweb.server.auth.Principal;
|
||||
import dev.coph.nextusweb.server.router.Request;
|
||||
import io.netty.buffer.Unpooled;
|
||||
import io.netty.handler.codec.http.DefaultFullHttpRequest;
|
||||
import io.netty.handler.codec.http.FullHttpRequest;
|
||||
import io.netty.handler.codec.http.HttpMethod;
|
||||
import io.netty.handler.codec.http.HttpRequest;
|
||||
import io.netty.handler.codec.http.HttpVersion;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class KeyResolverTest {
|
||||
|
||||
private HttpRequest req(String header, String value) {
|
||||
HttpRequest r = new DefaultHttpRequest(HttpVersion.HTTP_1_1, HttpMethod.GET, "/");
|
||||
if (header != null) r.headers().set(header, value);
|
||||
return r;
|
||||
private Request request() {
|
||||
FullHttpRequest raw = new DefaultFullHttpRequest(
|
||||
HttpVersion.HTTP_1_1, HttpMethod.GET, "/", Unpooled.EMPTY_BUFFER);
|
||||
return new Request(raw, Map.of());
|
||||
}
|
||||
|
||||
private Request requestWith(String header, String value) {
|
||||
FullHttpRequest raw = new DefaultFullHttpRequest(
|
||||
HttpVersion.HTTP_1_1, HttpMethod.GET, "/", Unpooled.EMPTY_BUFFER);
|
||||
raw.headers().set(header, value);
|
||||
return new Request(raw, Map.of());
|
||||
}
|
||||
|
||||
@Test
|
||||
void clientIpUsesRemoteWhenNoForwardedHeader() {
|
||||
assertEquals("10.0.0.1", KeyResolver.clientIp().resolve(req(null, null), "10.0.0.1"));
|
||||
void clientIpReturnsResolvedIpVerbatim() {
|
||||
assertEquals("10.0.0.1", KeyResolver.clientIp().resolve(request(), "10.0.0.1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void clientIpUsesForwardedHeaderFirstValue() {
|
||||
HttpRequest r = req("X-Forwarded-For", "1.1.1.1, 2.2.2.2");
|
||||
assertEquals("1.1.1.1", KeyResolver.clientIp().resolve(r, "10.0.0.1"));
|
||||
void headerResolverUsesHeaderValue() {
|
||||
Request r = requestWith("X-API-Key", "secret123");
|
||||
assertEquals("h:secret123", KeyResolver.header("X-API-Key").resolve(r, "10.0.0.1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void clientIpHandlesSingleForwardedValue() {
|
||||
HttpRequest r = req("X-Forwarded-For", "3.3.3.3");
|
||||
assertEquals("3.3.3.3", KeyResolver.clientIp().resolve(r, "10.0.0.1"));
|
||||
void headerResolverFallsBackToClientIp() {
|
||||
assertEquals("ip:10.0.0.1", KeyResolver.header("X-API-Key").resolve(request(), "10.0.0.1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void userOrIpReturnsBearerToken() {
|
||||
HttpRequest r = req("Authorization", "Bearer abc123");
|
||||
assertEquals("u:abc123", KeyResolver.userOrIp().resolve(r, "10.0.0.1"));
|
||||
void cookieResolverUsesCookieValue() {
|
||||
Request r = requestWith("Cookie", "sid=abc; other=x");
|
||||
assertEquals("c:abc", KeyResolver.cookie("sid").resolve(r, "10.0.0.1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void userOrIpFallsBackToClientIp() {
|
||||
HttpRequest r = req(null, null);
|
||||
assertEquals("ip:10.0.0.1", KeyResolver.userOrIp().resolve(r, "10.0.0.1"));
|
||||
void cookieResolverFallsBackToClientIp() {
|
||||
assertEquals("ip:10.0.0.1", KeyResolver.cookie("sid").resolve(request(), "10.0.0.1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void userOrIpIgnoresNonBearerAuth() {
|
||||
HttpRequest r = req("Authorization", "Basic xyz");
|
||||
assertEquals("ip:10.0.0.1", KeyResolver.userOrIp().resolve(r, "10.0.0.1"));
|
||||
void principalResolverUsesPrincipalId() {
|
||||
Request r = request();
|
||||
r.principal(Principal.of("user-42", Set.of("admin")));
|
||||
assertEquals("p:user-42", KeyResolver.principal().resolve(r, "10.0.0.1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void principalResolverFallsBackToClientIpWhenAnonymous() {
|
||||
assertEquals("ip:10.0.0.1", KeyResolver.principal().resolve(request(), "10.0.0.1"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,4 +75,30 @@ class RateLimitConfigTest {
|
||||
assertEquals("global", rules.get(0).name());
|
||||
assertEquals("/x", rules.get(1).name());
|
||||
}
|
||||
|
||||
@Test
|
||||
void allLimitersCollectsEveryDistinctLimiter() {
|
||||
// Distinct concrete instances (non-capturing lambdas would be the same JVM singleton).
|
||||
RateLimiter a = new FixedWindowLimiter(1, 1000);
|
||||
RateLimiter b = new FixedWindowLimiter(1, 1000);
|
||||
RateLimiter c = new FixedWindowLimiter(1, 1000);
|
||||
RateLimitConfig cfg = RateLimitConfig.builder()
|
||||
.global(a, keyer())
|
||||
.forPath("/x", b, keyer())
|
||||
.forPrefix("/api/", c, keyer())
|
||||
.build();
|
||||
assertEquals(3, cfg.allLimiters().size());
|
||||
assertTrue(cfg.allLimiters().containsAll(List.of(a, b, c)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void allLimitersDeduplicatesSharedInstance() {
|
||||
RateLimiter shared = alwaysAllow();
|
||||
RateLimitConfig cfg = RateLimitConfig.builder()
|
||||
.global(shared, keyer())
|
||||
.forPath("/x", shared, keyer())
|
||||
.forPrefix("/api/", shared, keyer())
|
||||
.build();
|
||||
assertEquals(1, cfg.allLimiters().size());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
package dev.coph.nextusweb.server.ratelimit;
|
||||
|
||||
import dev.coph.nextusweb.server.router.Request;
|
||||
import dev.coph.nextusweb.server.router.Response;
|
||||
import io.netty.handler.codec.http.DefaultHttpRequest;
|
||||
import io.netty.buffer.Unpooled;
|
||||
import io.netty.handler.codec.http.DefaultFullHttpRequest;
|
||||
import io.netty.handler.codec.http.FullHttpRequest;
|
||||
import io.netty.handler.codec.http.HttpMethod;
|
||||
import io.netty.handler.codec.http.HttpRequest;
|
||||
import io.netty.handler.codec.http.HttpVersion;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class RateLimitGateTest {
|
||||
|
||||
private HttpRequest req() {
|
||||
return new DefaultHttpRequest(HttpVersion.HTTP_1_1, HttpMethod.GET, "/");
|
||||
private Request req() {
|
||||
FullHttpRequest raw = new DefaultFullHttpRequest(
|
||||
HttpVersion.HTTP_1_1, HttpMethod.GET, "/", Unpooled.EMPTY_BUFFER);
|
||||
return new Request(raw, Map.of());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package dev.coph.nextusweb.server.router;
|
||||
|
||||
import dev.coph.nextusweb.server.auth.Principal;
|
||||
import dev.coph.nextusweb.server.router.exception.BadRequestException;
|
||||
import io.netty.buffer.Unpooled;
|
||||
import io.netty.handler.codec.http.DefaultFullHttpRequest;
|
||||
@@ -101,4 +102,48 @@ class RequestTest {
|
||||
Request req = new Request(build(HttpMethod.POST, "/", "not-json"), Map.of());
|
||||
assertThrows(BadRequestException.class, () -> req.jsonAs(Payload.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void cookieParsesNamedCookie() {
|
||||
FullHttpRequest raw = build(HttpMethod.GET, "/", null);
|
||||
raw.headers().set("Cookie", "sid=abc123; theme=dark");
|
||||
Request req = new Request(raw, Map.of());
|
||||
assertEquals("abc123", req.cookie("sid"));
|
||||
assertEquals("dark", req.cookie("theme"));
|
||||
assertNull(req.cookie("missing"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void cookieReturnsNullWhenNoCookieHeader() {
|
||||
Request req = new Request(build(HttpMethod.GET, "/", null), Map.of());
|
||||
assertNull(req.cookie("sid"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void attributesSetGetAndRemove() {
|
||||
Request req = new Request(build(HttpMethod.GET, "/", null), Map.of());
|
||||
assertNull(req.attribute("k"));
|
||||
req.attribute("k", "v");
|
||||
assertEquals("v", req.<String>attribute("k"));
|
||||
req.attribute("k", null);
|
||||
assertNull(req.attribute("k"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void clientIpRoundTrips() {
|
||||
Request req = new Request(build(HttpMethod.GET, "/", null), Map.of());
|
||||
assertNull(req.clientIp());
|
||||
req.clientIp("203.0.113.9");
|
||||
assertEquals("203.0.113.9", req.clientIp());
|
||||
}
|
||||
|
||||
@Test
|
||||
void principalRoundTripsAndDrivesIsAuthenticated() {
|
||||
Request req = new Request(build(HttpMethod.GET, "/", null), Map.of());
|
||||
assertFalse(req.isAuthenticated());
|
||||
assertNull(req.principal());
|
||||
req.principal(Principal.of("user-7"));
|
||||
assertTrue(req.isAuthenticated());
|
||||
assertEquals("user-7", req.principal().id());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package dev.coph.nextusweb.server.tls;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.File;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class TlsConfigTest {
|
||||
|
||||
@Test
|
||||
void fromPemWithMissingFilesThrowsIllegalState() {
|
||||
File missingCert = new File("does-not-exist-cert.pem");
|
||||
File missingKey = new File("does-not-exist-key.pem");
|
||||
assertThrows(IllegalStateException.class, () -> TlsConfig.fromPem(missingCert, missingKey));
|
||||
}
|
||||
|
||||
@Test
|
||||
void fromSslContextRejectsNull() {
|
||||
assertThrows(NullPointerException.class, () -> TlsConfig.fromSslContext(null));
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ class WebSocketGroupTest {
|
||||
}
|
||||
|
||||
private WebSocketSession session(EmbeddedChannel ch) {
|
||||
return new WebSocketSession(ch, "/ws", Map.of());
|
||||
return new WebSocketSession(ch, "/ws", Map.of(), null);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -13,7 +13,7 @@ class WebSocketHandlerTest {
|
||||
void defaultMethodsDoNotThrow() {
|
||||
WebSocketHandler handler = new WebSocketHandler() {};
|
||||
EmbeddedChannel ch = new EmbeddedChannel();
|
||||
WebSocketSession session = new WebSocketSession(ch, "/ws", Map.of());
|
||||
WebSocketSession session = new WebSocketSession(ch, "/ws", Map.of(), null);
|
||||
assertDoesNotThrow(() -> handler.onOpen(session));
|
||||
assertDoesNotThrow(() -> handler.onMessage(session, "msg"));
|
||||
assertDoesNotThrow(() -> handler.onBinary(session, new byte[]{1}));
|
||||
|
||||
@@ -15,7 +15,7 @@ import static org.junit.jupiter.api.Assertions.*;
|
||||
class WebSocketSessionTest {
|
||||
|
||||
private WebSocketSession session(EmbeddedChannel ch) {
|
||||
return new WebSocketSession(ch, "/ws/{id}", Map.of("id", "42"));
|
||||
return new WebSocketSession(ch, "/ws/{id}", Map.of("id", "42"), null);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
Reference in New Issue
Block a user