mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-10-13 16:56:28 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5dbe187915 | ||
|
|
d3252f3d02 | ||
|
|
fbfc1aadb5 |
@@ -139,6 +139,10 @@ func BuildJSONFilterCondition(model interface{}, tableAlias, token, operator str
|
|||||||
return fmt.Sprintf("%s LIKE ?", colExpr), prepend(value), true
|
return fmt.Sprintf("%s LIKE ?", colExpr), prepend(value), true
|
||||||
case "ilike":
|
case "ilike":
|
||||||
return fmt.Sprintf("%s ILIKE ?", colExpr), prepend(value), true
|
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":
|
case "in":
|
||||||
inCond, inArgs := BuildInCondition(colExpr, value)
|
inCond, inArgs := BuildInCondition(colExpr, value)
|
||||||
if inCond == "" {
|
if inCond == "" {
|
||||||
|
|||||||
@@ -93,6 +93,11 @@ func TestBuildJSONFilterCondition(t *testing.T) {
|
|||||||
wantCond: `("data" #>> ?::text[]) ILIKE ?`,
|
wantCond: `("data" #>> ?::text[]) ILIKE ?`,
|
||||||
wantArgs: []interface{}{"{city}", "%la%"},
|
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"},
|
name: "in", token: "data->>'tier'", operator: "in", value: []string{"a", "b"},
|
||||||
wantCond: `("data" #>> ?::text[]) IN (?,?)`,
|
wantCond: `("data" #>> ?::text[]) IN (?,?)`,
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -87,6 +87,10 @@ type FilterOption struct {
|
|||||||
Operator string `json:"operator"`
|
Operator string `json:"operator"`
|
||||||
Value interface{} `json:"value"`
|
Value interface{} `json:"value"`
|
||||||
LogicOperator string `json:"logic_operator"` // "AND" or "OR" - how this filter combines with previous filters
|
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 {
|
type SortOption struct {
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ type Handler struct {
|
|||||||
openAPIGenerator func() (string, error)
|
openAPIGenerator func() (string, error)
|
||||||
defaultSort map[string][]common.SortOption
|
defaultSort map[string][]common.SortOption
|
||||||
disallowNulls bool
|
disallowNulls bool
|
||||||
|
nullAsZero bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHandler creates a new API handler with database and registry abstractions
|
// NewHandler creates a new API handler with database and registry abstractions
|
||||||
@@ -61,6 +62,12 @@ func (h *Handler) SetDisallowNulls(disallow bool) {
|
|||||||
h.disallowNulls = disallow
|
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
|
// 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)
|
// If not set, the handler will simply return (pass through to next route)
|
||||||
func (h *Handler) SetFallbackHandler(fallback FallbackHandler) {
|
func (h *Handler) SetFallbackHandler(fallback FallbackHandler) {
|
||||||
@@ -2026,7 +2033,11 @@ func (h *Handler) buildFilterConditionAlias(filter common.FilterOption, model in
|
|||||||
|
|
||||||
switch filter.Operator {
|
switch filter.Operator {
|
||||||
case "eq", "=":
|
case "eq", "=":
|
||||||
|
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)
|
condition = fmt.Sprintf("%s = ?", filter.Column)
|
||||||
|
}
|
||||||
args = []interface{}{filter.Value}
|
args = []interface{}{filter.Value}
|
||||||
case "neq", "!=", "<>":
|
case "neq", "!=", "<>":
|
||||||
condition = fmt.Sprintf("%s != ?", filter.Column)
|
condition = fmt.Sprintf("%s != ?", filter.Column)
|
||||||
@@ -2111,7 +2122,11 @@ func (h *Handler) applyFilter(query common.SelectQuery, filter common.FilterOpti
|
|||||||
|
|
||||||
switch filter.Operator {
|
switch filter.Operator {
|
||||||
case "eq", "=":
|
case "eq", "=":
|
||||||
|
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)
|
condition = fmt.Sprintf("%s = ?", filter.Column)
|
||||||
|
}
|
||||||
args = []interface{}{filter.Value}
|
args = []interface{}{filter.Value}
|
||||||
case "neq", "!=", "<>":
|
case "neq", "!=", "<>":
|
||||||
condition = fmt.Sprintf("%s != ?", filter.Column)
|
condition = fmt.Sprintf("%s != ?", filter.Column)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -89,6 +89,7 @@ Search with specific operators (AND logic).
|
|||||||
|
|
||||||
**Supported Operators:**
|
**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.
|
- `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)
|
- `beginswith` / `startswith` - Starts with (case-insensitive)
|
||||||
- `endswith` - Ends with (case-insensitive)
|
- `endswith` - Ends with (case-insensitive)
|
||||||
- `equals` / `eq` - Exact match
|
- `equals` / `eq` - Exact match
|
||||||
|
|||||||
@@ -173,3 +173,146 @@ func TestValidateAndAdjustFilterForColumnType_NumericColumn_Ilike(t *testing.T)
|
|||||||
t.Fatalf("args = %#v, want [\"%%345346346%%\"]", c.args)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ type Handler struct {
|
|||||||
openAPIGenerator func() (string, error)
|
openAPIGenerator func() (string, error)
|
||||||
defaultSort map[string][]common.SortOption
|
defaultSort map[string][]common.SortOption
|
||||||
disallowNulls bool
|
disallowNulls bool
|
||||||
|
nullAsZero bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHandler creates a new API handler with database and registry abstractions
|
// 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,
|
db: db,
|
||||||
registry: registry,
|
registry: registry,
|
||||||
hooks: NewHookRegistry(),
|
hooks: NewHookRegistry(),
|
||||||
|
// x-fieldfilter on a numeric column with 0 also matches NULLs by default
|
||||||
|
nullAsZero: true,
|
||||||
}
|
}
|
||||||
// Initialize nested processor
|
// Initialize nested processor
|
||||||
handler.nestedProcessor = common.NewNestedCUDProcessor(db, registry, handler)
|
handler.nestedProcessor = common.NewNestedCUDProcessor(db, registry, handler)
|
||||||
@@ -68,6 +71,12 @@ func (h *Handler) SetDisallowNulls(disallow bool) {
|
|||||||
h.disallowNulls = disallow
|
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
|
// 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)
|
// If not set, the handler will simply return (pass through to next route)
|
||||||
func (h *Handler) SetFallbackHandler(fallback FallbackHandler) {
|
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) {
|
switch strings.ToLower(filter.Operator) {
|
||||||
case "eq", "equals":
|
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)
|
return applyWhere(fmt.Sprintf("%s = ?", qualifiedColumn), filter.Value)
|
||||||
case "neq", "not_equals", "ne":
|
case "neq", "not_equals", "ne":
|
||||||
return applyWhere(fmt.Sprintf("%s != ?", qualifiedColumn), filter.Value)
|
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
|
// columns are compared natively (see likeColumn above) since citext is
|
||||||
// already case-insensitive.
|
// already case-insensitive.
|
||||||
return applyWhere(fmt.Sprintf("%s ILIKE ?", likeColumn), filter.Value)
|
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":
|
case "in":
|
||||||
cond, inArgs := common.BuildInCondition(qualifiedColumn, filter.Value)
|
cond, inArgs := common.BuildInCondition(qualifiedColumn, filter.Value)
|
||||||
if cond == "" {
|
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
|
// 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
|
// 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 {
|
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
|
qualifiedColumn := rawQualifiedColumn
|
||||||
|
|
||||||
op := strings.ToLower(filter.Operator)
|
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.
|
// Cast to TEXT for LIKE/ILIKE to support date/time/timestamp columns.
|
||||||
// citext columns are left native: they're already case-insensitive and
|
// citext columns are left native: they're already case-insensitive and
|
||||||
// casting would defeat a citext index.
|
// 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
|
// 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)
|
condition, filterArgs := h.buildFilterCondition(qualifiedColumn, filter, tableName)
|
||||||
if condition != "" {
|
if condition != "" {
|
||||||
conditions = append(conditions, 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}
|
return fmt.Sprintf("%s LIKE ?", qualifiedColumn), []interface{}{filter.Value}
|
||||||
case "ilike":
|
case "ilike":
|
||||||
return fmt.Sprintf("%s ILIKE ?", qualifiedColumn), []interface{}{filter.Value}
|
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":
|
case "in":
|
||||||
cond, inArgs := common.BuildInCondition(qualifiedColumn, filter.Value)
|
cond, inArgs := common.BuildInCondition(qualifiedColumn, filter.Value)
|
||||||
return cond, inArgs
|
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)
|
return fmt.Sprintf("%s LIKE '%v'", qualifiedColumn, filter.Value)
|
||||||
case "ilike":
|
case "ilike":
|
||||||
return fmt.Sprintf("%s ILIKE '%v'", qualifiedColumn, filter.Value)
|
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":
|
case "in":
|
||||||
if values, ok := filter.Value.([]any); ok {
|
if values, ok := filter.Value.([]any); ok {
|
||||||
valueStrs := make([]string, len(values))
|
valueStrs := make([]string, len(values))
|
||||||
|
|||||||
@@ -432,6 +432,7 @@ func (h *Handler) parseFieldFilter(options *ExtendedRequestOptions, headerKey, v
|
|||||||
Operator: "eq",
|
Operator: "eq",
|
||||||
Value: value,
|
Value: value,
|
||||||
LogicOperator: "AND", // Default to AND
|
LogicOperator: "AND", // Default to AND
|
||||||
|
NullAsZero: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -495,6 +496,8 @@ func (h *Handler) mapSearchOperator(colName, operator, value string) common.Filt
|
|||||||
switch operator {
|
switch operator {
|
||||||
case "contains", "contain", "like":
|
case "contains", "contain", "like":
|
||||||
return common.FilterOption{Column: colName, Operator: "ilike", Value: "%" + value + "%"}
|
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":
|
case "beginswith", "startswith":
|
||||||
return common.FilterOption{Column: colName, Operator: "ilike", Value: value + "%"}
|
return common.FilterOption{Column: colName, Operator: "ilike", Value: value + "%"}
|
||||||
case "endswith":
|
case "endswith":
|
||||||
@@ -1484,7 +1487,7 @@ func (h *Handler) ValidateAndAdjustFilterForColumnType(filter *common.FilterOpti
|
|||||||
// strips the '%' wildcards and hands the driver a non-string argument,
|
// strips the '%' wildcards and hands the driver a non-string argument,
|
||||||
// which fails with "operator does not exist: text ~~* integer" once the
|
// which fails with "operator does not exist: text ~~* integer" once the
|
||||||
// column is cast to TEXT below.
|
// 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) {
|
if reflection.IsStringType(colType) {
|
||||||
return ColumnCastInfo{NeedsCast: false, IsNumericType: false}
|
return ColumnCastInfo{NeedsCast: false, IsNumericType: false}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
### Helper Functions
|
||||||
|
|
||||||
Extract user information from context:
|
Extract user information from context:
|
||||||
|
|||||||
@@ -170,6 +170,7 @@ func (a *DatabaseAuthenticator) SetAuthenticateCallback(fn func(r *http.Request)
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *DatabaseAuthenticator) Login(ctx context.Context, req LoginRequest) (*LoginResponse, error) {
|
func (a *DatabaseAuthenticator) Login(ctx context.Context, req LoginRequest) (*LoginResponse, error) {
|
||||||
|
req.Claims = enrichClaims(ctx, req.Claims)
|
||||||
return a.auth().Login(ctx, req)
|
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
|
// 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.
|
// 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) {
|
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
|
// 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) auth() lookup.AuthStore { return a.src.get().Auth }
|
||||||
|
|
||||||
func (a *JWTAuthenticator) Login(ctx context.Context, req LoginRequest) (*LoginResponse, error) {
|
func (a *JWTAuthenticator) Login(ctx context.Context, req LoginRequest) (*LoginResponse, error) {
|
||||||
|
req.Claims = enrichClaims(ctx, req.Claims)
|
||||||
return a.auth().JWTLogin(ctx, req)
|
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 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
|
// GetPasskeyCredentials returns all passkey credentials for a user
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user