Compare commits

...
3 Commits
Author SHA1 Message Date
Hein 5dbe187915 feat(restheadspec): add x-searchop-notcontain operator
Maps notcontain/notcontains/notlike to a not_ilike filter rendered as
(col IS NULL OR CAST(col AS TEXT) NOT ILIKE ?), so NULL rows are kept.
2026-10-12 15:56:42 +02:00
Hein d3252f3d02 feat(restheadspec): match NULL when x-fieldfilter compares a numeric column to 0
Adds a SetNullAsZero handler option: on by default in restheadspec (for
x-fieldfilter only), off by default in resolvespec. When enabled, an eq
filter of 0 on a numeric column renders (col = 0 OR col IS NULL).
2026-10-12 15:49:57 +02:00
Hein fbfc1aadb5 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.
2026-10-12 12:18:23 +02:00
14 changed files with 760 additions and 6 deletions
+4
View File
@@ -139,6 +139,10 @@ func BuildJSONFilterCondition(model interface{}, tableAlias, token, operator str
return fmt.Sprintf("%s LIKE ?", colExpr), prepend(value), true
case "ilike":
return fmt.Sprintf("%s ILIKE ?", colExpr), prepend(value), true
case "not_ilike":
// colExpr appears twice, so its bound args must be repeated too.
notArgs := append(append([]interface{}{}, colArgs...), colArgs...)
return fmt.Sprintf("(%s IS NULL OR %s NOT ILIKE ?)", colExpr, colExpr), append(notArgs, value), true
case "in":
inCond, inArgs := BuildInCondition(colExpr, value)
if inCond == "" {
+5
View File
@@ -93,6 +93,11 @@ func TestBuildJSONFilterCondition(t *testing.T) {
wantCond: `("data" #>> ?::text[]) ILIKE ?`,
wantArgs: []interface{}{"{city}", "%la%"},
},
{
name: "not_ilike repeats bound path", token: "data->>'city'", operator: "not_ilike", value: "%la%",
wantCond: `(("data" #>> ?::text[]) IS NULL OR ("data" #>> ?::text[]) NOT ILIKE ?)`,
wantArgs: []interface{}{"{city}", "{city}", "%la%"},
},
{
name: "in", token: "data->>'tier'", operator: "in", value: []string{"a", "b"},
wantCond: `("data" #>> ?::text[]) IN (?,?)`,
+39
View File
@@ -0,0 +1,39 @@
package common
import "github.com/bitechdev/ResolveSpec/pkg/reflection"
// IsNumericZeroFilter reports whether value is a numeric zero compared against
// a numeric column of model. Used by the "null as zero" option, which turns
// col = 0 into (col = 0 OR col IS NULL).
func IsNumericZeroFilter(model interface{}, column string, value interface{}) bool {
if model == nil || !reflection.IsNumericType(reflection.GetColumnTypeFromModel(model, column)) {
return false
}
switch v := value.(type) {
case int:
return v == 0
case int8:
return v == 0
case int16:
return v == 0
case int32:
return v == 0
case int64:
return v == 0
case uint:
return v == 0
case uint8:
return v == 0
case uint16:
return v == 0
case uint32:
return v == 0
case uint64:
return v == 0
case float32:
return v == 0
case float64:
return v == 0
}
return false
}
+4
View File
@@ -87,6 +87,10 @@ type FilterOption struct {
Operator string `json:"operator"`
Value interface{} `json:"value"`
LogicOperator string `json:"logic_operator"` // "AND" or "OR" - how this filter combines with previous filters
// NullAsZero makes an "eq" filter on a numeric column with a zero value also
// match NULLs (COALESCE(col, 0) = 0). Set for x-fieldfilter headers only.
NullAsZero bool `json:"-"`
}
type SortOption struct {
+17 -2
View File
@@ -33,6 +33,7 @@ type Handler struct {
openAPIGenerator func() (string, error)
defaultSort map[string][]common.SortOption
disallowNulls bool
nullAsZero bool
}
// NewHandler creates a new API handler with database and registry abstractions
@@ -61,6 +62,12 @@ func (h *Handler) SetDisallowNulls(disallow bool) {
h.disallowNulls = disallow
}
// SetNullAsZero controls whether an equality filter against 0 on a numeric
// column also matches NULLs: (col = 0 OR col IS NULL). Disabled by default.
func (h *Handler) SetNullAsZero(enabled bool) {
h.nullAsZero = enabled
}
// SetFallbackHandler sets a fallback handler to be called when no model is found
// If not set, the handler will simply return (pass through to next route)
func (h *Handler) SetFallbackHandler(fallback FallbackHandler) {
@@ -2026,7 +2033,11 @@ func (h *Handler) buildFilterConditionAlias(filter common.FilterOption, model in
switch filter.Operator {
case "eq", "=":
condition = fmt.Sprintf("%s = ?", filter.Column)
if h.nullAsZero && common.IsNumericZeroFilter(model, rawColumn, filter.Value) {
condition = fmt.Sprintf("(%s = ? OR %s IS NULL)", filter.Column, filter.Column)
} else {
condition = fmt.Sprintf("%s = ?", filter.Column)
}
args = []interface{}{filter.Value}
case "neq", "!=", "<>":
condition = fmt.Sprintf("%s != ?", filter.Column)
@@ -2111,7 +2122,11 @@ func (h *Handler) applyFilter(query common.SelectQuery, filter common.FilterOpti
switch filter.Operator {
case "eq", "=":
condition = fmt.Sprintf("%s = ?", filter.Column)
if h.nullAsZero && common.IsNumericZeroFilter(model, rawColumn, filter.Value) {
condition = fmt.Sprintf("(%s = ? OR %s IS NULL)", filter.Column, filter.Column)
} else {
condition = fmt.Sprintf("%s = ?", filter.Column)
}
args = []interface{}{filter.Value}
case "neq", "!=", "<>":
condition = fmt.Sprintf("%s != ?", filter.Column)
+41
View File
@@ -0,0 +1,41 @@
package resolvespec
import (
"testing"
"github.com/bitechdev/ResolveSpec/pkg/common"
)
type nullAsZeroModel struct {
Inactive int `json:"inactive" bun:"inactive"`
Name string `json:"name" bun:"name"`
}
func TestNullAsZero_ResolveSpec(t *testing.T) {
model := nullAsZeroModel{}
tests := []struct {
name string
enabled bool
filter common.FilterOption
want string
}{
{"default off, zero", false, common.FilterOption{Column: "inactive", Operator: "eq", Value: float64(0)}, `"t"."inactive" = ?`},
{"on, zero", true, common.FilterOption{Column: "inactive", Operator: "eq", Value: float64(0)}, `("t"."inactive" = ? OR "t"."inactive" IS NULL)`},
{"on, int zero", true, common.FilterOption{Column: "inactive", Operator: "eq", Value: 0}, `("t"."inactive" = ? OR "t"."inactive" IS NULL)`},
{"on, positive", true, common.FilterOption{Column: "inactive", Operator: "eq", Value: float64(5)}, `"t"."inactive" = ?`},
{"on, string column", true, common.FilterOption{Column: "name", Operator: "eq", Value: float64(0)}, `"t"."name" = ?`},
{"on, neq untouched", true, common.FilterOption{Column: "inactive", Operator: "neq", Value: float64(0)}, `"t"."inactive" != ?`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
h := &Handler{}
if tt.enabled {
h.SetNullAsZero(true)
}
got, _ := h.buildFilterConditionAlias(tt.filter, model, "t")
if got != tt.want {
t.Fatalf("got %q, want %q", got, tt.want)
}
})
}
}
+1
View File
@@ -89,6 +89,7 @@ Search with specific operators (AND logic).
**Supported Operators:**
- `contains` - Contains substring (case-insensitive). Implemented as `CAST(col AS TEXT) ILIKE '%value%'` for every column type, including arrays (stringifies the array, then substring-matches). **Not** array containment — no GIN index use, and can false-positive on partial matches within array elements. resolvespec (a different spec package in this repo) defines `contains` differently: real PostgreSQL array-overlap (`&&`). Don't assume the two behave the same.
- `notcontains` / `notcontain` / `notlike` - Does not contain substring (case-insensitive). Implemented as `(col IS NULL OR CAST(col AS TEXT) NOT ILIKE '%value%')`, so NULLs are kept
- `beginswith` / `startswith` - Starts with (case-insensitive)
- `endswith` - Ends with (case-insensitive)
- `equals` / `eq` - Exact match
+143
View File
@@ -173,3 +173,146 @@ func TestValidateAndAdjustFilterForColumnType_NumericColumn_Ilike(t *testing.T)
t.Fatalf("args = %#v, want [\"%%345346346%%\"]", c.args)
}
}
type inactiveModel struct {
Inactive spectypes.SqlInt64 `json:"inactive" bun:"inactive"`
Name string `json:"name" bun:"name"`
}
func TestFieldFilter_ZeroOnNumericColumn_CoalescesNull(t *testing.T) {
h := NewHandler(nil, nil)
model := inactiveModel{}
run := func(header, val string) (string, []interface{}) {
req := &MockRequest{headers: map[string]string{header: val}, queryParams: map[string]string{}}
options := h.parseOptionsFromHeaders(req, model)
f := options.Filters[0]
ci := h.ValidateAndAdjustFilterForColumnType(&f, model)
q := &jsonCapQuery{}
h.applyFilter(q, f, "public.t", ci.NeedsCast, "AND", model)
c := q.only(t)
return c.query, c.args
}
if got, args := run("x-fieldfilter-inactive", "0"); got != "(t.inactive = ? OR t.inactive IS NULL)" || !reflect.DeepEqual(args, []interface{}{int64(0)}) {
t.Fatalf("got %q %#v", got, args)
}
if got, _ := run("x-fieldfilter-inactive", "1"); got != "t.inactive = ?" {
t.Fatalf("non-zero value must not coalesce, got %q", got)
}
if got, _ := run("x-fieldfilter-name", "0"); got != "t.name = ?" {
t.Fatalf("string column must not coalesce, got %q", got)
}
}
func TestFieldFilter_NullAsZero_Cases(t *testing.T) {
h := NewHandler(nil, nil)
model := inactiveModel{}
tests := []struct {
name string
column string
value string
wantQuery string
wantArg interface{}
}{
{"zero", "inactive", "0", "(t.inactive = ? OR t.inactive IS NULL)", int64(0)},
{"positive", "inactive", "1", "t.inactive = ?", int64(1)},
{"larger positive", "inactive", "42", "t.inactive = ?", int64(42)},
{"negative", "inactive", "-1", "t.inactive = ?", int64(-1)},
}
for _, tt := range tests {
t.Run(tt.name+"/single", func(t *testing.T) {
req := &MockRequest{headers: map[string]string{"x-fieldfilter-" + tt.column: tt.value}, queryParams: map[string]string{}}
f := h.parseOptionsFromHeaders(req, model).Filters[0]
ci := h.ValidateAndAdjustFilterForColumnType(&f, model)
q := &jsonCapQuery{}
h.applyFilter(q, f, "public.t", ci.NeedsCast, "AND", model)
c := q.only(t)
if c.query != tt.wantQuery || !reflect.DeepEqual(c.args, []interface{}{tt.wantArg}) {
t.Fatalf("got %q %#v, want %q [%#v]", c.query, c.args, tt.wantQuery, tt.wantArg)
}
})
t.Run(tt.name+"/or-group", func(t *testing.T) {
req := &MockRequest{headers: map[string]string{"x-fieldfilter-" + tt.column: tt.value}, queryParams: map[string]string{}}
f := h.parseOptionsFromHeaders(req, model).Filters[0]
ci := h.ValidateAndAdjustFilterForColumnType(&f, model)
q := &jsonCapQuery{}
h.applyOrFilterGroup(q, []*common.FilterOption{&f}, []ColumnCastInfo{ci}, "public.t", model)
c := q.only(t)
want := "(" + tt.wantQuery + ")"
if c.query != want || !reflect.DeepEqual(c.args, []interface{}{tt.wantArg}) {
t.Fatalf("got %q %#v, want %q [%#v]", c.query, c.args, want, tt.wantArg)
}
})
}
}
// Filters not coming from x-fieldfilter must keep a plain equality, even for 0.
func TestNullAsZero_NotAppliedWithoutFlag(t *testing.T) {
h := NewHandler(nil, nil)
model := inactiveModel{}
f := common.FilterOption{Column: "inactive", Operator: "eq", Value: "0"}
ci := h.ValidateAndAdjustFilterForColumnType(&f, model)
q := &jsonCapQuery{}
h.applyFilter(q, f, "public.t", ci.NeedsCast, "AND", model)
if got := q.only(t).query; got != "t.inactive = ?" {
t.Fatalf("got %q", got)
}
}
func TestNullAsZero_RestHeadSpecConfig(t *testing.T) {
model := inactiveModel{}
build := func(h *Handler) string {
req := &MockRequest{headers: map[string]string{"x-fieldfilter-inactive": "0"}, queryParams: map[string]string{}}
f := h.parseOptionsFromHeaders(req, model).Filters[0]
ci := h.ValidateAndAdjustFilterForColumnType(&f, model)
q := &jsonCapQuery{}
h.applyFilter(q, f, "public.t", ci.NeedsCast, "AND", model)
return q.only(t).query
}
if got := build(NewHandler(nil, nil)); got != "(t.inactive = ? OR t.inactive IS NULL)" {
t.Fatalf("default should be on, got %q", got)
}
h := NewHandler(nil, nil)
h.SetNullAsZero(false)
if got := build(h); got != "t.inactive = ?" {
t.Fatalf("disabled should be plain equality, got %q", got)
}
}
func TestSearchOp_NotContain(t *testing.T) {
h := NewHandler(nil, nil)
model := inactiveModel{}
for _, op := range []string{"notcontain", "notcontains", "notlike"} {
req := &MockRequest{headers: map[string]string{"x-searchop-" + op + "-inactive": "99"}, queryParams: map[string]string{}}
opts := h.parseOptionsFromHeaders(req, model)
if len(opts.Filters) != 1 || opts.Filters[0].Operator != "not_ilike" || opts.Filters[0].Value != "%99%" {
t.Fatalf("%s: unexpected filters %+v", op, opts.Filters)
}
}
req := &MockRequest{headers: map[string]string{"x-searchop-notcontain-inactive": "99"}, queryParams: map[string]string{}}
f := h.parseOptionsFromHeaders(req, model).Filters[0]
ci := h.ValidateAndAdjustFilterForColumnType(&f, model)
if !ci.NeedsCast {
t.Fatalf("numeric column must be cast to TEXT for NOT ILIKE")
}
q := &jsonCapQuery{}
h.applyFilter(q, f, "public.t", ci.NeedsCast, "AND", model)
c := q.only(t)
if want := "(t.inactive IS NULL OR CAST(t.inactive AS TEXT) NOT ILIKE ?)"; c.query != want || !reflect.DeepEqual(c.args, []interface{}{"%99%"}) {
t.Fatalf("got %q %#v, want %q", c.query, c.args, want)
}
q = &jsonCapQuery{}
h.applyOrFilterGroup(q, []*common.FilterOption{&f}, []ColumnCastInfo{ci}, "public.t", model)
c = q.only(t)
if want := "((t.inactive IS NULL OR CAST(t.inactive AS TEXT) NOT ILIKE ?))"; c.query != want {
t.Fatalf("or group got %q, want %q", c.query, want)
}
}
+36 -1
View File
@@ -34,6 +34,7 @@ type Handler struct {
openAPIGenerator func() (string, error)
defaultSort map[string][]common.SortOption
disallowNulls bool
nullAsZero bool
}
// NewHandler creates a new API handler with database and registry abstractions
@@ -42,6 +43,8 @@ func NewHandler(db common.Database, registry common.ModelRegistry) *Handler {
db: db,
registry: registry,
hooks: NewHookRegistry(),
// x-fieldfilter on a numeric column with 0 also matches NULLs by default
nullAsZero: true,
}
// Initialize nested processor
handler.nestedProcessor = common.NewNestedCUDProcessor(db, registry, handler)
@@ -68,6 +71,12 @@ func (h *Handler) SetDisallowNulls(disallow bool) {
h.disallowNulls = disallow
}
// SetNullAsZero controls whether an x-fieldfilter equality against 0 on a numeric
// column also matches NULLs: (col = 0 OR col IS NULL). Enabled by default.
func (h *Handler) SetNullAsZero(enabled bool) {
h.nullAsZero = enabled
}
// SetFallbackHandler sets a fallback handler to be called when no model is found
// If not set, the handler will simply return (pass through to next route)
func (h *Handler) SetFallbackHandler(fallback FallbackHandler) {
@@ -2518,6 +2527,9 @@ func (h *Handler) applyFilter(query common.SelectQuery, filter common.FilterOpti
switch strings.ToLower(filter.Operator) {
case "eq", "equals":
if h.nullAsZeroApplies(&filter, model, needsCast) {
return applyWhere(fmt.Sprintf("(%s = ? OR %s IS NULL)", rawQualifiedColumn, rawQualifiedColumn), filter.Value)
}
return applyWhere(fmt.Sprintf("%s = ?", qualifiedColumn), filter.Value)
case "neq", "not_equals", "ne":
return applyWhere(fmt.Sprintf("%s != ?", qualifiedColumn), filter.Value)
@@ -2538,6 +2550,9 @@ func (h *Handler) applyFilter(query common.SelectQuery, filter common.FilterOpti
// columns are compared natively (see likeColumn above) since citext is
// already case-insensitive.
return applyWhere(fmt.Sprintf("%s ILIKE ?", likeColumn), filter.Value)
case "not_ilike":
// NULLs don't contain the value, so they are kept
return applyWhere(fmt.Sprintf("(%s IS NULL OR %s NOT ILIKE ?)", rawQualifiedColumn, likeColumn), filter.Value)
case "in":
cond, inArgs := common.BuildInCondition(qualifiedColumn, filter.Value)
if cond == "" {
@@ -2588,6 +2603,16 @@ func (h *Handler) applyFilter(query common.SelectQuery, filter common.FilterOpti
}
}
// nullAsZeroApplies reports whether an eq filter should also match NULLs, i.e.
// (col = 0 OR col IS NULL). This is the case for x-fieldfilter on a numeric
// column compared against zero, where a NULL is treated the same as 0.
func (h *Handler) nullAsZeroApplies(filter *common.FilterOption, model interface{}, needsCast bool) bool {
if !h.nullAsZero || !filter.NullAsZero || needsCast {
return false
}
return common.IsNumericZeroFilter(model, filter.Column, filter.Value)
}
// applyOrFilterGroup applies a group of OR filters as a single grouped condition
// This ensures OR conditions are properly grouped with parentheses to prevent OR logic from escaping
func (h *Handler) applyOrFilterGroup(query common.SelectQuery, filters []*common.FilterOption, castInfo []ColumnCastInfo, tableName string, model interface{}) common.SelectQuery {
@@ -2614,7 +2639,7 @@ func (h *Handler) applyOrFilterGroup(query common.SelectQuery, filters []*common
qualifiedColumn := rawQualifiedColumn
op := strings.ToLower(filter.Operator)
if op == "like" || op == "ilike" {
if op == "like" || op == "ilike" || op == "not_ilike" {
// Cast to TEXT for LIKE/ILIKE to support date/time/timestamp columns.
// citext columns are left native: they're already case-insensitive and
// casting would defeat a citext index.
@@ -2627,6 +2652,11 @@ func (h *Handler) applyOrFilterGroup(query common.SelectQuery, filters []*common
}
// Build the condition based on operator
if strings.EqualFold(filter.Operator, "eq") && h.nullAsZeroApplies(filter, model, castInfo[i].NeedsCast) {
conditions = append(conditions, fmt.Sprintf("(%s = ? OR %s IS NULL)", rawQualifiedColumn, rawQualifiedColumn))
args = append(args, filter.Value)
continue
}
condition, filterArgs := h.buildFilterCondition(qualifiedColumn, filter, tableName)
if condition != "" {
conditions = append(conditions, condition)
@@ -2665,6 +2695,9 @@ func (h *Handler) buildFilterCondition(qualifiedColumn string, filter *common.Fi
return fmt.Sprintf("%s LIKE ?", qualifiedColumn), []interface{}{filter.Value}
case "ilike":
return fmt.Sprintf("%s ILIKE ?", qualifiedColumn), []interface{}{filter.Value}
case "not_ilike":
rawCol := h.qualifyColumnName(filter.Column, tableName)
return fmt.Sprintf("(%s IS NULL OR %s NOT ILIKE ?)", rawCol, qualifiedColumn), []interface{}{filter.Value}
case "in":
cond, inArgs := common.BuildInCondition(qualifiedColumn, filter.Value)
return cond, inArgs
@@ -3413,6 +3446,8 @@ func (h *Handler) buildFilterSQL(filter *common.FilterOption, tableName string)
return fmt.Sprintf("%s LIKE '%v'", qualifiedColumn, filter.Value)
case "ilike":
return fmt.Sprintf("%s ILIKE '%v'", qualifiedColumn, filter.Value)
case "not_ilike":
return fmt.Sprintf("(%s IS NULL OR CAST(%s AS TEXT) NOT ILIKE '%v')", qualifiedColumn, qualifiedColumn, filter.Value)
case "in":
if values, ok := filter.Value.([]any); ok {
valueStrs := make([]string, len(values))
+4 -1
View File
@@ -432,6 +432,7 @@ func (h *Handler) parseFieldFilter(options *ExtendedRequestOptions, headerKey, v
Operator: "eq",
Value: value,
LogicOperator: "AND", // Default to AND
NullAsZero: true,
})
}
@@ -495,6 +496,8 @@ func (h *Handler) mapSearchOperator(colName, operator, value string) common.Filt
switch operator {
case "contains", "contain", "like":
return common.FilterOption{Column: colName, Operator: "ilike", Value: "%" + value + "%"}
case "notcontains", "notcontain", "notlike":
return common.FilterOption{Column: colName, Operator: "not_ilike", Value: "%" + value + "%"}
case "beginswith", "startswith":
return common.FilterOption{Column: colName, Operator: "ilike", Value: value + "%"}
case "endswith":
@@ -1484,7 +1487,7 @@ func (h *Handler) ValidateAndAdjustFilterForColumnType(filter *common.FilterOpti
// strips the '%' wildcards and hands the driver a non-string argument,
// which fails with "operator does not exist: text ~~* integer" once the
// column is cast to TEXT below.
if op := strings.ToLower(filter.Operator); op == "like" || op == "ilike" {
if op := strings.ToLower(filter.Operator); op == "like" || op == "ilike" || op == "not_ilike" {
if reflection.IsStringType(colType) {
return ColumnCastInfo{NeedsCast: false, IsNumericType: false}
}
+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)
}
}