diff --git a/README.md b/README.md index 43e6cf1..070d168 100644 --- a/README.md +++ b/README.md @@ -135,7 +135,7 @@ Ignore caching for some specific resources, e.g. prevent Service Worker caching -```diff +```diff trfk-vue: build: "spa" ++ command: --ignore-cache-control-paths "/sw.js" @@ -147,6 +147,45 @@ Ignore caching for some specific resources, e.g. prevent Service Worker caching This is not needed for most of your assets because their filenames should contain file hash (added by default by modern bundlers). So cache naturally invalidated by referencing hashed assets from uncachable html. However some special resources like service worker must be served on fixed URL without file hash in filename +Enable HTTP Basic Authentication to protect your SPA: + +```diff + trfk-vue: + build: "spa" +++ command: --basic-auth "admin:secretpassword" + labels: + - "traefik.enable=true" + - "traefik.http.routers.trfk-vue.rule=Host(`trfk-vue.localhost`)" + - "traefik.http.services.trfk-vue.loadbalancer.server.port=8080" +``` + +Or using environment variable: + +```diff + trfk-vue: + build: "spa" +++ environment: +++ - BASIC_AUTH=admin:secretpassword + labels: + - "traefik.enable=true" + - "traefik.http.routers.trfk-vue.rule=Host(`trfk-vue.localhost`)" + - "traefik.http.services.trfk-vue.loadbalancer.server.port=8080" +``` + +Customize the authentication realm (the name shown in browser login dialogs): + +```diff + trfk-vue: + build: "spa" + environment: + - BASIC_AUTH=admin:secretpassword +++ - BASIC_AUTH_REALM=My Application + labels: + - "traefik.enable=true" + - "traefik.http.routers.trfk-vue.rule=Host(`trfk-vue.localhost`)" + - "traefik.http.services.trfk-vue.loadbalancer.server.port=8080" +``` + ## Available Options: @@ -166,3 +205,5 @@ This is not needed for most of your assets because their filenames should contai | CACHE_BUFFER | `--cache-buffer ` | Specifies the maximum size of LRU cache in bytes | `51200` | | LOGGER | `--logger` | Enable requests logger | `false` | | LOG_PRETTY | `--log-pretty` | Print log messages in a pretty format instead of default JSON format | `false` | +| BASIC_AUTH | `--basic-auth ` | Enable HTTP Basic Authentication with credentials in format "username:password" | | +| BASIC_AUTH_REALM | `--basic-auth-realm ` | Set the realm name for HTTP Basic Authentication (shown in browser login prompt) | `Restricted` | diff --git a/src/app/app.go b/src/app/app.go index 0d4f2f0..54ca209 100644 --- a/src/app/app.go +++ b/src/app/app.go @@ -3,6 +3,7 @@ package app import ( "bytes" "compress/gzip" + "crypto/subtle" "fmt" "github.com/andybalholm/brotli" lru "github.com/hashicorp/golang-lru" @@ -298,8 +299,49 @@ func (app *App) HandlerFuncNew(w http.ResponseWriter, r *http.Request) { http.ServeContent(w, r, responseItem.Name, responseItem.ModTime, bytes.NewReader(responseItem.Content)) } +func (app *App) BasicAuthMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if app.params.BasicAuth == "" { + next.ServeHTTP(w, r) + return + } + + parts := strings.SplitN(app.params.BasicAuth, ":", 2) + if len(parts) != 2 { + next.ServeHTTP(w, r) + return + } + + expectedUsername := parts[0] + expectedPassword := parts[1] + + username, password, ok := r.BasicAuth() + if !ok { + w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Basic realm="%s"`, app.params.BasicAuthRealm)) + w.WriteHeader(http.StatusUnauthorized) + return + } + + usernameMatch := subtle.ConstantTimeCompare([]byte(username), []byte(expectedUsername)) == 1 + passwordMatch := subtle.ConstantTimeCompare([]byte(password), []byte(expectedPassword)) == 1 + + if !usernameMatch || !passwordMatch { + w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Basic realm="%s"`, app.params.BasicAuthRealm)) + w.WriteHeader(http.StatusUnauthorized) + return + } + + next.ServeHTTP(w, r) + }) +} + func (app *App) Listen() { var handlerFunc http.Handler = http.HandlerFunc(app.HandlerFuncNew) + + if app.params.BasicAuth != "" { + handlerFunc = app.BasicAuthMiddleware(handlerFunc) + } + if app.params.Logger { handlerFunc = util.LogRequestHandler(handlerFunc, &util.LogRequestHandlerOptions{ Pretty: app.params.LogPretty, diff --git a/src/app/app_test.go b/src/app/app_test.go index 474e9df..05fd32e 100644 --- a/src/app/app_test.go +++ b/src/app/app_test.go @@ -345,3 +345,113 @@ func TestGetFilePath(t *testing.T) { t.Errorf("Expected false, got %t", valid) } } + +func TestBasicAuthMiddleware(t *testing.T) { + params := param.Params{ + Address: "0.0.0.0", + Port: 8080, + Gzip: false, + Brotli: false, + Threshold: 1024, + Directory: "../../test/frontend/dist", + CacheControlMaxAge: 604800, + SpaMode: true, + IgnoreCacheControlPaths: nil, + CacheEnabled: true, + CacheBuffer: 50 * 1024, + BasicAuth: "testuser:testpass", + BasicAuthRealm: "Restricted", + } + app1 := app.NewApp(¶ms) + + // Test without auth credentials - should return 401 + req1, _ := http.NewRequest("GET", "/", nil) + recorder1 := httptest.NewRecorder() + handler1 := app1.BasicAuthMiddleware(http.HandlerFunc(app1.HandlerFuncNew)) + handler1.ServeHTTP(recorder1, req1) + if recorder1.Code != http.StatusUnauthorized { + t.Errorf("Expected 401 Unauthorized, got %d", recorder1.Code) + } + if recorder1.HeaderMap["Www-Authenticate"][0] != `Basic realm="Restricted"` { + t.Errorf("Expected WWW-Authenticate header, got %s", recorder1.HeaderMap["Www-Authenticate"]) + } + + // Test with correct credentials - should return 200 + req2, _ := http.NewRequest("GET", "/", nil) + req2.SetBasicAuth("testuser", "testpass") + recorder2 := httptest.NewRecorder() + handler2 := app1.BasicAuthMiddleware(http.HandlerFunc(app1.HandlerFuncNew)) + handler2.ServeHTTP(recorder2, req2) + if recorder2.Code != http.StatusOK { + t.Errorf("Expected 200 OK, got %d", recorder2.Code) + } + + // Test with incorrect username - should return 401 + req3, _ := http.NewRequest("GET", "/", nil) + req3.SetBasicAuth("wronguser", "testpass") + recorder3 := httptest.NewRecorder() + handler3 := app1.BasicAuthMiddleware(http.HandlerFunc(app1.HandlerFuncNew)) + handler3.ServeHTTP(recorder3, req3) + if recorder3.Code != http.StatusUnauthorized { + t.Errorf("Expected 401 Unauthorized, got %d", recorder3.Code) + } + + // Test with incorrect password - should return 401 + req4, _ := http.NewRequest("GET", "/", nil) + req4.SetBasicAuth("testuser", "wrongpass") + recorder4 := httptest.NewRecorder() + handler4 := app1.BasicAuthMiddleware(http.HandlerFunc(app1.HandlerFuncNew)) + handler4.ServeHTTP(recorder4, req4) + if recorder4.Code != http.StatusUnauthorized { + t.Errorf("Expected 401 Unauthorized, got %d", recorder4.Code) + } + + // Test with no BasicAuth configured - should allow through + params.BasicAuth = "" + app2 := app.NewApp(¶ms) + req5, _ := http.NewRequest("GET", "/", nil) + recorder5 := httptest.NewRecorder() + handler5 := app2.BasicAuthMiddleware(http.HandlerFunc(app2.HandlerFuncNew)) + handler5.ServeHTTP(recorder5, req5) + if recorder5.Code != http.StatusOK { + t.Errorf("Expected 200 OK when no auth is configured, got %d", recorder5.Code) + } + + // Test with invalid BasicAuth format - should allow through + params.BasicAuth = "invalidformat" + app3 := app.NewApp(¶ms) + req6, _ := http.NewRequest("GET", "/", nil) + recorder6 := httptest.NewRecorder() + handler6 := app3.BasicAuthMiddleware(http.HandlerFunc(app3.HandlerFuncNew)) + handler6.ServeHTTP(recorder6, req6) + if recorder6.Code != http.StatusOK { + t.Errorf("Expected 200 OK when auth format is invalid, got %d", recorder6.Code) + } + + // Test custom realm name + params.BasicAuth = "user:pass" + params.BasicAuthRealm = "My Custom Realm" + app4 := app.NewApp(¶ms) + req7, _ := http.NewRequest("GET", "/", nil) + recorder7 := httptest.NewRecorder() + handler7 := app4.BasicAuthMiddleware(http.HandlerFunc(app4.HandlerFuncNew)) + handler7.ServeHTTP(recorder7, req7) + if recorder7.Code != http.StatusUnauthorized { + t.Errorf("Expected 401 Unauthorized, got %d", recorder7.Code) + } + if recorder7.HeaderMap["Www-Authenticate"][0] != `Basic realm="My Custom Realm"` { + t.Errorf("Expected custom realm 'My Custom Realm', got %s", recorder7.HeaderMap["Www-Authenticate"]) + } + + // Test default realm name when not specified + params.BasicAuth = "user:pass" + params.BasicAuthRealm = "Restricted" + app5 := app.NewApp(¶ms) + req8, _ := http.NewRequest("GET", "/", nil) + recorder8 := httptest.NewRecorder() + handler8 := app5.BasicAuthMiddleware(http.HandlerFunc(app5.HandlerFuncNew)) + handler8.ServeHTTP(recorder8, req8) + if recorder8.HeaderMap["Www-Authenticate"][0] != `Basic realm="Restricted"` { + t.Errorf("Expected default realm 'Restricted', got %s", recorder8.HeaderMap["Www-Authenticate"]) + } +} diff --git a/src/param/param.go b/src/param/param.go index 9b23aa6..1c08c71 100644 --- a/src/param/param.go +++ b/src/param/param.go @@ -85,6 +85,16 @@ var Flags = []cli.Flag{ Name: "no-compress", Value: nil, }, + &cli.StringFlag{ + EnvVars: []string{"BASIC_AUTH"}, + Name: "basic-auth", + Value: "", + }, + &cli.StringFlag{ + EnvVars: []string{"BASIC_AUTH_REALM"}, + Name: "basic-auth-realm", + Value: "Restricted", + }, } type Params struct { @@ -102,6 +112,8 @@ type Params struct { Logger bool LogPretty bool NoCompress []string + BasicAuth string + BasicAuthRealm string //DirectoryListing bool } @@ -126,6 +138,8 @@ func ContextToParams(c *cli.Context) (*Params, error) { Logger: c.Bool("logger"), LogPretty: c.Bool("log-pretty"), NoCompress: c.StringSlice("no-compress"), + BasicAuth: c.String("basic-auth"), + BasicAuthRealm: c.String("basic-auth-realm"), //DirectoryListing: c.Bool("directory-listing"), }, nil }