Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

HTTP Basic auth support #6

Merged
merged 7 commits into from
Nov 18, 2024
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 29 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,7 @@ It currently does the following things:
hashes, etc.
- **Actions**: Ability to execute shell commands via API
- **Configuration** through file uploads

Future features:

- Set a password for http-basic-auth (persisted, for all future requests)
- **HTTP Basic Auth** for API endpoints

---

Expand Down Expand Up @@ -93,3 +90,31 @@ $ go run cmd/system-api/main.go --config systemapi-config.toml
# Execute the example action
$ curl -v -X POST -d "@README.md" localhost:3535/api/v1/file-upload/testfile
```

## HTTP Basic Auth

All API endpoints can be protected with HTTP Basic Auth. The secret needs to be set once, either via file or via API.
If set via API, it will be persisted in a file specified in the config file.

The config file ([systemapi-config.toml](./systemapi-config.toml)) includes a `basic_auth_secret_path`.
- If this file is specified but doesn't exist, system-api will not start
- If the file exists and is empty, then the APIs are unauthenticated until a secret is set
- If the file exists and is not empty, then the APIs are authenticated with the secret in this file

```bash
# Set `basic_auth_secret_path` in the config file and create it empty
touch .basic-auth-secret
vi systemapi-config.toml

# Start the server,
$ go run cmd/system-api/main.go --config systemapi-config.toml

# Initially, requests are unauthenticated
$ curl localhost:3535/api/v1/livez

# Set the basic auth secret
$ curl -d "foobar" localhost:3535/api/v1/set-basic-auth

# Now requests are authenticated
$ curl -u admin:foobar -v localhost:3535/livez
```
1 change: 1 addition & 0 deletions cmd/system-api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ func runCli(cCtx *cli.Context) (err error) {
}
server, err := systemapi.NewServer(cfg)
if err != nil {
log.Error("Error creating server", "err", err)
return err
}
go server.Start()
Expand Down
3 changes: 3 additions & 0 deletions systemapi-config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ pipe_file = "pipe.fifo"
log_json = false
log_debug = true

# The path to the secret file used for basic auth
basic_auth_secret_path = "/tmp/basic_auth_secret"

[actions]
# reboot = "reboot"
# rbuilder_restart = "/etc/init.d/rbuilder restart"
Expand Down
2 changes: 2 additions & 0 deletions systemapi/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ type systemAPIConfigGeneral struct {
PipeFile string `toml:"pipe_file"`
LogJSON bool `toml:"log_json"`
LogDebug bool `toml:"log_debug"`

BasicAuthSecretPath string `toml:"basic_auth_secret_path"`
}

type SystemAPIConfig struct {
Expand Down
44 changes: 44 additions & 0 deletions systemapi/middleware.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package systemapi

import (
"crypto/subtle"
"fmt"
"net/http"
)

// BasicAuth implements a simple middleware handler for adding basic http auth to a route.
func BasicAuth(realm string, getCreds func() map[string]string) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Loading credentials dynamically because they can be updated at runtime
creds := getCreds()

// If no credentials are set, just pass through (unauthenticated)
if len(creds) == 0 {
next.ServeHTTP(w, r)
return
}

// Load credentials from request
user, pass, ok := r.BasicAuth()
if !ok {
basicAuthFailed(w, realm)
return
}

// Compare to allowed credentials
credPass, credUserOk := creds[user]
if !credUserOk || subtle.ConstantTimeCompare([]byte(pass), []byte(credPass)) != 1 {
basicAuthFailed(w, realm)
return
}

next.ServeHTTP(w, r)
})
}
}

func basicAuthFailed(w http.ResponseWriter, realm string) {
w.Header().Add("WWW-Authenticate", fmt.Sprintf(`Basic realm="%s"`, realm))
w.WriteHeader(http.StatusUnauthorized)
}
63 changes: 63 additions & 0 deletions systemapi/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ type Server struct {

events []Event
eventsLock sync.RWMutex

basicAuthSecret string
}

func NewServer(cfg *HTTPServerConfig) (srv *Server, err error) {
Expand All @@ -55,6 +57,26 @@ func NewServer(cfg *HTTPServerConfig) (srv *Server, err error) {
events: make([]Event, 0),
}

if cfg.Config.General.BasicAuthSecretPath != "" {
// Abort if the file does not exist
if _, err := os.Stat(cfg.Config.General.BasicAuthSecretPath); os.IsNotExist(err) {
return nil, fmt.Errorf("basic auth secret file does not exist: %s", cfg.Config.General.BasicAuthSecretPath)
}

// Read the secret from the file
secret, err := os.ReadFile(cfg.Config.General.BasicAuthSecretPath)
if err != nil {
return nil, fmt.Errorf("failed to read basic auth secret file: %w", err)
}

if len(secret) == 0 {
cfg.Log.Info("Empty basic auth file loaded", "file", cfg.Config.General.BasicAuthSecretPath)
} else {
cfg.Log.Info("Basic auth enabled", "file", cfg.Config.General.BasicAuthSecretPath)
}
srv.basicAuthSecret = string(secret)
}

if cfg.Config.General.PipeFile != "" {
os.Remove(cfg.Config.General.PipeFile)
err := syscall.Mknod(cfg.Config.General.PipeFile, syscall.S_IFIFO|0o666, 0)
Expand All @@ -80,12 +102,16 @@ func (s *Server) getRouter() http.Handler {

mux.Use(httplog.RequestLogger(s.log))
mux.Use(middleware.Recoverer)
mux.Use(BasicAuth("system-api", s.getBasicAuthCreds))
metachris marked this conversation as resolved.
Show resolved Hide resolved

mux.Get("/", s.handleLivenessCheck)
mux.Get("/livez", s.handleLivenessCheck)
mux.Get("/api/v1/new_event", s.handleNewEvent)
mux.Get("/api/v1/events", s.handleGetEvents)
mux.Get("/logs", s.handleGetLogs)

mux.Post("/api/v1/set-basic-auth", s.handleSetBasicAuthCreds)

mux.Get("/api/v1/actions/{action}", s.handleAction)
mux.Post("/api/v1/file-upload/{file}", s.handleFileUpload)

Expand Down Expand Up @@ -285,3 +311,40 @@ func (s *Server) handleFileUpload(w http.ResponseWriter, r *http.Request) {
s.addInternalEvent(fmt.Sprintf("file upload success: %s = %s - content: %d bytes", fileArg, filename, len(content)))
w.WriteHeader(http.StatusOK)
}

func (s *Server) getBasicAuthCreds() map[string]string {
// dynamic because can be set at runtime
resp := make(map[string]string)
if s.basicAuthSecret != "" {
resp["admin"] = s.basicAuthSecret
}
return resp
}

func (s *Server) handleSetBasicAuthCreds(w http.ResponseWriter, r *http.Request) {
if s.cfg.Config.General.BasicAuthSecretPath == "" {
s.log.Warn("Basic auth secret path not set")
w.WriteHeader(http.StatusNotImplemented)
return
}

// read secret from payload
secret, err := io.ReadAll(r.Body)
if err != nil {
s.log.Error("Failed to read secret from payload", "err", err)
w.WriteHeader(http.StatusInternalServerError)
return
}

// write secret to file
err = os.WriteFile(s.cfg.Config.General.BasicAuthSecretPath, secret, 0o600)
if err != nil {
s.log.Error("Failed to write secret to file", "err", err)
w.WriteHeader(http.StatusInternalServerError)
return
}

s.basicAuthSecret = string(secret)
s.log.Info("Basic auth secret updated")
w.WriteHeader(http.StatusOK)
}
111 changes: 111 additions & 0 deletions systemapi/server_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
package systemapi

import (
"bytes"
"io"
"net/http"
"net/http/httptest"
"os"
"testing"

"github.com/flashbots/system-api/common"
"github.com/go-chi/httplog/v2"
"github.com/stretchr/testify/require"
)

func getTestLogger() *httplog.Logger {
return common.SetupLogger(&common.LoggingOpts{
Debug: true,
JSON: false,
})
}

func getTestConfig() *HTTPServerConfig {
return &HTTPServerConfig{
Log: getTestLogger(),
Config: NewSystemAPIConfig(),
}
}

func execRequest(t *testing.T, router http.Handler, method, url string, body io.Reader) *httptest.ResponseRecorder {
t.Helper()
req, err := http.NewRequest(method, url, body)
require.NoError(t, err)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
return rr
}

func TestGeneralHandlers(t *testing.T) {
// Instantiate the server
srv, err := NewServer(getTestConfig())
require.NoError(t, err)
router := srv.getRouter()

// Test /livez
rr := execRequest(t, router, http.MethodGet, "/livez", nil)
require.Equal(t, http.StatusOK, rr.Code)

// Test /api/v1/events
rr = execRequest(t, router, http.MethodGet, "/api/v1/events", nil)
require.Equal(t, http.StatusOK, rr.Code)
body, err := io.ReadAll(rr.Body)
require.NoError(t, err)
require.Equal(t, "[]\n", string(body))

// Add an event
rr = execRequest(t, router, http.MethodGet, "/api/v1/new_event?message=foo", nil)
require.Equal(t, http.StatusOK, rr.Code)
require.Len(t, srv.events, 1)
}

func TestBasicAuth(t *testing.T) {
basicAuthSecret := []byte("secret")
tempDir := t.TempDir()

// Create the config
cfg := getTestConfig()
cfg.Config.General.BasicAuthSecretPath = tempDir + "/basic_auth_secret"

// Create the temporary file to store the basic auth secret
err := os.WriteFile(cfg.Config.General.BasicAuthSecretPath, []byte{}, 0o600)
require.NoError(t, err)

// Instantiate the server
srv, err := NewServer(cfg)
require.NoError(t, err)
router := srv.getRouter()

// Helper to get /livez with and without basic auth
getLiveZ := func(basicAuthUser, basicAuthPass string) int {
req, err := http.NewRequest(http.MethodGet, "/livez", nil)
if basicAuthUser != "" {
req.SetBasicAuth(basicAuthUser, basicAuthPass)
}
require.NoError(t, err)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
return rr.Code
}

// Initially, /livez should work without basic auth
require.Equal(t, http.StatusOK, getLiveZ("", ""))

// Set a basic auth secret
rr := execRequest(t, router, http.MethodPost, "/api/v1/set-basic-auth", bytes.NewReader(basicAuthSecret))
require.Equal(t, http.StatusOK, rr.Code)

// Ensure secretFromFile was written to file
secretFromFile, err := os.ReadFile(cfg.Config.General.BasicAuthSecretPath)
require.NoError(t, err)
require.Equal(t, basicAuthSecret, secretFromFile)

// From here on, /livez shoud fail without basic auth
require.Equal(t, http.StatusUnauthorized, getLiveZ("", ""))

// /livez should work with basic auth
require.Equal(t, http.StatusOK, getLiveZ("admin", string(basicAuthSecret)))

// /livez should now work with invalid basic auth credentials
require.Equal(t, http.StatusUnauthorized, getLiveZ("admin1", string(basicAuthSecret)))
}
Loading