mirror of
https://github.com/esiur/esiur-dotnet.git
synced 2026-07-30 17:30:40 +00:00
295 lines
11 KiB
Markdown
295 lines
11 KiB
Markdown
# Esiur for ASP.NET Core
|
|
|
|
This package supports ASP.NET Core applications targeting .NET 8 or .NET 10.
|
|
|
|
`Esiur.AspNetCore` exposes an Esiur EP server through ASP.NET Core endpoint
|
|
routing and WebSockets. ASP.NET Core owns the application lifetime, while Esiur
|
|
continues to own its session authentication, encryption, and resource
|
|
authorization.
|
|
|
|
The integration does not open Esiur's separate TCP listener. Clients connect to
|
|
the route mapped with `MapEsiur`.
|
|
|
|
## Installation
|
|
|
|
```shell
|
|
dotnet add package Esiur.AspNetCore
|
|
```
|
|
|
|
## Development quick start
|
|
|
|
The following example deliberately enables anonymous Esiur sessions to keep a
|
|
local development service easy to try. Do not expose resources this way unless
|
|
their authorization policy is safe for anonymous callers.
|
|
|
|
```csharp
|
|
using Esiur.AspNetCore;
|
|
|
|
var builder = WebApplication.CreateBuilder(args);
|
|
builder.WebHost.UseUrls("http://localhost:8080");
|
|
|
|
builder.Services.AddEsiur(esiur =>
|
|
{
|
|
esiur
|
|
.AddMemoryStore("sys")
|
|
.AddResource<HelloResource>("sys/service")
|
|
.AllowAnonymous() // Development only.
|
|
.IncludeExceptionMessages(); // Development diagnostics only.
|
|
});
|
|
|
|
var app = builder.Build();
|
|
|
|
var webSockets = new WebSocketOptions
|
|
{
|
|
KeepAliveInterval = TimeSpan.FromSeconds(30),
|
|
};
|
|
webSockets.AllowedOrigins.Add("http://localhost:8080");
|
|
app.UseWebSockets(webSockets);
|
|
|
|
app.MapEsiur("/esiur");
|
|
|
|
await app.RunAsync();
|
|
```
|
|
|
|
```csharp
|
|
using Esiur.Resource;
|
|
|
|
[Resource]
|
|
public partial class HelloResource
|
|
{
|
|
private int count;
|
|
|
|
[Export]
|
|
public string Hello(string name) => $"Hello, {name}!";
|
|
|
|
[Export]
|
|
public int Increment() => Interlocked.Increment(ref count);
|
|
|
|
[Export]
|
|
public int CurrentCount() => Volatile.Read(ref count);
|
|
}
|
|
```
|
|
|
|
`AddEsiur` registers a host-managed Warehouse and EP server. The hosted runtime
|
|
adds the resources, opens the Warehouse during application startup, and closes
|
|
it during graceful shutdown. Shutdown stops admission and drains or aborts active
|
|
WebSockets before resource termination begins. Configuration is validated when
|
|
the host starts.
|
|
|
|
## Browser origin policy
|
|
|
|
Call `UseWebSockets` before the mapped endpoint. The Esiur endpoint accepts
|
|
native clients that omit the HTTP `Origin` header. For browser clients, it
|
|
allows the request's own origin when no Esiur origin list is configured.
|
|
|
|
Once origins are configured, they become the complete browser-origin
|
|
allowlist:
|
|
|
|
```csharp
|
|
builder.Services.AddEsiur(esiur =>
|
|
{
|
|
esiur
|
|
.AddMemoryStore("sys")
|
|
.AddResource<HelloResource>("sys/service")
|
|
.AllowAnonymous() // Development only; use authentication in production.
|
|
.AllowWebSocketOrigins("https://app.example.com");
|
|
});
|
|
```
|
|
|
|
If `WebSocketOptions.AllowedOrigins` is also restricted, add the same origins
|
|
there. Set ASP.NET Core `AllowedHosts` to the service's real host names in
|
|
production; do not leave it as `*`, because implicit same-origin checks rely on
|
|
the validated request host. `AllowAnyWebSocketOrigin` is available for
|
|
exceptional cases, but it should not be the production default.
|
|
|
|
## Production authentication and encryption
|
|
|
|
Register an application authentication provider and authenticated record
|
|
encryption together. `UseAuthentication` and `UseEncryption` register their
|
|
`DefaultName` protocol names with the Warehouse and allow them on the EP server.
|
|
The facade deliberately does not offer protocol aliases: the provider and its
|
|
handshake handler must negotiate the same stable name across every Esiur
|
|
language implementation.
|
|
|
|
```csharp
|
|
using Esiur.AspNetCore;
|
|
using Esiur.Security.Authority.Providers;
|
|
using Esiur.Security.Cryptography;
|
|
|
|
// Load Esiur's precomputed salted password credentials from the application's
|
|
// account store. Do not keep raw passwords in this dictionary.
|
|
var passwordCredentials =
|
|
new Dictionary<(string Domain, string Identity), PasswordHash>();
|
|
|
|
builder.Services.AddEsiur(esiur =>
|
|
{
|
|
esiur
|
|
.AddMemoryStore("sys")
|
|
.AddResource<HelloResource>("sys/service")
|
|
.UsePasswordAuthentication((identity, domain) =>
|
|
passwordCredentials.TryGetValue((domain, identity), out var credential)
|
|
? credential
|
|
: null)
|
|
.UseEncryption(new AesEncryptionProvider())
|
|
.AllowWebSocketOrigins("https://app.example.com")
|
|
.RequireEncryption();
|
|
});
|
|
```
|
|
|
|
`UsePasswordAuthentication` is the simple server shortcut: the callback returns
|
|
a `PasswordHash` for an account or `null` when it is unknown, so applications do
|
|
not need to subclass `PasswordAuthenticationProvider`. The lookup is synchronous
|
|
and should use a memory cache or another non-blocking credential store. Advanced
|
|
protocols and asynchronous backends can still use `UseAuthentication(provider)`,
|
|
`UseAuthentication<TProvider>()`, or the provider factory overload. Each form
|
|
uses the provider's `DefaultName`.
|
|
|
|
The shortcut continues the same challenge-response shape for an unknown
|
|
identity using provider-local random dummy material. The application should
|
|
still make known and unknown lookups take comparable time and apply handshake
|
|
rate limits; the shortcut cannot hide timing differences in the application's
|
|
credential store. Identities and domains are limited to 512 UTF-8 bytes before
|
|
the application callback runs.
|
|
|
|
Create each stored value once during account enrollment with
|
|
`PasswordAuthenticationProvider.CreateCredential(passwordBytes)`, persist its
|
|
hash and salt, then clear and discard `passwordBytes`. Do not regenerate the
|
|
credential inside the authentication callback. The stored hash is a
|
|
password-equivalent verifier: anyone who obtains it can authenticate through
|
|
this protocol without recovering the original password (a pass-the-hash
|
|
attack). Keep it secret like a plaintext password: restrict access, encrypt it
|
|
at rest where appropriate, and never put it in logs or client-visible data.
|
|
|
|
For new production deployments, prefer `PpapAuthenticationProvider`. PPAP uses
|
|
Argon2id as part of its password-derived ML-KEM identity flow. An Argon2id hash
|
|
cannot simply replace `PasswordHash.Hash` here: `password-sha3-v1` fixes the
|
|
wire verifier to SHA3-256 of the password and protocol salt, so changing only
|
|
server-side storage would make clients compute a different value and fail
|
|
authentication. Memory-hard derivation therefore needs a protocol designed for
|
|
it on both peers, such as PPAP.
|
|
|
|
Omitting `AllowAnonymous` makes Esiur authentication mandatory.
|
|
`RequireEncryption` rejects sessions that do not negotiate both authentication
|
|
and an allowed encryption provider.
|
|
|
|
Remote errors contain stable Esiur codes only by default. Development services
|
|
can call `IncludeExceptionMessages()`. Source and stack details require an
|
|
explicit advanced `ConfigureServer` setting and should not be exposed publicly.
|
|
|
|
## Connection and memory limits
|
|
|
|
Esiur applies global and per-IP concurrent-connection and attempt limits. Tune
|
|
them for the deployment, and reduce the per-connection WebSocket send queue if
|
|
the application does not send large values:
|
|
|
|
```csharp
|
|
builder.Services.AddEsiur(esiur => esiur
|
|
.AddMemoryStore("sys")
|
|
.AddResource<HelloResource>("sys/service")
|
|
.UsePasswordAuthentication((identity, domain) =>
|
|
passwordCredentials.TryGetValue((domain, identity), out var credential)
|
|
? credential
|
|
: null)
|
|
.LimitPendingWebSocketSendBytes(2 * 1024 * 1024)
|
|
.LimitTotalPendingWebSocketSendBytes(256L * 1024 * 1024)
|
|
.ConfigureWarehouse(configuration =>
|
|
{
|
|
configuration.Connections.MaximumConnections = 500;
|
|
configuration.Connections.MaximumConnectionsPerIpAddress = 20;
|
|
configuration.Connections.MaximumConnectionAttempts = 2_000;
|
|
}));
|
|
```
|
|
|
|
ASP.NET Core endpoint rate limiting can add a separate handshake policy. A
|
|
handshake limiter does not limit traffic after the WebSocket is established.
|
|
The total pending-send limit is shared by every Esiur WebSocket in the host, so
|
|
the per-connection allowance cannot multiply without bound under many slow
|
|
clients. Exceeding either limit closes the affected connection rather than
|
|
dropping a protocol frame and continuing with a corrupted stream.
|
|
|
|
The mapped endpoint participates in standard ASP.NET Core endpoint conventions:
|
|
|
|
```csharp
|
|
app.MapEsiur("/esiur")
|
|
.RequireAuthorization("EsiurUpgrade")
|
|
.RequireRateLimiting("EsiurHandshakes");
|
|
```
|
|
|
|
ASP.NET Core authorization only gates the HTTP WebSocket upgrade. It does not
|
|
replace Esiur session authentication or Esiur resource authorization.
|
|
|
|
## Reverse proxies
|
|
|
|
When TLS terminates at a trusted reverse proxy, apply forwarded headers before
|
|
`UseWebSockets` and `MapEsiur`. The original scheme and host are needed for
|
|
same-origin validation and correct connection metadata.
|
|
|
|
```csharp
|
|
using Microsoft.AspNetCore.HttpOverrides;
|
|
using System.Net;
|
|
|
|
builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
|
{
|
|
options.ForwardedHeaders =
|
|
ForwardedHeaders.XForwardedFor |
|
|
ForwardedHeaders.XForwardedProto |
|
|
ForwardedHeaders.XForwardedHost;
|
|
|
|
// Trust only the proxy addresses used by this deployment.
|
|
options.KnownProxies.Add(IPAddress.Parse("10.0.0.10"));
|
|
});
|
|
|
|
// After builder.Build():
|
|
app.UseForwardedHeaders();
|
|
app.UseWebSockets();
|
|
app.MapEsiur("/esiur");
|
|
```
|
|
|
|
The proxy must support WebSocket upgrades and supply the corresponding
|
|
`X-Forwarded-*` headers. Do not trust arbitrary proxy addresses.
|
|
|
|
## Connecting from .NET through WebSockets
|
|
|
|
Set `EpConnectionContext.WebSocketUri` to the public `ws` or `wss` endpoint,
|
|
including the route mapped by `MapEsiur`:
|
|
|
|
```csharp
|
|
using Esiur.Protocol;
|
|
using Esiur.Resource;
|
|
|
|
var clientWarehouse = new Warehouse();
|
|
|
|
var service = await clientWarehouse.Get<IResource>(
|
|
"ep://api.example.com/sys/service",
|
|
new EpConnectionContext
|
|
{
|
|
WebSocketUri = new Uri("wss://api.example.com/esiur"),
|
|
// Add the authentication mode, identity, and protocol required by the server.
|
|
});
|
|
```
|
|
|
|
Every WebSocket client, including clients in other languages, must request the
|
|
exact, case-sensitive `EP` WebSocket subprotocol (`Sec-WebSocket-Protocol: EP`).
|
|
The Esiur client transports do this automatically.
|
|
|
|
The `ep://` URL identifies the logical EP connection and resource path;
|
|
`WebSocketUri` selects its WebSocket transport and carries the ASP.NET route.
|
|
When using address-bound encryption, use an IP-literal `WebSocketUri`: the .NET
|
|
`ClientWebSocket` API does not expose the endpoint selected for a DNS host, so
|
|
Esiur fails address binding closed instead of recording a separate DNS guess.
|
|
|
|
## Existing Warehouse and server
|
|
|
|
Advanced applications can supply their own instances while still using the
|
|
ASP.NET Core endpoint and host lifecycle:
|
|
|
|
```csharp
|
|
builder.Services.AddEsiur()
|
|
.UseWarehouse(warehouse)
|
|
.UseServer(server, "sys/server");
|
|
```
|
|
|
|
Use `UseWarehouse(warehouse, manageLifecycle: false)` only when another
|
|
component owns the Warehouse lifecycle and the supplied server and resources
|
|
are already attached and initialized.
|