Skip to content

Commit 8c0cfe4

Browse files
authored
Add anonymous visitor tracking middleware and visitor-aware rate-limit partitioner (#74)
1 parent 63f48cf commit 8c0cfe4

10 files changed

Lines changed: 547 additions & 218 deletions

File tree

docs/concepts/anonymous-visitor.md

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
---
2+
title: Anonymous Visitor Tracking
3+
layout: home
4+
parent: Concepts
5+
nav_order: 800
6+
---
7+
8+
# {{ page.title }}
9+
10+
LightNap apps with anonymous-input features (comments, ratings, public submissions, anonymous analytics, A/B test buckets) need persistent identity for visitors who are not logged in. IP-only identification loses identity on every NAT or VPN hop; rolling your own cookie scheme is error-prone. LightNap ships an opt-in middleware that mints and reads a first-party visitor cookie, then exposes the identifier on the current request.
11+
12+
## What the middleware does
13+
14+
On every request, `AnonymousVisitorIdMiddleware`:
15+
16+
1. Looks for the configured cookie (default name `lna_visitor_id`).
17+
2. If a valid GUID is present, copies it to `HttpContext.Items["AnonymousVisitorId"]`.
18+
3. Otherwise mints a new GUID, sets the cookie on the response, and stores the new value on `HttpContext.Items`.
19+
20+
Two consumers read the item:
21+
22+
- `WebUserContext` resolves `IUserContext.Kind` to `UserContextKind.AnonymousVisitor` when the request is unauthenticated but a visitor ID is set. `GetActorId()` then returns the visitor ID, which downstream code can use for audit, last-modified-by, or partition-key purposes.
23+
- The rate-limit partitioner prefers the visitor ID over the remote IP fallback, so unauthenticated users behind shared NATs do not all share a single bucket.
24+
25+
## When to enable it
26+
27+
Turn it on when your app:
28+
29+
- Accepts anonymous user-generated content (comments, votes, public form submissions).
30+
- Correlates anonymous analytics or experiment buckets across requests.
31+
- Wants per-visitor rate limiting that survives IP changes.
32+
33+
If your app has no anonymous input surface, leave it off. The middleware is not registered by default — consumers that don't need it pay nothing.
34+
35+
## Enabling
36+
37+
In `Program.cs`, after the existing `Authentication` settings are loaded:
38+
39+
```csharp
40+
var anonymousVisitorSettings = builder.Configuration
41+
.GetRequiredSection<AnonymousVisitorSettings>("AnonymousVisitor");
42+
builder.Services.AddLightNapAnonymousVisitorTracking(anonymousVisitorSettings, bootstrapLogger);
43+
```
44+
45+
And in the pipeline, after `UseAuthentication()` and before the endpoints:
46+
47+
```csharp
48+
app.UseLightNapAnonymousVisitorTracking();
49+
```
50+
51+
In `appsettings.json`, add:
52+
53+
```jsonc
54+
"AnonymousVisitor": {
55+
"CookieName": "lna_visitor_id",
56+
"Lifetime": "365.00:00:00",
57+
"SecureOnly": true
58+
}
59+
```
60+
61+
Both ends are commented out in the stock `Program.cs` to make the opt-in explicit.
62+
63+
## Cookie attributes
64+
65+
| Attribute | Default | Why |
66+
|--------------|--------------------------|------------------------------------------------------------------------------------------------|
67+
| `HttpOnly` | `true` | The cookie is server-side only; no script needs to read it. |
68+
| `SameSite` | `Lax` | Sent on same-site and top-level navigation; not on third-party iframes. |
69+
| `Secure` | `true` (via `SecureOnly`)| HTTPS only. Set `false` for local HTTP development. |
70+
| `Expires` | 1 year | Long enough to persist across browser restarts; short enough to limit linkability over time. |
71+
| `Path` | `/` | Used by the whole app. |
72+
73+
## Privacy and retention
74+
75+
The visitor cookie is anonymous — it does not by itself reveal who a person is. It does, however, link a person's actions over time. If your app **persists** the visitor identifier or `IUserContext.GetIpAddress()` on durable rows (audit log, user-generated content), document a retention policy that matches the rest of your privacy posture and prune older rows accordingly. See the [Audit Log](./audit-log) docs for a maintenance-task pattern.
76+
77+
## How `IUserContext.GetActorId()` interacts
78+
79+
Once the middleware is registered, the contract from the [IUserContext](./project-structure) primitive resolves cleanly without branching:
80+
81+
| Request kind | `Kind` | `GetActorId()` |
82+
|-----------------------------------|---------------------|--------------------------|
83+
| Authenticated | `Authenticated` | User ID |
84+
| Unauthenticated, visitor cookie | `AnonymousVisitor` | Visitor GUID |
85+
| Unauthenticated, no cookie | `Anonymous` | Throws |
86+
| Background job / seeder | `System` | `"system"` |
87+
88+
Callers writing audit rows or partitioning anonymous data just call `GetActorId()`; the framework guarantees the right answer.

docs/concepts/index.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,12 @@ Pick between the one-shot `LightNap.MaintenanceService` model (nightly cron-styl
4848

4949
`/health/live` and `/health/ready` for container orchestrators and uptime monitors. Readiness covers the database and Redis (in distributed mode); liveness is dependency-free.
5050

51+
## Identity & Visitors
52+
53+
### [Anonymous Visitor Tracking](./anonymous-visitor)
54+
55+
Opt-in middleware that mints and reads a per-browser visitor cookie so unauthenticated users have a stable identifier for audit, anonymous UGC attribution, and per-visitor rate limiting that survives NAT and VPN hops.
56+
5157
## Compliance & Audit
5258

5359
### [Administrative Audit Log](./audit-log)
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
namespace LightNap.Configuration.AnonymousVisitor
2+
{
3+
/// <summary>
4+
/// Settings for the anonymous visitor tracking middleware.
5+
/// </summary>
6+
public sealed class AnonymousVisitorSettings
7+
{
8+
/// <summary>
9+
/// The name of the first-party cookie that stores the visitor identifier.
10+
/// </summary>
11+
public string CookieName { get; set; } = "lna_visitor_id";
12+
13+
/// <summary>
14+
/// How long the visitor cookie persists. Defaults to one year.
15+
/// </summary>
16+
public TimeSpan Lifetime { get; set; } = TimeSpan.FromDays(365);
17+
18+
/// <summary>
19+
/// When <c>true</c>, the cookie is set with <c>Secure</c> (HTTPS only). Default <c>true</c>;
20+
/// set <c>false</c> only for local development over HTTP.
21+
/// </summary>
22+
public bool SecureOnly { get; set; } = true;
23+
}
24+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
using LightNap.Configuration.AnonymousVisitor;
2+
using LightNap.WebApi.Middleware;
3+
using Microsoft.AspNetCore.Http;
4+
using Microsoft.Extensions.Options;
5+
6+
namespace LightNap.WebApi.Tests.Middleware
7+
{
8+
[TestClass]
9+
public class AnonymousVisitorIdMiddlewareTests
10+
{
11+
private static AnonymousVisitorIdMiddleware CreateMiddleware(
12+
AnonymousVisitorSettings? settings = null,
13+
RequestDelegate? next = null)
14+
{
15+
return new AnonymousVisitorIdMiddleware(
16+
next ?? (_ => Task.CompletedTask),
17+
Options.Create(settings ?? new AnonymousVisitorSettings()));
18+
}
19+
20+
[TestMethod]
21+
public async Task NoCookie_MintsNewIdentifier_AndSetsCookie()
22+
{
23+
var settings = new AnonymousVisitorSettings();
24+
var middleware = CreateMiddleware(settings);
25+
var context = new DefaultHttpContext();
26+
27+
await middleware.InvokeAsync(context);
28+
29+
Assert.IsNotNull(context.Items[AnonymousVisitorIdMiddleware.ItemKey]);
30+
var minted = (string)context.Items[AnonymousVisitorIdMiddleware.ItemKey]!;
31+
Assert.IsTrue(Guid.TryParse(minted, out _), $"Expected a GUID, got: {minted}");
32+
33+
var setCookieHeaders = context.Response.Headers.SetCookie.ToString();
34+
StringAssert.Contains(setCookieHeaders, settings.CookieName);
35+
StringAssert.Contains(setCookieHeaders, minted);
36+
}
37+
38+
[TestMethod]
39+
public async Task ExistingValidCookie_UsesCookieValue_NoSetCookieResponse()
40+
{
41+
var settings = new AnonymousVisitorSettings();
42+
var middleware = CreateMiddleware(settings);
43+
var existingId = Guid.NewGuid().ToString();
44+
45+
var context = new DefaultHttpContext();
46+
context.Request.Headers.Cookie = $"{settings.CookieName}={existingId}";
47+
48+
await middleware.InvokeAsync(context);
49+
50+
Assert.AreEqual(existingId, context.Items[AnonymousVisitorIdMiddleware.ItemKey]);
51+
Assert.IsTrue(string.IsNullOrEmpty(context.Response.Headers.SetCookie.ToString()),
52+
"Expected no Set-Cookie response header when the request already carried a valid cookie.");
53+
}
54+
55+
[TestMethod]
56+
public async Task MalformedCookie_MintsNewIdentifier()
57+
{
58+
var settings = new AnonymousVisitorSettings();
59+
var middleware = CreateMiddleware(settings);
60+
61+
var context = new DefaultHttpContext();
62+
context.Request.Headers.Cookie = $"{settings.CookieName}=not-a-guid";
63+
64+
await middleware.InvokeAsync(context);
65+
66+
var minted = (string)context.Items[AnonymousVisitorIdMiddleware.ItemKey]!;
67+
Assert.AreNotEqual("not-a-guid", minted);
68+
Assert.IsTrue(Guid.TryParse(minted, out _));
69+
}
70+
71+
[TestMethod]
72+
public async Task CallsNextDelegate()
73+
{
74+
bool nextCalled = false;
75+
var middleware = CreateMiddleware(next: _ =>
76+
{
77+
nextCalled = true;
78+
return Task.CompletedTask;
79+
});
80+
81+
await middleware.InvokeAsync(new DefaultHttpContext());
82+
83+
Assert.IsTrue(nextCalled);
84+
}
85+
}
86+
}

0 commit comments

Comments
 (0)