diff --git a/Makefile b/Makefile index c478ccc..c5763c4 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ FLAGS = -ldflags "\ " test: - go test -cover ./... + go test ./... run: STATICS=statics/www/ go run $(FLAGS) ./cmd/inceptiondb/... diff --git a/api/0_build.go b/api/0_build.go index d238bba..961a05a 100644 --- a/api/0_build.go +++ b/api/0_build.go @@ -12,13 +12,19 @@ import ( "github.com/fulldump/inceptiondb/statics" ) -func Build(s service.Servicer, staticsDir, version string) *box.B { // TODO: remove datadir +func Build(s service.Servicer, staticsDir, version, apiKey, apiSecret string, hideUI bool) *box.B { // TODO: remove datadir b := box.NewBox() v1 := b.Resource("/v1") v1.WithInterceptors(box.SetResponseHeader("Content-Type", "application/json")) + if apiKey != "" && apiSecret != "" { + v1.WithInterceptors( + Authenticate(apiKey, apiSecret), + ) + } + apicollectionv1.BuildV1Collection(v1, s). WithInterceptors( injectServicer(s), @@ -58,11 +64,13 @@ func Build(s service.Servicer, staticsDir, version string) *box.B { // TODO: rem return spec }) - // Mount statics - b.Resource("/*"). - WithActions( - box.Get(statics.ServeStatics(staticsDir)).WithName("serveStatics"), - ) + if !hideUI { + // Mount statics + b.Resource("/*"). + WithActions( + box.Get(statics.ServeStatics(staticsDir)).WithName("serveStatics"), + ) + } return b } diff --git a/api/0_helpers.go b/api/0_helpers.go index a313a0f..b4dad9c 100644 --- a/api/0_helpers.go +++ b/api/0_helpers.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "io" "net/http" "github.com/fulldump/box" @@ -50,6 +51,10 @@ func (p PrettyError) MarshalJSON() ([]byte, error) { }) } +func (p PrettyError) MarshalTo(w io.Writer) error { + return json.NewEncoder(w).Encode(p) +} + func InterceptorUnavailable(db *database.Database) box.I { return func(next box.H) box.H { return func(ctx context.Context) { @@ -79,6 +84,17 @@ func PrettyErrorInterceptor(next box.H) box.H { } w := box.GetResponse(ctx) + if err == ErrUnauthorized { + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(map[string]interface{}{ + "error": map[string]interface{}{ + "message": err.Error(), + "description": fmt.Sprintf("user is not authenticated"), + }, + }) + return + } + if err == box.ErrResourceNotFound { w.WriteHeader(http.StatusNotFound) json.NewEncoder(w).Encode(map[string]interface{}{ diff --git a/api/0_interceptors.go b/api/0_interceptors.go index 9a9fb7c..c14d2fb 100644 --- a/api/0_interceptors.go +++ b/api/0_interceptors.go @@ -2,6 +2,7 @@ package api import ( "context" + "errors" "fmt" "log" "net/http" @@ -12,6 +13,30 @@ import ( "github.com/fulldump/box" ) +var ErrUnauthorized = errors.New("unauthorized") + +func Authenticate(apiKey, apiSecret string) box.I { + return func(next box.H) box.H { + return func(ctx context.Context) { + + if apiKey == "" && apiSecret == "" { + next(ctx) + return + } + + r := box.GetRequest(ctx) + key := r.Header.Get("X-Api-Key") + secret := r.Header.Get("X-Api-Secret") + + if key != apiKey || secret != apiSecret { + box.SetError(ctx, ErrUnauthorized) + return + } + next(ctx) + } + } +} + func RecoverFromPanic(next box.H) box.H { return func(ctx context.Context) { defer func() { diff --git a/api/acceptance_test.go b/api/acceptance_test.go index 76e2311..c4a8dd0 100644 --- a/api/acceptance_test.go +++ b/api/acceptance_test.go @@ -23,7 +23,7 @@ func TestAcceptance(t *testing.T) { s := service.NewService(db) - b := Build(s, "", "test") + b := Build(s, "", "test", "", "", false) b.WithInterceptors( InterceptorUnavailable(db), RecoverFromPanic, diff --git a/api/auth_test.go b/api/auth_test.go new file mode 100644 index 0000000..da09803 --- /dev/null +++ b/api/auth_test.go @@ -0,0 +1,70 @@ +package api + +import ( + "net/http" + "testing" + + "github.com/fulldump/apitest" + "github.com/fulldump/biff" + + "github.com/fulldump/inceptiondb/database" + "github.com/fulldump/inceptiondb/service" +) + +func TestAuthentication(t *testing.T) { + + biff.Alternative("Authentication", func(a *biff.A) { + + db := database.NewDatabase(&database.Config{ + Dir: t.TempDir(), + }) + + s := service.NewService(db) + + apiKey := "my-key" + apiSecret := "my-secret" + + b := Build(s, "", "test", apiKey, apiSecret, false) + b.WithInterceptors( + PrettyErrorInterceptor, + ) + + api := apitest.NewWithHandler(b) + + a.Alternative("Missing headers", func(a *biff.A) { + resp := api.Request("GET", "/v1/collections").Do() + biff.AssertEqual(resp.StatusCode, http.StatusUnauthorized) + biff.AssertEqualJson(resp.BodyJson(), map[string]any{ + "error": map[string]any{ + "message": "unauthorized", + "description": "user is not authenticated", + }, + }) + }) + + a.Alternative("Wrong Key", func(a *biff.A) { + resp := api.Request("GET", "/v1/collections"). + WithHeader("X-Api-Key", "wrong-key"). + WithHeader("X-Api-Secret", apiSecret). + Do() + biff.AssertEqual(resp.StatusCode, http.StatusUnauthorized) + }) + + a.Alternative("Wrong Secret", func(a *biff.A) { + resp := api.Request("GET", "/v1/collections"). + WithHeader("X-Api-Key", apiKey). + WithHeader("X-Api-Secret", "wrong-secret"). + Do() + biff.AssertEqual(resp.StatusCode, http.StatusUnauthorized) + }) + + a.Alternative("Correct credentials", func(a *biff.A) { + resp := api.Request("GET", "/v1/collections"). + WithHeader("X-Api-Key", apiKey). + WithHeader("X-Api-Secret", apiSecret). + Do() + biff.AssertEqual(resp.StatusCode, http.StatusOK) + }) + + }) +} diff --git a/bootstrap/bootstrap.go b/bootstrap/bootstrap.go index 3dbd859..b9fe9da 100644 --- a/bootstrap/bootstrap.go +++ b/bootstrap/bootstrap.go @@ -28,7 +28,8 @@ func Bootstrap(c *configuration.Configuration) (start, stop func()) { Dir: c.Dir, }) - b := api.Build(service.NewService(db), c.Statics, VERSION) + // b := api.Build(service.NewService(db), c.Statics, VERSION) + b := api.Build(service.NewService(db), c.Statics, VERSION, c.ApiKey, c.ApiSecret, c.HideUI) if c.EnableCompression { b.WithInterceptors(api.Compression) } diff --git a/cmd/inceptiondb/main.go b/cmd/inceptiondb/main.go index 5119084..53a0489 100644 --- a/cmd/inceptiondb/main.go +++ b/cmd/inceptiondb/main.go @@ -3,6 +3,7 @@ package main import ( "encoding/json" "fmt" + "log" "os" "github.com/fulldump/goconfig" @@ -42,6 +43,10 @@ func main() { e.Encode(c) } + if c.ApiKey == "" || c.ApiSecret == "" { + log.Println("ApiKey and ApiSecret are not set, authentication will be disabled") + } + start, _ := bootstrap.Bootstrap(c) start() } diff --git a/configuration/configuration.go b/configuration/configuration.go index 9032ca9..503b16a 100644 --- a/configuration/configuration.go +++ b/configuration/configuration.go @@ -10,4 +10,7 @@ type Configuration struct { ShowBanner bool `usage:"show big banner"` ShowConfig bool `usage:"print config"` EnableCompression bool `usage:"enable http compression (gzip)"` + ApiKey string `usage:"API Key for v2 authentication"` + ApiSecret string `usage:"API Secret for v2 authentication"` + HideUI bool `usage:"do not serve UI"` } diff --git a/statics/www/index.html b/statics/www/index.html index eb989ae..ff50e31 100644 --- a/statics/www/index.html +++ b/statics/www/index.html @@ -1,24 +1,29 @@ +