Skip to content

Commit 18c96a1

Browse files
committed
Implement basic auth configuration
Implements basic auth middleware with username:password format via BASIC_AUTH env var or --basic-auth flag. Realm name is customizable via BASIC_AUTH_REALM (defaults to "Restricted"). Uses constant-time comparison to prevent timing attacks. c50df8
1 parent c50df8b commit 18c96a1

File tree

4 files changed

+208
-1
lines changed

4 files changed

+208
-1
lines changed

README.md

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ Ignore caching for some specific resources, e.g. prevent Service Worker caching
135135

136136

137137

138-
```diff
138+
```diff
139139
trfk-vue:
140140
build: "spa"
141141
++ command: --ignore-cache-control-paths "/sw.js"
@@ -147,6 +147,45 @@ Ignore caching for some specific resources, e.g. prevent Service Worker caching
147147

148148
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
149149

150+
Enable HTTP Basic Authentication to protect your SPA:
151+
152+
```diff
153+
trfk-vue:
154+
build: "spa"
155+
++ command: --basic-auth "admin:secretpassword"
156+
labels:
157+
- "traefik.enable=true"
158+
- "traefik.http.routers.trfk-vue.rule=Host(`trfk-vue.localhost`)"
159+
- "traefik.http.services.trfk-vue.loadbalancer.server.port=8080"
160+
```
161+
162+
Or using environment variable:
163+
164+
```diff
165+
trfk-vue:
166+
build: "spa"
167+
++ environment:
168+
++ - BASIC_AUTH=admin:secretpassword
169+
labels:
170+
- "traefik.enable=true"
171+
- "traefik.http.routers.trfk-vue.rule=Host(`trfk-vue.localhost`)"
172+
- "traefik.http.services.trfk-vue.loadbalancer.server.port=8080"
173+
```
174+
175+
Customize the authentication realm (the name shown in browser login dialogs):
176+
177+
```diff
178+
trfk-vue:
179+
build: "spa"
180+
environment:
181+
- BASIC_AUTH=admin:secretpassword
182+
++ - BASIC_AUTH_REALM=My Application
183+
labels:
184+
- "traefik.enable=true"
185+
- "traefik.http.routers.trfk-vue.rule=Host(`trfk-vue.localhost`)"
186+
- "traefik.http.services.trfk-vue.loadbalancer.server.port=8080"
187+
```
188+
150189

151190

152191
## Available Options:
@@ -166,3 +205,5 @@ This is not needed for most of your assets because their filenames should contai
166205
| CACHE_BUFFER | `--cache-buffer <number>` | Specifies the maximum size of LRU cache in bytes | `51200` |
167206
| LOGGER | `--logger` | Enable requests logger | `false` |
168207
| LOG_PRETTY | `--log-pretty` | Print log messages in a pretty format instead of default JSON format | `false` |
208+
| BASIC_AUTH | `--basic-auth <string>` | Enable HTTP Basic Authentication with credentials in format "username:password" | |
209+
| BASIC_AUTH_REALM | `--basic-auth-realm <string>` | Set the realm name for HTTP Basic Authentication (shown in browser login prompt) | `Restricted` |

src/app/app.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package app
33
import (
44
"bytes"
55
"compress/gzip"
6+
"crypto/subtle"
67
"fmt"
78
"github.com/andybalholm/brotli"
89
lru "github.com/hashicorp/golang-lru"
@@ -298,8 +299,49 @@ func (app *App) HandlerFuncNew(w http.ResponseWriter, r *http.Request) {
298299
http.ServeContent(w, r, responseItem.Name, responseItem.ModTime, bytes.NewReader(responseItem.Content))
299300
}
300301

302+
func (app *App) BasicAuthMiddleware(next http.Handler) http.Handler {
303+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
304+
if app.params.BasicAuth == "" {
305+
next.ServeHTTP(w, r)
306+
return
307+
}
308+
309+
parts := strings.SplitN(app.params.BasicAuth, ":", 2)
310+
if len(parts) != 2 {
311+
next.ServeHTTP(w, r)
312+
return
313+
}
314+
315+
expectedUsername := parts[0]
316+
expectedPassword := parts[1]
317+
318+
username, password, ok := r.BasicAuth()
319+
if !ok {
320+
w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Basic realm="%s"`, app.params.BasicAuthRealm))
321+
w.WriteHeader(http.StatusUnauthorized)
322+
return
323+
}
324+
325+
usernameMatch := subtle.ConstantTimeCompare([]byte(username), []byte(expectedUsername)) == 1
326+
passwordMatch := subtle.ConstantTimeCompare([]byte(password), []byte(expectedPassword)) == 1
327+
328+
if !usernameMatch || !passwordMatch {
329+
w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Basic realm="%s"`, app.params.BasicAuthRealm))
330+
w.WriteHeader(http.StatusUnauthorized)
331+
return
332+
}
333+
334+
next.ServeHTTP(w, r)
335+
})
336+
}
337+
301338
func (app *App) Listen() {
302339
var handlerFunc http.Handler = http.HandlerFunc(app.HandlerFuncNew)
340+
341+
if app.params.BasicAuth != "" {
342+
handlerFunc = app.BasicAuthMiddleware(handlerFunc)
343+
}
344+
303345
if app.params.Logger {
304346
handlerFunc = util.LogRequestHandler(handlerFunc, &util.LogRequestHandlerOptions{
305347
Pretty: app.params.LogPretty,

src/app/app_test.go

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,3 +345,113 @@ func TestGetFilePath(t *testing.T) {
345345
t.Errorf("Expected false, got %t", valid)
346346
}
347347
}
348+
349+
func TestBasicAuthMiddleware(t *testing.T) {
350+
params := param.Params{
351+
Address: "0.0.0.0",
352+
Port: 8080,
353+
Gzip: false,
354+
Brotli: false,
355+
Threshold: 1024,
356+
Directory: "../../test/frontend/dist",
357+
CacheControlMaxAge: 604800,
358+
SpaMode: true,
359+
IgnoreCacheControlPaths: nil,
360+
CacheEnabled: true,
361+
CacheBuffer: 50 * 1024,
362+
BasicAuth: "testuser:testpass",
363+
BasicAuthRealm: "Restricted",
364+
}
365+
app1 := app.NewApp(&params)
366+
367+
// Test without auth credentials - should return 401
368+
req1, _ := http.NewRequest("GET", "/", nil)
369+
recorder1 := httptest.NewRecorder()
370+
handler1 := app1.BasicAuthMiddleware(http.HandlerFunc(app1.HandlerFuncNew))
371+
handler1.ServeHTTP(recorder1, req1)
372+
if recorder1.Code != http.StatusUnauthorized {
373+
t.Errorf("Expected 401 Unauthorized, got %d", recorder1.Code)
374+
}
375+
if recorder1.HeaderMap["Www-Authenticate"][0] != `Basic realm="Restricted"` {
376+
t.Errorf("Expected WWW-Authenticate header, got %s", recorder1.HeaderMap["Www-Authenticate"])
377+
}
378+
379+
// Test with correct credentials - should return 200
380+
req2, _ := http.NewRequest("GET", "/", nil)
381+
req2.SetBasicAuth("testuser", "testpass")
382+
recorder2 := httptest.NewRecorder()
383+
handler2 := app1.BasicAuthMiddleware(http.HandlerFunc(app1.HandlerFuncNew))
384+
handler2.ServeHTTP(recorder2, req2)
385+
if recorder2.Code != http.StatusOK {
386+
t.Errorf("Expected 200 OK, got %d", recorder2.Code)
387+
}
388+
389+
// Test with incorrect username - should return 401
390+
req3, _ := http.NewRequest("GET", "/", nil)
391+
req3.SetBasicAuth("wronguser", "testpass")
392+
recorder3 := httptest.NewRecorder()
393+
handler3 := app1.BasicAuthMiddleware(http.HandlerFunc(app1.HandlerFuncNew))
394+
handler3.ServeHTTP(recorder3, req3)
395+
if recorder3.Code != http.StatusUnauthorized {
396+
t.Errorf("Expected 401 Unauthorized, got %d", recorder3.Code)
397+
}
398+
399+
// Test with incorrect password - should return 401
400+
req4, _ := http.NewRequest("GET", "/", nil)
401+
req4.SetBasicAuth("testuser", "wrongpass")
402+
recorder4 := httptest.NewRecorder()
403+
handler4 := app1.BasicAuthMiddleware(http.HandlerFunc(app1.HandlerFuncNew))
404+
handler4.ServeHTTP(recorder4, req4)
405+
if recorder4.Code != http.StatusUnauthorized {
406+
t.Errorf("Expected 401 Unauthorized, got %d", recorder4.Code)
407+
}
408+
409+
// Test with no BasicAuth configured - should allow through
410+
params.BasicAuth = ""
411+
app2 := app.NewApp(&params)
412+
req5, _ := http.NewRequest("GET", "/", nil)
413+
recorder5 := httptest.NewRecorder()
414+
handler5 := app2.BasicAuthMiddleware(http.HandlerFunc(app2.HandlerFuncNew))
415+
handler5.ServeHTTP(recorder5, req5)
416+
if recorder5.Code != http.StatusOK {
417+
t.Errorf("Expected 200 OK when no auth is configured, got %d", recorder5.Code)
418+
}
419+
420+
// Test with invalid BasicAuth format - should allow through
421+
params.BasicAuth = "invalidformat"
422+
app3 := app.NewApp(&params)
423+
req6, _ := http.NewRequest("GET", "/", nil)
424+
recorder6 := httptest.NewRecorder()
425+
handler6 := app3.BasicAuthMiddleware(http.HandlerFunc(app3.HandlerFuncNew))
426+
handler6.ServeHTTP(recorder6, req6)
427+
if recorder6.Code != http.StatusOK {
428+
t.Errorf("Expected 200 OK when auth format is invalid, got %d", recorder6.Code)
429+
}
430+
431+
// Test custom realm name
432+
params.BasicAuth = "user:pass"
433+
params.BasicAuthRealm = "My Custom Realm"
434+
app4 := app.NewApp(&params)
435+
req7, _ := http.NewRequest("GET", "/", nil)
436+
recorder7 := httptest.NewRecorder()
437+
handler7 := app4.BasicAuthMiddleware(http.HandlerFunc(app4.HandlerFuncNew))
438+
handler7.ServeHTTP(recorder7, req7)
439+
if recorder7.Code != http.StatusUnauthorized {
440+
t.Errorf("Expected 401 Unauthorized, got %d", recorder7.Code)
441+
}
442+
if recorder7.HeaderMap["Www-Authenticate"][0] != `Basic realm="My Custom Realm"` {
443+
t.Errorf("Expected custom realm 'My Custom Realm', got %s", recorder7.HeaderMap["Www-Authenticate"])
444+
}
445+
446+
// Test default realm name when not specified
447+
params.BasicAuth = "user:pass"
448+
params.BasicAuthRealm = "Restricted"
449+
app5 := app.NewApp(&params)
450+
req8, _ := http.NewRequest("GET", "/", nil)
451+
recorder8 := httptest.NewRecorder()
452+
handler8 := app5.BasicAuthMiddleware(http.HandlerFunc(app5.HandlerFuncNew))
453+
handler8.ServeHTTP(recorder8, req8)
454+
if recorder8.HeaderMap["Www-Authenticate"][0] != `Basic realm="Restricted"` {
455+
t.Errorf("Expected default realm 'Restricted', got %s", recorder8.HeaderMap["Www-Authenticate"])
456+
}
457+
}

src/param/param.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,16 @@ var Flags = []cli.Flag{
8585
Name: "no-compress",
8686
Value: nil,
8787
},
88+
&cli.StringFlag{
89+
EnvVars: []string{"BASIC_AUTH"},
90+
Name: "basic-auth",
91+
Value: "",
92+
},
93+
&cli.StringFlag{
94+
EnvVars: []string{"BASIC_AUTH_REALM"},
95+
Name: "basic-auth-realm",
96+
Value: "Restricted",
97+
},
8898
}
8999

90100
type Params struct {
@@ -102,6 +112,8 @@ type Params struct {
102112
Logger bool
103113
LogPretty bool
104114
NoCompress []string
115+
BasicAuth string
116+
BasicAuthRealm string
105117
//DirectoryListing bool
106118
}
107119

@@ -126,6 +138,8 @@ func ContextToParams(c *cli.Context) (*Params, error) {
126138
Logger: c.Bool("logger"),
127139
LogPretty: c.Bool("log-pretty"),
128140
NoCompress: c.StringSlice("no-compress"),
141+
BasicAuth: c.String("basic-auth"),
142+
BasicAuthRealm: c.String("basic-auth-realm"),
129143
//DirectoryListing: c.Bool("directory-listing"),
130144
}, nil
131145
}

0 commit comments

Comments
 (0)