Introduce authentication framework with AuthConfig, AuthGate, and Authenticator classes, alongside comprehensive tests for rules, modes, and schemes.
This commit is contained in:
@@ -5,13 +5,16 @@ A lightweight, high-performance HTTP server library built on top of Netty. Nexus
|
||||
## Features
|
||||
|
||||
- **Netty-based** — uses epoll/kqueue/NIO automatically based on the platform
|
||||
- **Virtual thread dispatch** — each request is handled on a Java 21 virtual thread
|
||||
- **Virtual thread dispatch** — each request is handled on a Java virtual thread, with per-connection read backpressure and HTTP keep-alive
|
||||
- **TLS / HTTPS** — enable encryption with a single `withTls(...)` call (PEM files or a custom `SslContext`)
|
||||
- **Pluggable authentication** — insert an auth layer that protects selected paths; API key, cookie, HTTP Basic, bearer or any custom scheme (not tied to bearer tokens)
|
||||
- **Trie-based router** — supports static paths, path parameters (`{id}`), and wildcards (`*`)
|
||||
- **Annotation-based controllers** — define routes declaratively with `@Controller`, `@GET`, `@POST`, etc.
|
||||
- **Middleware chain** — attach cross-cutting logic to all routes
|
||||
- **CORS support** — configurable origins, methods, headers, credentials, and preflight caching
|
||||
- **Rate limiting** — four algorithm implementations with per-IP, per-token, or custom key strategies
|
||||
- **WebSockets** — path-routed handlers with origin validation, idle timeout, frame size limits and permessage-deflate
|
||||
- **Rate limiting** — four algorithm implementations with per-IP, per-header, per-cookie, per-principal or custom key strategies, with automatic eviction of idle state
|
||||
- **Spoofing-safe client IP** — `X-Forwarded-For` is honoured only behind configured trusted proxies
|
||||
- **WebSockets** — path-routed handlers with origin validation, optional authentication, ordered per-connection delivery, backpressure, idle timeout, frame size limits and permessage-deflate
|
||||
- **JSON I/O** — built-in Jackson integration for request parsing and response serialization
|
||||
|
||||
---
|
||||
@@ -166,11 +169,17 @@ req.pathParam("id") // path parameter, e.g. from /users/{id}
|
||||
req.queryParam("search") // first value of ?search=
|
||||
req.queryParams("tag") // all values of ?tag= as List<String>
|
||||
req.header("Authorization") // raw header value
|
||||
req.cookie("sid") // value of a named cookie
|
||||
req.body() // raw body as UTF-8 String
|
||||
req.json() // body parsed as Jackson JsonNode
|
||||
req.jsonAs(MyDto.class) // body deserialized into a POJO
|
||||
req.method() // HttpMethod
|
||||
req.path() // decoded path without query string
|
||||
req.clientIp() // resolved client IP (honours trusted proxies)
|
||||
req.principal() // authenticated principal, or null (see Authentication)
|
||||
req.isAuthenticated() // whether a principal is attached
|
||||
req.attribute("k", value) // attach per-request state
|
||||
req.<T>attribute("k") // read it back
|
||||
```
|
||||
|
||||
`json()` and `jsonAs()` throw `BadRequestException` (→ `400`) on malformed JSON.
|
||||
@@ -214,6 +223,115 @@ Use `CorsConfig.permissive()` for a development preset that allows any origin wi
|
||||
|
||||
---
|
||||
|
||||
## TLS / HTTPS
|
||||
|
||||
Enable encryption by attaching a `TlsConfig`. The TLS handler becomes the first element of every connection's pipeline, so both HTTP and WebSocket traffic are served over TLS (HTTPS / WSS).
|
||||
|
||||
```java
|
||||
import dev.coph.nextusweb.server.tls.TlsConfig;
|
||||
|
||||
HttpServer.builder(443, router)
|
||||
.withTls(TlsConfig.fromPem(
|
||||
new File("fullchain.pem"), // PEM certificate chain
|
||||
new File("privkey.pem"))) // PKCS#8 private key
|
||||
.start();
|
||||
```
|
||||
|
||||
| Factory | Use |
|
||||
|---|---|
|
||||
| `TlsConfig.fromPem(cert, key)` | PEM certificate chain + unencrypted PKCS#8 key |
|
||||
| `TlsConfig.fromPem(cert, key, password)` | …with a password-protected key |
|
||||
| `TlsConfig.fromPem(certStream, keyStream, password)` | Load PEM material from the classpath or another stream |
|
||||
| `TlsConfig.fromSslContext(ctx)` | Full control — supply a Netty `SslContext` (custom ciphers, mutual TLS, …) |
|
||||
|
||||
Any initialisation failure (missing/invalid material) is reported as an `IllegalStateException`.
|
||||
|
||||
---
|
||||
|
||||
## Authentication
|
||||
|
||||
The auth layer authenticates **selected paths** before they reach handlers and attaches a `Principal` to the request (visible to rate limiting, middleware and handlers). It is deliberately **not** tied to bearer tokens — choose any credential scheme.
|
||||
|
||||
```java
|
||||
import dev.coph.nextusweb.server.auth.*;
|
||||
|
||||
// 1. An authenticator turns a credential into a Principal (or null if invalid).
|
||||
Authenticator auth = Authenticator.apiKey("X-API-Key", key ->
|
||||
key.equals(System.getenv("API_KEY")) ? Principal.of("service", Set.of("admin")) : null);
|
||||
|
||||
// 2. Decide which paths it protects.
|
||||
AuthConfig authConfig = AuthConfig.builder(auth)
|
||||
.protectPrefix("/api/") // required: 401 if missing/invalid
|
||||
.optional("/feed") // attach principal if present, never reject
|
||||
.challenge("ApiKey realm=\"api\"")
|
||||
.build();
|
||||
|
||||
HttpServer.builder(8080, router)
|
||||
.withAuth(new AuthGate(authConfig))
|
||||
.start();
|
||||
```
|
||||
|
||||
In a handler:
|
||||
|
||||
```java
|
||||
router.get("/api/me", (req, res) -> {
|
||||
Principal p = req.principal(); // never null on a protected path
|
||||
if (!p.hasRole("admin")) { res.status(403); return; }
|
||||
res.json(Map.of("id", p.id()));
|
||||
});
|
||||
```
|
||||
|
||||
### Authenticators
|
||||
|
||||
| Factory | Credential |
|
||||
|---|---|
|
||||
| `Authenticator.apiKey(header, validator)` | An API key in a request header (e.g. `X-API-Key`) |
|
||||
| `Authenticator.cookie(name, validator)` | A session (or other) cookie |
|
||||
| `Authenticator.basic(validator)` | HTTP Basic `username` / `password` |
|
||||
| `Authenticator.bearer(validator)` | A bearer token (provided for completeness; never required) |
|
||||
| `Authenticator.anyOf(a, b, …)` | Tries each in order, first match wins |
|
||||
| Custom | Implement `Authenticator` — e.g. mutual-TLS cert, HMAC-signed request |
|
||||
|
||||
`validator` returns the resolved `Principal`, or `null` for missing/invalid credentials (→ `401` on a `REQUIRED` path). A thrown exception is treated as an internal error (→ generic `500`); details are logged, never sent to the client. Rate limiting runs **before** authentication, so an unauthenticated flood is shed before reaching a (potentially expensive) authenticator.
|
||||
|
||||
WebSocket upgrades on protected paths are authenticated the same way; the resolved principal is available via `session.principal()`.
|
||||
|
||||
---
|
||||
|
||||
## Trusted proxies & client IP
|
||||
|
||||
`req.clientIp()` and `KeyResolver.clientIp()` return a spoofing-safe client address. By default (`TrustedProxies.none()`) the transport peer address is used and `X-Forwarded-For` is ignored — a directly connected client cannot forge its IP. When running behind a reverse proxy, declare it trusted so the forwarded header is honoured:
|
||||
|
||||
```java
|
||||
import dev.coph.nextusweb.server.net.TrustedProxies;
|
||||
|
||||
HttpServer.builder(8080, router)
|
||||
.withTrustedProxies(TrustedProxies.of("10.0.0.0/8", "127.0.0.1", "::1"))
|
||||
.start();
|
||||
```
|
||||
|
||||
The resolver walks `X-Forwarded-For` from right to left and returns the first hop that is **not** a trusted proxy, so forged left-most entries are ignored. Use `TrustedProxies.all()` only when the server can never be reached except through a trusted proxy.
|
||||
|
||||
---
|
||||
|
||||
## Hardening & limits
|
||||
|
||||
| Concern | How it's handled |
|
||||
|---|---|
|
||||
| Connection reuse | HTTP keep-alive is honoured; connections close only on `Connection: close` or error |
|
||||
| Slow-client / Slowloris | A per-connection read timeout (`httpReadTimeout`, default 30s) closes stalled/idle connections |
|
||||
| Request memory | Auto-read is disabled while a request is in flight (one buffered body per connection); `maxHttpContentLength` (default 1 MiB) caps the body, returning `413` |
|
||||
| Error disclosure | Handler exceptions return a generic `500`; the detail is logged server-side, never sent to the client |
|
||||
|
||||
```java
|
||||
HttpServer.builder(8080, router)
|
||||
.maxHttpContentLength(2 * 1024 * 1024) // 2 MiB body cap
|
||||
.httpReadTimeout(Duration.ofSeconds(20)) // null/zero disables
|
||||
.start();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
### Algorithms
|
||||
@@ -233,8 +351,8 @@ RateLimitConfig config = RateLimitConfig.builder()
|
||||
.global(new TokenBucketLimiter(100, 200), KeyResolver.clientIp())
|
||||
// Stricter rule for a specific path
|
||||
.forPath("/login", new FixedWindowLimiter(5, 60_000), KeyResolver.clientIp())
|
||||
// Rule for an entire path prefix
|
||||
.forPrefix("/api/", new SlidingWindowLimiter(1000, 1000), KeyResolver.userOrIp())
|
||||
// Per-API-key rule for an entire path prefix (no bearer token required)
|
||||
.forPrefix("/api/", new SlidingWindowLimiter(1000, 1000), KeyResolver.header("X-API-Key"))
|
||||
.build();
|
||||
|
||||
RateLimitGate gate = new RateLimitGate(config);
|
||||
@@ -246,6 +364,8 @@ HttpServer.builder(8080, router)
|
||||
|
||||
When the limit is exceeded the server responds with `429 Too Many Requests` and a `Retry-After` header.
|
||||
|
||||
Per-key limiter state is evicted automatically by a background task (every 5 minutes, entries idle for >10 minutes), so a high-cardinality key (many distinct IPs/API keys) cannot grow the limiter maps without bound. Call `gate.shutdown()` when stopping the server.
|
||||
|
||||
### Response headers
|
||||
|
||||
Every response automatically includes:
|
||||
@@ -260,9 +380,13 @@ Every response automatically includes:
|
||||
|
||||
| Factory | Behaviour |
|
||||
|---|---|
|
||||
| `KeyResolver.clientIp()` | Uses `X-Forwarded-For` if present, otherwise the remote IP |
|
||||
| `KeyResolver.userOrIp()` | Uses the Bearer token if present (`u:<token>`), otherwise the client IP (`ip:<addr>`) |
|
||||
| Custom lambda | `(req, remoteAddr) -> myKey(req)` |
|
||||
| `KeyResolver.clientIp()` | The resolved client IP (honours trusted proxies — `X-Forwarded-For` is **not** trusted from a direct client) |
|
||||
| `KeyResolver.header(name)` | Header value (e.g. an API key in `X-API-Key`); falls back to `ip:<addr>` when absent |
|
||||
| `KeyResolver.cookie(name)` | Cookie value (e.g. a session id); falls back to `ip:<addr>` when absent |
|
||||
| `KeyResolver.principal()` | The authenticated principal id (`p:<id>`); falls back to `ip:<addr>` when anonymous (requires the auth layer to run for the path) |
|
||||
| Custom lambda | `(req, clientIp) -> myKey(req)` — `req` is the framework `Request`, `clientIp` the resolved IP |
|
||||
|
||||
> The old `KeyResolver.userOrIp()` (which trusted any client's `X-Forwarded-For` and keyed on a raw bearer token) has been removed. It allowed trivial rate-limit bypass and unbounded key growth; use `header(...)`, `cookie(...)` or `principal()` instead.
|
||||
|
||||
---
|
||||
|
||||
@@ -307,7 +431,8 @@ WebSocketRouter wsRouter = new WebSocketRouter()
|
||||
WebSocketConfig wsConfig = WebSocketConfig.builder()
|
||||
.allowedOrigins("https://app.example.com")
|
||||
.maxFramePayloadLength(64 * 1024) // 64 KiB per frame
|
||||
.maxAggregatedMessageSize(1024 * 1024) // 1 MiB upgrade body cap
|
||||
.maxAggregatedMessageSize(1024 * 1024) // 1 MiB cap on a reassembled (fragmented) message
|
||||
.maxQueuedMessages(1024) // per-connection backlog before backpressure
|
||||
.idleTimeout(Duration.ofSeconds(60)) // close idle peers
|
||||
.subprotocols("chat.v1")
|
||||
.compression(true) // permessage-deflate
|
||||
@@ -327,6 +452,7 @@ session.id(); // stable UUID for this connection
|
||||
session.path(); // matched path
|
||||
session.pathParam("room"); // path parameter, e.g. from /ws/rooms/{room}
|
||||
session.remoteAddress(); // client IP
|
||||
session.principal(); // authenticated principal, or null
|
||||
session.attribute("userId", id); // attach state to the session
|
||||
session.attribute("userId"); // read it back
|
||||
|
||||
@@ -357,7 +483,9 @@ group.broadcastExcept(sessionA, "everyone but A");
|
||||
| Concern | How it's handled |
|
||||
|---|---|
|
||||
| Cross-origin upgrades | `Origin` header validated against `WebSocketConfig.allowedOrigins(...)`; mismatched origins are rejected with `403` |
|
||||
| Memory exhaustion | `maxFramePayloadLength` caps a single frame; `maxAggregatedMessageSize` caps the upgrade request body |
|
||||
| Authentication | When an `AuthGate` is configured, protected upgrade paths are authenticated and the principal is exposed via `session.principal()` |
|
||||
| Memory exhaustion | `maxFramePayloadLength` caps a single frame; `maxQueuedMessages` (default 1024) bounds the per-connection callback backlog and pauses reads (backpressure) when exceeded |
|
||||
| Message ordering | Callbacks for a single connection run **strictly in arrival order** on a per-connection serial drainer (still on virtual threads, so handlers may block) |
|
||||
| Idle / zombie connections | `idleTimeout` triggers a server-side close when no read **and** no write happen within the window |
|
||||
| User code isolation | All callbacks dispatch onto Java virtual threads, never the Netty event loop |
|
||||
| Subprotocol negotiation | Server advertises the configured `subprotocols(...)` list; clients that ask for an unsupported subprotocol fail the handshake |
|
||||
|
||||
Reference in New Issue
Block a user