diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ce0e44..c4f45ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Job counts are cached for very large job tables to make request timeouts less likely. [PR #108](https://github.com/riverqueue/riverui/pull/108). +- Added basic auth config parameter for protection. [PR #113](https://github.com/riverqueue/riverui/pull/113) ## [0.3.1] - 2024-08-02 diff --git a/cmd/riverui/main.go b/cmd/riverui/main.go index 2c2f16e..bb2eae1 100644 --- a/cmd/riverui/main.go +++ b/cmd/riverui/main.go @@ -78,10 +78,12 @@ func initAndServe(ctx context.Context) int { } handlerOpts := &riverui.HandlerOpts{ - Client: client, - DBPool: dbPool, - Logger: logger, - Prefix: pathPrefix, + Client: client, + DBPool: dbPool, + Logger: logger, + Prefix: pathPrefix, + BasicAuthUser: os.Getenv("BASIC_AUTH_USER"), + BasicAuthPassword: os.Getenv("BASIC_AUTH_PASSWORD"), } server, err := riverui.NewServer(handlerOpts) diff --git a/docs/README.md b/docs/README.md index cc4bacd..1cffe60 100644 --- a/docs/README.md +++ b/docs/README.md @@ -42,6 +42,15 @@ $ docker pull ghcr.io/riverqueue/riverui:latest $ docker run -p 8080:8080 --env DATABASE_URL ghcr.io/riverqueue/riverui:latest ``` +### Environment variables + +- `DATABASE_URL=...` - define database url +- `PORT=8080` - define listening port +- `RIVER_DEBUG=true` - enable debugging logs +- `CORS_ORIGINS=url1,url2` - define allowed CORS origins +- `OTEL_ENABLED=true` - enable OTEL integration +- `BASIC_AUTH_USER=admin`, `BASIC_AUTH_PASSWORD=changeme` - enable basic auth username/password + ## Development See [developing River UI](./docs/development.md). diff --git a/handler.go b/handler.go index b0ed941..a12ce9c 100644 --- a/handler.go +++ b/handler.go @@ -3,6 +3,7 @@ package riverui import ( "context" "embed" + "encoding/base64" "errors" "fmt" "io/fs" @@ -37,7 +38,9 @@ type HandlerOpts struct { // Logger is the logger to use logging errors within the handler. Logger *slog.Logger // Prefix is the path prefix to use for the API and UI HTTP requests. - Prefix string + Prefix string + BasicAuthUser string + BasicAuthPassword string } func (opts *HandlerOpts) validate() error { @@ -146,6 +149,10 @@ func NewServer(opts *HandlerOpts) (*Server, error) { middlewareStack.Use(&stripPrefixMiddleware{prefix}) } + if opts.BasicAuthUser != "" && opts.BasicAuthPassword != "" { + middlewareStack.Use(basicAuthMiddleware{opts.BasicAuthUser, opts.BasicAuthPassword}) + } + server := &Server{ handler: middlewareStack.Mount(mux), services: services, @@ -233,6 +240,45 @@ func mountStaticFiles(logger *slog.Logger, mux *http.ServeMux) error { }) } +// Requires basic auth username/password authentication on all endpoints +// https://en.wikipedia.org/wiki/Basic_access_authentication +type basicAuthMiddleware struct { + username string + password string +} + +func (m basicAuthMiddleware) Middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + basicAuthHeader := r.Header.Get("Authorization") + if basicAuthHeader == "" { + w.Header().Set("WWW-Authenticate", `Basic realm="riverui restricted"`) + w.WriteHeader(http.StatusUnauthorized) + return + } + + if !strings.HasPrefix(basicAuthHeader, "Basic ") { + w.Header().Set("WWW-Authenticate", `Basic realm="riverui restricted"`) + w.WriteHeader(http.StatusUnauthorized) + return + } + + data, err := base64.StdEncoding.DecodeString(basicAuthHeader[len("Basic "):]) + if err != nil { + w.Header().Set("WWW-Authenticate", `Basic realm="riverui restricted"`) + w.WriteHeader(http.StatusUnauthorized) + return + } + + parts := strings.Split(string(data), ":") + if len(parts) != 2 || parts[0] != m.username || parts[1] != m.password { + w.Header().Set("WWW-Authenticate", `Basic realm="riverui restricted"`) + w.WriteHeader(http.StatusUnauthorized) + return + } + next.ServeHTTP(w, r) + }) +} + // Go's http.StripPrefix can sometimes result in an empty path. For example, // when removing a prefix like "/foo" from path "/foo", the result is "". This // does not get handled by the ServeMux correctly (it results in a redirect to