From fbfc1aadb523a8501a1bbe93eb164acd875c66cc Mon Sep 17 00:00:00 2001 From: Hein Date: Mon, 12 Oct 2026 12:18:23 +0200 Subject: [PATCH] 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. --- pkg/security/README.md | 77 ++++++++ pkg/security/providers.go | 6 +- pkg/security/requestinfo.go | 315 +++++++++++++++++++++++++++++++ pkg/security/requestinfo_test.go | 70 +++++++ 4 files changed, 466 insertions(+), 2 deletions(-) create mode 100644 pkg/security/requestinfo.go create mode 100644 pkg/security/requestinfo_test.go diff --git a/pkg/security/README.md b/pkg/security/README.md index a6c36a0..ef36608 100644 --- a/pkg/security/README.md +++ b/pkg/security/README.md @@ -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: diff --git a/pkg/security/providers.go b/pkg/security/providers.go index e10f50b..9529277 100644 --- a/pkg/security/providers.go +++ b/pkg/security/providers.go @@ -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 diff --git a/pkg/security/requestinfo.go b/pkg/security/requestinfo.go new file mode 100644 index 0000000..86d45c4 --- /dev/null +++ b/pkg/security/requestinfo.go @@ -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 +} diff --git a/pkg/security/requestinfo_test.go b/pkg/security/requestinfo_test.go new file mode 100644 index 0000000..082f3f4 --- /dev/null +++ b/pkg/security/requestinfo_test.go @@ -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) + } +}