mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-10-12 16:26:29 +00:00
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.
316 lines
9.8 KiB
Go
316 lines
9.8 KiB
Go
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
|
|
}
|