feature: authentication middlewares #20
|
@ -4,3 +4,10 @@ import "net/http"
|
|||
|
||||
// Adapter to encapsulate incoming requests
|
||||
type Adapter func(http.HandlerFunc) http.HandlerFunc
|
||||
|
||||
// AuthHandlerFunc is http.HandlerFunc with additional Authorization information
|
||||
type AuthHandlerFunc func(Auth, http.ResponseWriter, *http.Request)
|
||||
|
||||
// AuthAdapter to encapsulate incoming request with access to api.Auth
|
||||
// to manage permissions
|
||||
type AuthAdapter func(AuthHandlerFunc) AuthHandlerFunc
|
||||
|
|
|
@ -0,0 +1,62 @@
|
|||
package api
|
||||
|
||||
// Auth can be used by http middleware to
|
||||
// 1) consult required roles in @Auth.Required
|
||||
// 2) update active roles in @Auth.Active
|
||||
type Auth struct {
|
||||
// required roles for this request
|
||||
// - the first dimension of the array reads as a OR
|
||||
// - the second dimension reads as a AND
|
||||
//
|
||||
// Example:
|
||||
// [ [A, B], [C, D] ] reads: roles (A and B) or (C and D) are required
|
||||
//
|
||||
// Warning: must not be mutated
|
||||
Required [][]string
|
||||
|
||||
// active roles to be updated by authentication
|
||||
// procedures (e.g. jwt)
|
||||
Active []string
|
||||
}
|
||||
|
||||
// Granted returns whether the authorization is granted
|
||||
// i.e. Auth.Active fulfills Auth.Required
|
||||
func (a Auth) Granted() bool {
|
||||
var nothingRequired = true
|
||||
|
||||
// first dimension: OR ; at least one is valid
|
||||
for _, required := range a.Required {
|
||||
// empty list
|
||||
if len(required) < 1 {
|
||||
continue
|
||||
}
|
||||
|
||||
nothingRequired = false
|
||||
|
||||
// second dimension: AND ; all required must be fulfilled
|
||||
if a.fulfills(required) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return nothingRequired
|
||||
}
|
||||
|
||||
// returns whether Auth.Active fulfills (contains) all @required roles
|
||||
func (a Auth) fulfills(required []string) bool {
|
||||
for _, requiredRole := range required {
|
||||
var found = false
|
||||
for _, activeRole := range a.Active {
|
||||
if activeRole == requiredRole {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
// missing role -> fail
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
}
|
||||
// all @required are fulfilled
|
||||
return true
|
||||
}
|
|
@ -0,0 +1,108 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCombination(t *testing.T) {
|
||||
tcases := []struct {
|
||||
Name string
|
||||
Required [][]string
|
||||
Active []string
|
||||
Granted bool
|
||||
}{
|
||||
{
|
||||
Name: "no requirement none given",
|
||||
Required: [][]string{},
|
||||
Active: []string{},
|
||||
Granted: true,
|
||||
},
|
||||
{
|
||||
Name: "no requirement 1 given",
|
||||
Required: [][]string{},
|
||||
Active: []string{"a"},
|
||||
Granted: true,
|
||||
},
|
||||
{
|
||||
Name: "no requirement some given",
|
||||
Required: [][]string{},
|
||||
Active: []string{"a", "b"},
|
||||
Granted: true,
|
||||
},
|
||||
|
||||
{
|
||||
Name: "1 required none given",
|
||||
Required: [][]string{{"a"}},
|
||||
Active: []string{},
|
||||
Granted: false,
|
||||
},
|
||||
{
|
||||
Name: "1 required fulfilled",
|
||||
Required: [][]string{{"a"}},
|
||||
Active: []string{"a"},
|
||||
Granted: true,
|
||||
},
|
||||
{
|
||||
Name: "1 required mismatch",
|
||||
Required: [][]string{{"a"}},
|
||||
Active: []string{"b"},
|
||||
Granted: false,
|
||||
},
|
||||
{
|
||||
Name: "2 required none gien",
|
||||
Required: [][]string{{"a", "b"}},
|
||||
Active: []string{},
|
||||
Granted: false,
|
||||
},
|
||||
{
|
||||
Name: "2 required other given",
|
||||
Required: [][]string{{"a", "b"}},
|
||||
Active: []string{"c"},
|
||||
Granted: false,
|
||||
},
|
||||
{
|
||||
Name: "2 required one given",
|
||||
Required: [][]string{{"a", "b"}},
|
||||
Active: []string{"a"},
|
||||
Granted: false,
|
||||
},
|
||||
{
|
||||
Name: "2 required fulfilled",
|
||||
Required: [][]string{{"a", "b"}},
|
||||
Active: []string{"a", "b"},
|
||||
Granted: true,
|
||||
},
|
||||
{
|
||||
Name: "2 or 2 required first fulfilled",
|
||||
Required: [][]string{{"a", "b"}, {"c", "d"}},
|
||||
Active: []string{"a", "b"},
|
||||
Granted: true,
|
||||
},
|
||||
{
|
||||
Name: "2 or 2 required second fulfilled",
|
||||
Required: [][]string{{"a", "b"}, {"c", "d"}},
|
||||
Active: []string{"c", "d"},
|
||||
Granted: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tcase := range tcases {
|
||||
t.Run(tcase.Name, func(t *testing.T) {
|
||||
|
||||
auth := Auth{
|
||||
Required: tcase.Required,
|
||||
Active: tcase.Active,
|
||||
}
|
||||
|
||||
// all right
|
||||
if tcase.Granted == auth.Granted() {
|
||||
return
|
||||
}
|
||||
|
||||
if tcase.Granted && !auth.Granted() {
|
||||
t.Fatalf("expected granted authorization")
|
||||
}
|
||||
t.Fatalf("unexpected granted authorization")
|
||||
})
|
||||
}
|
||||
}
|
16
builder.go
16
builder.go
|
@ -16,6 +16,7 @@ type Builder struct {
|
|||
conf *config.Server
|
||||
handlers []*apiHandler
|
||||
adapters []api.Adapter
|
||||
authAdapters []api.AuthAdapter
|
||||
}
|
||||
|
||||
// represents an api handler (method-pattern combination)
|
||||
|
@ -40,8 +41,8 @@ func (b *Builder) AddType(t datatype.T) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// Use adds an http adapter (middleware)
|
||||
func (b *Builder) Use(adapter api.Adapter) {
|
||||
// With adds an http adapter (middleware)
|
||||
func (b *Builder) With(adapter api.Adapter) {
|
||||
if b.conf == nil {
|
||||
b.conf = &config.Server{}
|
||||
}
|
||||
|
@ -51,6 +52,17 @@ func (b *Builder) Use(adapter api.Adapter) {
|
|||
b.adapters = append(b.adapters, adapter)
|
||||
}
|
||||
|
||||
// WithAuth adds an http adapter with auth capabilities (middleware)
|
||||
func (b *Builder) WithAuth(adapter api.AuthAdapter) {
|
||||
if b.conf == nil {
|
||||
b.conf = &config.Server{}
|
||||
}
|
||||
if b.authAdapters == nil {
|
||||
b.authAdapters = make([]api.AuthAdapter, 0)
|
||||
}
|
||||
b.authAdapters = append(b.authAdapters, adapter)
|
||||
}
|
||||
|
||||
// Setup the builder with its api definition file
|
||||
// panics if already setup
|
||||
func (b *Builder) Setup(r io.Reader) error {
|
||||
|
|
|
@ -1,12 +1,8 @@
|
|||
package aicra
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
|
@ -52,92 +48,6 @@ func TestAddType(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestUse(t *testing.T) {
|
||||
builder := &Builder{}
|
||||
if err := addBuiltinTypes(builder); err != nil {
|
||||
t.Fatalf("unexpected error <%v>", err)
|
||||
}
|
||||
|
||||
// build @n middlewares that take data from context and increment it
|
||||
n := 1024
|
||||
|
||||
type ckey int
|
||||
const key ckey = 0
|
||||
|
||||
middleware := func(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
newr := r
|
||||
|
||||
// first time -> store 1
|
||||
value := r.Context().Value(key)
|
||||
if value == nil {
|
||||
newr = r.WithContext(context.WithValue(r.Context(), key, int(1)))
|
||||
next(w, newr)
|
||||
return
|
||||
}
|
||||
|
||||
// get value and increment
|
||||
cast, ok := value.(int)
|
||||
if !ok {
|
||||
t.Fatalf("value is not an int")
|
||||
}
|
||||
cast++
|
||||
newr = r.WithContext(context.WithValue(r.Context(), key, cast))
|
||||
next(w, newr)
|
||||
}
|
||||
}
|
||||
|
||||
// add middleware @n times
|
||||
for i := 0; i < n; i++ {
|
||||
builder.Use(middleware)
|
||||
}
|
||||
|
||||
config := strings.NewReader(`[ { "method": "GET", "path": "/path", "scope": [[]], "info": "info", "in": {}, "out": {} } ]`)
|
||||
err := builder.Setup(config)
|
||||
if err != nil {
|
||||
t.Fatalf("setup: unexpected error <%v>", err)
|
||||
}
|
||||
|
||||
pathHandler := func(ctx api.Ctx) (*struct{}, api.Err) {
|
||||
// write value from middlewares into response
|
||||
value := ctx.Req.Context().Value(key)
|
||||
if value == nil {
|
||||
t.Fatalf("nothing found in context")
|
||||
}
|
||||
cast, ok := value.(int)
|
||||
if !ok {
|
||||
t.Fatalf("cannot cast context data to int")
|
||||
}
|
||||
// write to response
|
||||
ctx.Res.Write([]byte(fmt.Sprintf("#%d#", cast)))
|
||||
|
||||
return nil, api.ErrSuccess
|
||||
}
|
||||
|
||||
if err := builder.Bind(http.MethodGet, "/path", pathHandler); err != nil {
|
||||
t.Fatalf("bind: unexpected error <%v>", err)
|
||||
}
|
||||
|
||||
handler, err := builder.Build()
|
||||
if err != nil {
|
||||
t.Fatalf("build: unexpected error <%v>", err)
|
||||
}
|
||||
|
||||
response := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/path", &bytes.Buffer{})
|
||||
|
||||
// test request
|
||||
handler.ServeHTTP(response, request)
|
||||
if response.Body == nil {
|
||||
t.Fatalf("response has no body")
|
||||
}
|
||||
token := fmt.Sprintf("#%d#", n)
|
||||
if !strings.Contains(response.Body.String(), token) {
|
||||
t.Fatalf("expected '%s' to be in response <%s>", token, response.Body.String())
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestBind(t *testing.T) {
|
||||
tcases := []struct {
|
||||
Name string
|
||||
|
|
27
handler.go
27
handler.go
|
@ -13,7 +13,7 @@ type Handler Builder
|
|||
|
||||
// ServeHTTP implements http.Handler and wraps it in middlewares (adapters)
|
||||
func (s Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
var h = http.HandlerFunc(s.handleRequest)
|
||||
var h = http.HandlerFunc(s.resolve)
|
||||
|
||||
for _, adapter := range s.adapters {
|
||||
h = adapter(h)
|
||||
|
@ -21,7 +21,7 @@ func (s Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||
h(w, r)
|
||||
}
|
||||
|
||||
func (s Handler) handleRequest(w http.ResponseWriter, r *http.Request) {
|
||||
func (s Handler) resolve(w http.ResponseWriter, r *http.Request) {
|
||||
// 1. find a matching service from config
|
||||
var service = s.conf.Find(r)
|
||||
if service == nil {
|
||||
|
@ -50,6 +50,29 @@ func (s Handler) handleRequest(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
var auth = api.Auth{
|
||||
Required: service.Scope,
|
||||
Active: []string{},
|
||||
}
|
||||
|
||||
// 5. run auth-aware middlewares
|
||||
var h = api.AuthHandlerFunc(func(a api.Auth, w http.ResponseWriter, r *http.Request) {
|
||||
if !a.Granted() {
|
||||
handleError(api.ErrPermission, w, r)
|
||||
return
|
||||
}
|
||||
|
||||
s.handle(input, handler, service, w, r)
|
||||
})
|
||||
|
||||
for _, adapter := range s.authAdapters {
|
||||
h = adapter(h)
|
||||
}
|
||||
h(auth, w, r)
|
||||
|
||||
}
|
||||
|
||||
func (s *Handler) handle(input *reqdata.T, handler *apiHandler, service *config.Service, w http.ResponseWriter, r *http.Request) {
|
||||
// 5. pass execution to the handler
|
||||
ctx := api.Ctx{Res: w, Req: r}
|
||||
var outData, outErr = handler.dyn.Handle(ctx, input.Data)
|
||||
|
|
|
@ -0,0 +1,241 @@
|
|||
package aicra
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.xdrm.io/go/aicra/api"
|
||||
)
|
||||
|
||||
func TestWith(t *testing.T) {
|
||||
builder := &Builder{}
|
||||
if err := addBuiltinTypes(builder); err != nil {
|
||||
t.Fatalf("unexpected error <%v>", err)
|
||||
}
|
||||
|
||||
// build @n middlewares that take data from context and increment it
|
||||
n := 1024
|
||||
|
||||
type ckey int
|
||||
const key ckey = 0
|
||||
|
||||
middleware := func(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
newr := r
|
||||
|
||||
// first time -> store 1
|
||||
value := r.Context().Value(key)
|
||||
if value == nil {
|
||||
newr = r.WithContext(context.WithValue(r.Context(), key, int(1)))
|
||||
next(w, newr)
|
||||
return
|
||||
}
|
||||
|
||||
// get value and increment
|
||||
cast, ok := value.(int)
|
||||
if !ok {
|
||||
t.Fatalf("value is not an int")
|
||||
}
|
||||
cast++
|
||||
newr = r.WithContext(context.WithValue(r.Context(), key, cast))
|
||||
next(w, newr)
|
||||
}
|
||||
}
|
||||
|
||||
// add middleware @n times
|
||||
for i := 0; i < n; i++ {
|
||||
builder.With(middleware)
|
||||
}
|
||||
|
||||
config := strings.NewReader(`[ { "method": "GET", "path": "/path", "scope": [[]], "info": "info", "in": {}, "out": {} } ]`)
|
||||
err := builder.Setup(config)
|
||||
if err != nil {
|
||||
t.Fatalf("setup: unexpected error <%v>", err)
|
||||
}
|
||||
|
||||
pathHandler := func(ctx api.Ctx) (*struct{}, api.Err) {
|
||||
// write value from middlewares into response
|
||||
value := ctx.Req.Context().Value(key)
|
||||
if value == nil {
|
||||
t.Fatalf("nothing found in context")
|
||||
}
|
||||
cast, ok := value.(int)
|
||||
if !ok {
|
||||
t.Fatalf("cannot cast context data to int")
|
||||
}
|
||||
// write to response
|
||||
ctx.Res.Write([]byte(fmt.Sprintf("#%d#", cast)))
|
||||
|
||||
return nil, api.ErrSuccess
|
||||
}
|
||||
|
||||
if err := builder.Bind(http.MethodGet, "/path", pathHandler); err != nil {
|
||||
t.Fatalf("bind: unexpected error <%v>", err)
|
||||
}
|
||||
|
||||
handler, err := builder.Build()
|
||||
if err != nil {
|
||||
t.Fatalf("build: unexpected error <%v>", err)
|
||||
}
|
||||
|
||||
response := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/path", &bytes.Buffer{})
|
||||
|
||||
// test request
|
||||
handler.ServeHTTP(response, request)
|
||||
if response.Body == nil {
|
||||
t.Fatalf("response has no body")
|
||||
}
|
||||
token := fmt.Sprintf("#%d#", n)
|
||||
if !strings.Contains(response.Body.String(), token) {
|
||||
t.Fatalf("expected '%s' to be in response <%s>", token, response.Body.String())
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestWithAuth(t *testing.T) {
|
||||
|
||||
tt := []struct {
|
||||
name string
|
||||
manifest string
|
||||
permissions []string
|
||||
granted bool
|
||||
}{
|
||||
{
|
||||
name: "provide only requirement A",
|
||||
manifest: `[ { "method": "GET", "path": "/path", "scope": [["A"]], "info": "info", "in": {}, "out": {} } ]`,
|
||||
permissions: []string{"A"},
|
||||
granted: true,
|
||||
},
|
||||
{
|
||||
name: "missing requirement",
|
||||
manifest: `[ { "method": "GET", "path": "/path", "scope": [["A"]], "info": "info", "in": {}, "out": {} } ]`,
|
||||
permissions: []string{},
|
||||
granted: false,
|
||||
},
|
||||
{
|
||||
name: "missing requirements",
|
||||
manifest: `[ { "method": "GET", "path": "/path", "scope": [["A", "B"]], "info": "info", "in": {}, "out": {} } ]`,
|
||||
permissions: []string{},
|
||||
granted: false,
|
||||
},
|
||||
{
|
||||
name: "missing some requirements",
|
||||
manifest: `[ { "method": "GET", "path": "/path", "scope": [["A", "B"]], "info": "info", "in": {}, "out": {} } ]`,
|
||||
permissions: []string{"A"},
|
||||
granted: false,
|
||||
},
|
||||
{
|
||||
name: "provide requirements",
|
||||
manifest: `[ { "method": "GET", "path": "/path", "scope": [["A", "B"]], "info": "info", "in": {}, "out": {} } ]`,
|
||||
permissions: []string{"A", "B"},
|
||||
granted: true,
|
||||
},
|
||||
{
|
||||
name: "missing OR requirements",
|
||||
manifest: `[ { "method": "GET", "path": "/path", "scope": [["A"], ["B"]], "info": "info", "in": {}, "out": {} } ]`,
|
||||
permissions: []string{"C"},
|
||||
granted: false,
|
||||
},
|
||||
{
|
||||
name: "provide 1 OR requirement",
|
||||
manifest: `[ { "method": "GET", "path": "/path", "scope": [["A"], ["B"]], "info": "info", "in": {}, "out": {} } ]`,
|
||||
permissions: []string{"A"},
|
||||
granted: true,
|
||||
},
|
||||
{
|
||||
name: "provide both OR requirements",
|
||||
manifest: `[ { "method": "GET", "path": "/path", "scope": [["A"], ["B"]], "info": "info", "in": {}, "out": {} } ]`,
|
||||
permissions: []string{"A", "B"},
|
||||
granted: true,
|
||||
},
|
||||
{
|
||||
name: "missing composite OR requirements",
|
||||
manifest: `[ { "method": "GET", "path": "/path", "scope": [["A", "B"], ["C", "D"]], "info": "info", "in": {}, "out": {} } ]`,
|
||||
permissions: []string{},
|
||||
granted: false,
|
||||
},
|
||||
{
|
||||
name: "missing partial composite OR requirements",
|
||||
manifest: `[ { "method": "GET", "path": "/path", "scope": [["A", "B"], ["C", "D"]], "info": "info", "in": {}, "out": {} } ]`,
|
||||
permissions: []string{"A", "C"},
|
||||
granted: false,
|
||||
},
|
||||
{
|
||||
name: "provide 1 composite OR requirement",
|
||||
manifest: `[ { "method": "GET", "path": "/path", "scope": [["A", "B"], ["C", "D"]], "info": "info", "in": {}, "out": {} } ]`,
|
||||
permissions: []string{"A", "B", "C"},
|
||||
granted: true,
|
||||
},
|
||||
{
|
||||
name: "provide both composite OR requirements",
|
||||
manifest: `[ { "method": "GET", "path": "/path", "scope": [["A", "B"], ["C", "D"]], "info": "info", "in": {}, "out": {} } ]`,
|
||||
permissions: []string{"A", "B", "C", "D"},
|
||||
granted: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tt {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
builder := &Builder{}
|
||||
if err := addBuiltinTypes(builder); err != nil {
|
||||
t.Fatalf("unexpected error <%v>", err)
|
||||
}
|
||||
|
||||
// tester middleware (last executed)
|
||||
builder.WithAuth(func(next api.AuthHandlerFunc) api.AuthHandlerFunc {
|
||||
return func(a api.Auth, w http.ResponseWriter, r *http.Request) {
|
||||
if a.Granted() == tc.granted {
|
||||
return
|
||||
}
|
||||
if a.Granted() {
|
||||
t.Fatalf("unexpected granted auth")
|
||||
} else {
|
||||
t.Fatalf("expected granted auth")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
builder.WithAuth(func(next api.AuthHandlerFunc) api.AuthHandlerFunc {
|
||||
return func(a api.Auth, w http.ResponseWriter, r *http.Request) {
|
||||
a.Active = tc.permissions
|
||||
next(a, w, r)
|
||||
}
|
||||
})
|
||||
|
||||
err := builder.Setup(strings.NewReader(tc.manifest))
|
||||
if err != nil {
|
||||
t.Fatalf("setup: unexpected error <%v>", err)
|
||||
}
|
||||
|
||||
pathHandler := func(ctx api.Ctx) (*struct{}, api.Err) {
|
||||
return nil, api.ErrNotImplemented
|
||||
}
|
||||
|
||||
if err := builder.Bind(http.MethodGet, "/path", pathHandler); err != nil {
|
||||
t.Fatalf("bind: unexpected error <%v>", err)
|
||||
}
|
||||
|
||||
handler, err := builder.Build()
|
||||
if err != nil {
|
||||
t.Fatalf("build: unexpected error <%v>", err)
|
||||
}
|
||||
|
||||
response := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/path", &bytes.Buffer{})
|
||||
|
||||
// test request
|
||||
handler.ServeHTTP(response, request)
|
||||
if response.Body == nil {
|
||||
t.Fatalf("response has no body")
|
||||
}
|
||||
|
||||
})
|
||||
}
|
||||
|
||||
}
|
Loading…
Reference in New Issue