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 8abceb4b3a
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:
+4 -2
View File
@@ -170,6 +170,7 @@ func (a *DatabaseAuthenticator) SetAuthenticateCallback(fn func(r *http.Request)
}
func (a *DatabaseAuthenticator) Login(ctx context.Context, req LoginRequest) (*LoginResponse, error) {
req.Claims = enrichClaims(ctx, req.Claims)
return a.auth().Login(ctx, req)
}
@@ -179,7 +180,7 @@ func (a *DatabaseAuthenticator) Login(ctx context.Context, req LoginRequest) (*L
// Procedure-only: the key and user lookup live in resolvespec_login_api_key so
// the underlying schema can differ per database; there is no direct-SQL path.
func (a *DatabaseAuthenticator) LoginWithAPIKey(ctx context.Context, rawKey string, claims map[string]any) (*LoginResponse, error) {
return a.auth().LoginAPIKey(ctx, rawKey, claims)
return a.auth().LoginAPIKey(ctx, rawKey, enrichClaims(ctx, claims))
}
// Register implements Registrable interface
@@ -403,6 +404,7 @@ func (a *JWTAuthenticator) WithLookupProvider(p *lookup.Provider) *JWTAuthentica
func (a *JWTAuthenticator) auth() lookup.AuthStore { return a.src.get().Auth }
func (a *JWTAuthenticator) Login(ctx context.Context, req LoginRequest) (*LoginResponse, error) {
req.Claims = enrichClaims(ctx, req.Claims)
return a.auth().JWTLogin(ctx, req)
}
@@ -626,7 +628,7 @@ func (a *DatabaseAuthenticator) LoginWithPasskey(ctx context.Context, req Passke
return nil, fmt.Errorf("passkey authentication failed: %w", err)
}
return a.src.get().Passkey.Login(ctx, userID, req.Claims)
return a.src.get().Passkey.Login(ctx, userID, enrichClaims(ctx, req.Claims))
}
// GetPasskeyCredentials returns all passkey credentials for a user
+315
View File
@@ -0,0 +1,315 @@
package security
import (
"context"
"net"
"net/http"
"strconv"
"strings"
)
const (
// ClaimIPAddress is the login claim holding the client's IP address.
ClaimIPAddress = "ip_address"
// ClaimPreviousSession is the login claim holding the session token the
// client presented when it logged in again.
ClaimPreviousSession = "previous_session"
// ClaimUserAgent is the login claim holding the client's User-Agent header.
ClaimUserAgent = "user_agent"
// ClaimServerIPAddress is the login claim holding the IP of the server
// address that accepted the connection.
ClaimServerIPAddress = "server_ip_address"
// ClaimHost is the login claim holding the requested Host.
ClaimHost = "host"
// ClaimOrigin is the login claim holding the Origin header.
ClaimOrigin = "origin"
// ClaimReferer is the login claim holding the Referer header.
ClaimReferer = "referer"
// ClaimClientPort is the login claim holding the port of the peer that
// connected (the proxy's port when behind one).
ClaimClientPort = "client_port"
// ClaimServerPort is the login claim holding the port of the server
// address that accepted the connection.
ClaimServerPort = "server_port"
// ClaimIsPrivateIP is the login claim (bool) that is true when the client
// IP is private, loopback or link-local.
ClaimIsPrivateIP = "is_private_ip"
// ClaimDeviceID is the login claim holding the client-supplied device id.
ClaimDeviceID = "device_id"
// ClaimClientID is the login claim holding the client-supplied client id
// (X-Client-ID header).
ClaimClientID = "client_id"
// ClaimClientPort is the login claim holding the port of the peer that
)
type requestInfoKey struct{}
// RequestInfoOptions tunes what WithRequestInfo and RequestInfoMiddleware read.
// The zero value trusts proxy headers from any peer, reads the default session
// cookie and takes the device id from X-Device-ID or the device_id cookie.
type RequestInfoOptions struct {
// Cookie is used to find the previous session token.
Cookie SessionCookieOptions
// TrustedProxies lists the CIDRs or IPs allowed to set X-Forwarded-For /
// X-Real-IP. When set, those headers are only honoured if the connecting
// peer is in the list, and X-Forwarded-For is read from the right, skipping
// trusted proxies. When empty, the headers are trusted from any peer.
TrustedProxies []string
// IgnoreProxyHeaders always uses the connecting peer as the client IP.
IgnoreProxyHeaders bool
// DeviceIDHeader and DeviceIDCookie name where the device id comes from.
// Defaults: "X-Device-ID" and "device_id".
DeviceIDHeader string
DeviceIDCookie string
// ClientIDHeader names the header carrying the client id.
// Default: "X-Client-ID".
ClientIDHeader string
}
func (o RequestInfoOptions) clientIDHeader() string {
if o.ClientIDHeader != "" {
return o.ClientIDHeader
}
return "X-Client-ID"
}
func (o RequestInfoOptions) deviceHeader() string {
if o.DeviceIDHeader != "" {
return o.DeviceIDHeader
}
return "X-Device-ID"
}
func (o RequestInfoOptions) deviceCookie() string {
if o.DeviceIDCookie != "" {
return o.DeviceIDCookie
}
return "device_id"
}
// RequestInfo is what the login methods learn about the calling client.
type RequestInfo struct {
IPAddress string // client IP
ClientPort string // port of the connecting peer
IsPrivateIP bool // IPAddress is private, loopback or link-local
PreviousSession string // session token the client already held
DeviceID string
ClientID string // client-supplied application/client id
UserAgent string
ServerIPAddress string // local address that accepted the connection
ServerPort string
Host string
Origin string
Referer string
}
// claims returns the non-empty fields keyed by their claim names.
func (i RequestInfo) claims() map[string]any {
out := map[string]any{}
for k, v := range map[string]string{
ClaimIPAddress: i.IPAddress,
ClaimClientPort: i.ClientPort,
ClaimPreviousSession: i.PreviousSession,
ClaimDeviceID: i.DeviceID,
ClaimClientID: i.ClientID,
ClaimUserAgent: i.UserAgent,
ClaimServerIPAddress: i.ServerIPAddress,
ClaimServerPort: i.ServerPort,
ClaimHost: i.Host,
ClaimOrigin: i.Origin,
ClaimReferer: i.Referer,
} {
if v != "" {
out[k] = v
}
}
if net.ParseIP(i.IPAddress) != nil {
out[ClaimIsPrivateIP] = i.IsPrivateIP
}
return out
}
// splitHostPort returns host and port of addr; the port is "" when addr has none.
func splitHostPort(addr string) (host, port string) {
if h, p, err := net.SplitHostPort(addr); err == nil {
return h, p
}
return addr, ""
}
// serverAddr returns the IP and port of the local address that accepted r's
// connection, or "" when the server did not record it.
func serverAddr(r *http.Request) (ip, port string) {
addr, ok := r.Context().Value(http.LocalAddrContextKey).(net.Addr)
if !ok || addr == nil {
return "", ""
}
return splitHostPort(addr.String())
}
func isPrivateIP(ip string) bool {
p := net.ParseIP(ip)
return p != nil && (p.IsPrivate() || p.IsLoopback() || p.IsLinkLocalUnicast())
}
// parseTrusted turns CIDRs / plain IPs into networks; invalid entries are skipped.
func parseTrusted(entries []string) []*net.IPNet {
var nets []*net.IPNet
for _, e := range entries {
e = strings.TrimSpace(e)
if !strings.Contains(e, "/") {
ip := net.ParseIP(e)
if ip == nil {
continue
}
bits := 32
if ip.To4() == nil {
bits = 128
}
e = e + "/" + strconv.Itoa(bits)
}
if _, n, err := net.ParseCIDR(e); err == nil {
nets = append(nets, n)
}
}
return nets
}
func inNets(nets []*net.IPNet, ip string) bool {
p := net.ParseIP(ip)
if p == nil {
return false
}
for _, n := range nets {
if n.Contains(p) {
return true
}
}
return false
}
// ClientIP returns the client IP, trusting X-Forwarded-For (first entry) and
// X-Real-IP from any peer, then RemoteAddr without its port. Use
// RequestInfoOptions to restrict which peers are trusted.
func ClientIP(r *http.Request) string {
return clientIP(r, RequestInfoOptions{})
}
func clientIP(r *http.Request, o RequestInfoOptions) string {
peer, _ := splitHostPort(r.RemoteAddr)
if o.IgnoreProxyHeaders {
return peer
}
trusted := parseTrusted(o.TrustedProxies)
restrict := len(o.TrustedProxies) > 0
if restrict && !inNets(trusted, peer) {
return peer
}
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
parts := strings.Split(xff, ",")
if !restrict {
return strings.TrimSpace(parts[0])
}
// Walk from the right: the first hop that is not a trusted proxy is the client.
for i := len(parts) - 1; i >= 0; i-- {
hop := strings.TrimSpace(parts[i])
if !inNets(trusted, hop) {
return hop
}
}
return strings.TrimSpace(parts[0])
}
if xri := r.Header.Get("X-Real-IP"); xri != "" {
return strings.TrimSpace(xri)
}
return peer
}
// previousSessionToken returns the first session token the request carries in
// the Authorization header or, failing that, the session cookie.
func previousSessionToken(r *http.Request, cookie SessionCookieOptions) string {
if h := r.Header.Get("Authorization"); h != "" {
first, _, _ := strings.Cut(h, ",")
first = strings.TrimSpace(first)
first = strings.TrimPrefix(first, "Bearer ")
first = strings.TrimPrefix(first, "Token ")
return strings.TrimSpace(first)
}
return GetSessionCookie(r, cookie)
}
func deviceID(r *http.Request, o RequestInfoOptions) string {
if v := strings.TrimSpace(r.Header.Get(o.deviceHeader())); v != "" {
return v
}
if c, err := r.Cookie(o.deviceCookie()); err == nil {
return c.Value
}
return ""
}
// WithRequestInfo returns a context carrying details of r: client IP and port,
// private-IP flag, previous session token, device id, client id, user agent, server IP and
// port, host, origin and referer. Login, LoginWithAPIKey and LoginWithPasskey
// add them to the login claims (see the Claim* constants); empty values are
// left out.
func WithRequestInfo(ctx context.Context, r *http.Request, opts ...RequestInfoOptions) context.Context {
var o RequestInfoOptions
if len(opts) > 0 {
o = opts[0]
}
ip := clientIP(r, o)
_, clientPort := splitHostPort(r.RemoteAddr)
serverIP, serverPort := serverAddr(r)
return context.WithValue(ctx, requestInfoKey{}, RequestInfo{
IPAddress: ip,
ClientPort: clientPort,
IsPrivateIP: isPrivateIP(ip),
PreviousSession: previousSessionToken(r, o.Cookie),
DeviceID: deviceID(r, o),
ClientID: strings.TrimSpace(r.Header.Get(o.clientIDHeader())),
UserAgent: r.UserAgent(),
ServerIPAddress: serverIP,
ServerPort: serverPort,
Host: r.Host,
Origin: r.Header.Get("Origin"),
Referer: r.Referer(),
})
}
// RequestInfoFromContext returns the info stored by WithRequestInfo.
func RequestInfoFromContext(ctx context.Context) (RequestInfo, bool) {
info, ok := ctx.Value(requestInfoKey{}).(RequestInfo)
return info, ok
}
// RequestInfoMiddleware stores the request details (see WithRequestInfo) in the
// request context for every route, including ones that skip authentication
// (such as login), so the login methods can send them as claims.
func RequestInfoMiddleware(opts ...RequestInfoOptions) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
next.ServeHTTP(w, r.WithContext(WithRequestInfo(r.Context(), r, opts...)))
})
}
}
// enrichClaims returns a copy of claims with the request info from ctx added.
// Values the caller already set are kept.
func enrichClaims(ctx context.Context, claims map[string]any) map[string]any {
info, ok := RequestInfoFromContext(ctx)
if !ok {
return claims
}
extra := info.claims()
out := make(map[string]any, len(claims)+len(extra))
for k, v := range claims {
out[k] = v
}
for k, v := range extra {
if _, set := out[k]; !set {
out[k] = v
}
}
return out
}
+70
View File
@@ -0,0 +1,70 @@
package security
import (
"context"
"net"
"net/http"
"net/http/httptest"
"testing"
)
func TestEnrichClaims(t *testing.T) {
r := httptest.NewRequest("POST", "/login", nil)
r.RemoteAddr = "10.0.0.1:5555"
r.Header.Set("X-Forwarded-For", "1.2.3.4, 10.0.0.1")
r.Header.Set("Authorization", "Bearer old-token")
r.Header.Set("User-Agent", "ua/1")
r.Header.Set("Origin", "https://app.example")
r.Host = "api.example"
r = r.WithContext(context.WithValue(r.Context(), http.LocalAddrContextKey, &net.TCPAddr{IP: net.ParseIP("192.168.1.5"), Port: 8080}))
ctx := WithRequestInfo(r.Context(), r)
in := map[string]any{ClaimUserAgent: "x"}
got := enrichClaims(ctx, in)
if got[ClaimIPAddress] != "1.2.3.4" || got[ClaimPreviousSession] != "old-token" || got[ClaimUserAgent] != "x" ||
got[ClaimServerIPAddress] != "192.168.1.5" || got[ClaimHost] != "api.example" || got[ClaimOrigin] != "https://app.example" {
t.Fatalf("unexpected claims: %v", got)
}
if _, ok := in[ClaimIPAddress]; ok {
t.Fatal("input map mutated")
}
if got := enrichClaims(ctx, map[string]any{ClaimIPAddress: "9.9.9.9"}); got[ClaimIPAddress] != "9.9.9.9" {
t.Fatalf("caller value overridden: %v", got)
}
if got := enrichClaims(r.Context(), nil); got != nil {
t.Fatalf("expected nil without info, got %v", got)
}
}
func TestClientIPTrustedProxies(t *testing.T) {
mk := func(peer, xff string) *http.Request {
r := httptest.NewRequest("GET", "/", nil)
r.RemoteAddr = peer
if xff != "" {
r.Header.Set("X-Forwarded-For", xff)
}
return r
}
o := RequestInfoOptions{TrustedProxies: []string{"10.0.0.0/8", "172.16.0.1"}}
if got := clientIP(mk("10.0.0.1:1", "6.6.6.6, 1.2.3.4, 10.0.0.2"), o); got != "1.2.3.4" {
t.Fatalf("spoofed prefix not skipped: %s", got)
}
if got := clientIP(mk("8.8.8.8:1", "1.2.3.4"), o); got != "8.8.8.8" {
t.Fatalf("untrusted peer header honoured: %s", got)
}
if got := clientIP(mk("10.0.0.1:1", "1.2.3.4"), RequestInfoOptions{IgnoreProxyHeaders: true}); got != "10.0.0.1" {
t.Fatalf("IgnoreProxyHeaders: %s", got)
}
}
func TestRequestInfoExtras(t *testing.T) {
r := httptest.NewRequest("POST", "/login", nil)
r.RemoteAddr = "192.168.0.7:4242"
r.Header.Set("X-Device-ID", "dev-1")
r.Header.Set("X-Client-ID", "app-9")
r = r.WithContext(context.WithValue(r.Context(), http.LocalAddrContextKey, &net.TCPAddr{IP: net.ParseIP("10.1.1.1"), Port: 8443}))
got := enrichClaims(WithRequestInfo(r.Context(), r), nil)
if got[ClaimClientPort] != "4242" || got[ClaimServerPort] != "8443" || got[ClaimDeviceID] != "dev-1" || got[ClaimClientID] != "app-9" || got[ClaimIsPrivateIP] != true {
t.Fatalf("unexpected claims: %v", got)
}
}