Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
43 changes: 42 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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:
Expand All @@ -166,3 +205,5 @@ This is not needed for most of your assets because their filenames should contai
| CACHE_BUFFER | `--cache-buffer <number>` | 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 <string>` | Enable HTTP Basic Authentication with credentials in format "username:password" | |
| BASIC_AUTH_REALM | `--basic-auth-realm <string>` | Set the realm name for HTTP Basic Authentication (shown in browser login prompt) | `Restricted` |
42 changes: 42 additions & 0 deletions src/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package app
import (
"bytes"
"compress/gzip"
"crypto/subtle"
"fmt"
"github.com/andybalholm/brotli"
lru "github.com/hashicorp/golang-lru"
Expand Down Expand Up @@ -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,
Expand Down
110 changes: 110 additions & 0 deletions src/app/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(&params)

// 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(&params)
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(&params)
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(&params)
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(&params)
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"])
}
}
14 changes: 14 additions & 0 deletions src/param/param.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -102,6 +112,8 @@ type Params struct {
Logger bool
LogPretty bool
NoCompress []string
BasicAuth string
BasicAuthRealm string
//DirectoryListing bool
}

Expand All @@ -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
}