feat(security): send request info as login claims

Add RequestInfoMiddleware / WithRequestInfo, which put client details in the
context. Login, LoginWithAPIKey, LoginWithPasskey and JWT login merge them into
the claims: ip_address, client_port, is_private_ip, previous_session, device_id,
client_id, user_agent, server_ip_address, server_port, host, origin, referer.
RequestInfoOptions adds trusted-proxy handling for X-Forwarded-For/X-Real-IP.
This commit is contained in:
Hein
2026-10-12 12:18:23 +02:00
parent 5b6406ef38
commit fbfc1aadb5
4 changed files with 466 additions and 2 deletions
+77
View File
@@ -1476,6 +1476,83 @@ func myHandler(w http.ResponseWriter, r *http.Request) {
}
```
### Request Info for Login (RequestInfoMiddleware / WithRequestInfo)
`Login`, `LoginWithAPIKey` and `LoginWithPasskey` (and `JWTAuthenticator.Login`) only receive a
`context.Context`, not the `*http.Request`. To send the client's IP address and the session the
client already holds, put them in the context. The authenticators then add two claims to the login
claims before calling the stored procedure / backend:
| Claim | Value |
|-------|-------|
| `ip_address` | Client IP: first `X-Forwarded-For` entry, else `X-Real-IP`, else `RemoteAddr` without the port |
| `previous_session` | Session token the client sent: first token of the `Authorization` header (`Bearer `/`Token ` stripped), else the session cookie |
| `user_agent` | `User-Agent` header |
| `server_ip_address` | IP of the local address that accepted the connection (from `http.LocalAddrContextKey`; omitted if the server didn't record it) |
| `host` | Requested `Host` |
| `origin` | `Origin` header |
| `referer` | `Referer` header |
| `client_port` | Port of the connecting peer (the proxy's port when behind one) |
| `server_port` | Port of the local address that accepted the connection |
| `is_private_ip` | `true` when `ip_address` is private, loopback or link-local (bool; sent whenever the IP parses) |
| `device_id` | `X-Device-ID` header, else the `device_id` cookie |
| `client_id` | `X-Client-ID` header |
Claims you set yourself (e.g. your own `user_agent`) are never overwritten, and the caller's claims map is not mutated. A claim
is omitted when its value is empty (e.g. no previous session on a first login).
`resolvespec_login` already reads `claims.ip_address`; `previous_session` is only carried through to the
login backend — nothing is stored for it, so use it in your own procedure/backend if you need it.
**Option 1 — middleware (recommended).** Add it on the root router, before auth, so it also covers
routes that skip authentication such as login:
```go
router.Use(security.RequestInfoMiddleware())
```
Pass `RequestInfoOptions` to change how the request is read:
```go
router.Use(security.RequestInfoMiddleware(security.RequestInfoOptions{
Cookie: cookieOpts, // same options you give the authenticator
TrustedProxies: []string{"10.0.0.0/8", "172.16.0.1"}, // CIDRs or IPs
// IgnoreProxyHeaders: true, // always use the connecting peer
// DeviceIDHeader: "X-Device-ID", DeviceIDCookie: "device_id", // defaults
// ClientIDHeader: "X-Client-ID", // default
}))
```
With `TrustedProxies` set, `X-Forwarded-For` / `X-Real-IP` are only honoured when the connecting
peer is in the list, and `X-Forwarded-For` is read right-to-left, skipping trusted proxies, so a
client cannot spoof `ip_address` by sending its own header. With it empty, the headers are trusted
from any peer.
The OAuth server's login and device-login handlers use `r.Context()`, so they pick the claims up
from this middleware.
**Option 2 — per handler.** Use `WithRequestInfo` when the route is not behind the middleware:
```go
func loginHandler(w http.ResponseWriter, r *http.Request) {
ctx := security.WithRequestInfo(r.Context(), r) // optional: , security.RequestInfoOptions{...}
resp, err := auth.Login(ctx, security.LoginRequest{
Username: r.PostFormValue("username"),
Password: r.PostFormValue("password"),
Claims: map[string]any{"user_agent": r.UserAgent()},
})
// ...
}
```
Related helpers: `security.ClientIP(r)`, `security.RequestInfoFromContext(ctx)`, and the claim
constants (`security.ClaimIPAddress`, `ClaimPreviousSession`, `ClaimUserAgent`,
`ClaimServerIPAddress`, `ClaimHost`, `ClaimOrigin`, `ClaimReferer`, `ClaimClientPort`,
`ClaimServerPort`, `ClaimIsPrivateIP`, `ClaimDeviceID`, `ClaimClientID`).
> Without `TrustedProxies`, `X-Forwarded-For` / `X-Real-IP` are taken at face value, so they are
> client-controlled unless your reverse proxy overwrites them. Set `TrustedProxies` for anything
> that relies on `ip_address`.
### Helper Functions
Extract user information from context: