Skip to content

Commit c784e83

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.
1 parent c50df8b commit c784e83

File tree

5 files changed

+343
-1
lines changed

5 files changed

+343
-1
lines changed

CLAUDE.md

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
# CLAUDE.md
2+
3+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+
5+
## Project Overview
6+
7+
spa-to-http is a lightweight, zero-configuration HTTP server for serving Single Page Applications (SPAs). Written in Go, it's designed as a faster, smaller alternative to Nginx for Docker-based SPA deployments. The server supports intelligent caching, compression (Brotli/Gzip), and SPA routing out of the box.
8+
9+
## Build and Test Commands
10+
11+
### Building
12+
```bash
13+
cd src
14+
go build -o dist/ -ldflags "-s -w"
15+
```
16+
17+
### Testing
18+
```bash
19+
# Run all tests
20+
cd src
21+
go test ./...
22+
23+
# Run tests with coverage
24+
cd src
25+
go test ./... -coverprofile cover.out
26+
go tool cover -func cover.out
27+
28+
# Run tests for a specific package
29+
cd src
30+
go test ./app -v
31+
go test ./param -v
32+
go test ./util -v
33+
```
34+
35+
### Docker Build
36+
```bash
37+
# Local build
38+
docker build -t spa-to-http .
39+
40+
# Multi-platform build (production)
41+
./publish.sh
42+
```
43+
44+
### Running Locally
45+
```bash
46+
cd src
47+
go run main.go [flags]
48+
49+
# Examples:
50+
go run main.go --brotli --port 8080
51+
go run main.go --directory /path/to/spa/dist --gzip
52+
```
53+
54+
## Architecture
55+
56+
### Core Components
57+
58+
**main.go** - Entry point using urfave/cli for CLI parsing. Spawns goroutine for file compression and starts HTTP server.
59+
60+
**app/app.go** - Core HTTP server logic:
61+
- `App` struct holds server state (params, HTTP server, LRU cache)
62+
- `CompressFiles()` - Pre-compresses files at startup (creates .gz/.br variants)
63+
- `GetOrCreateResponseItem()` - Central file serving logic with caching, handles SPA fallback to index.html
64+
- `HandlerFuncNew()` - HTTP request handler with compression negotiation and cache-control headers
65+
- `BasicAuthMiddleware()` - Optional HTTP Basic Authentication middleware using constant-time comparison for security
66+
- `Listen()` - Starts HTTP server with optional basic auth and request logging middleware
67+
68+
**param/param.go** - Parameter parsing and configuration:
69+
- `Flags` - CLI flag definitions with environment variable bindings
70+
- `Params` - Strongly-typed configuration struct
71+
- `ContextToParams()` - Converts CLI context to Params
72+
73+
**util/** - Utility functions:
74+
- `http.go` - IP address extraction from headers (X-Real-IP, X-Forwarded-For)
75+
- `log.go` - HTTP request logging middleware using zerolog
76+
- `util.go` - File type detection helpers
77+
78+
### Key Design Patterns
79+
80+
**Compression Strategy**: Files are pre-compressed at startup in a background goroutine. At request time, the server negotiates content encoding (prefers Brotli over Gzip) and serves pre-compressed variants when available and over threshold size.
81+
82+
**Caching**: Two-level caching approach:
83+
1. LRU cache (hashicorp/golang-lru TwoQueueCache) for file contents
84+
2. HTTP cache-control headers (7-day max-age for assets, no-store for HTML)
85+
86+
**SPA Mode**: When a file doesn't exist and SPA mode is enabled, requests fall back to serving `/index.html` from the root directory. This enables client-side routing. The cache stores redirects as strings to avoid re-resolution.
87+
88+
**Configuration**: All options can be set via CLI flags or environment variables (e.g., `--port` or `PORT=8080`), making it Docker-friendly.
89+
90+
### Important Implementation Details
91+
92+
- The `NoCompress` option allows excluding file extensions from compression (e.g., already-compressed formats)
93+
- Symlinks are resolved and validated to prevent directory traversal attacks (see `GetFilePath` in app.go:220-232)
94+
- Range requests and excluded extensions bypass compression logic
95+
- The `--ignore-cache-control-paths` flag prevents caching for specific paths (useful for service workers)
96+
- Default threshold for compression is 1024 bytes
97+
- Basic authentication uses `crypto/subtle.ConstantTimeCompare` to prevent timing attacks
98+
- Basic auth credentials format: `username:password` (configured via `--basic-auth` or `BASIC_AUTH` env var)
99+
- Basic auth realm can be customized via `--basic-auth-realm` or `BASIC_AUTH_REALM` env var (defaults to "Restricted")
100+
101+
## File Organization
102+
103+
```
104+
src/
105+
├── main.go # Entry point
106+
├── app/
107+
│ ├── app.go # Core server logic
108+
│ └── app_test.go # App tests
109+
├── param/
110+
│ ├── param.go # CLI/env parameter handling
111+
│ └── param_test.go # Param tests
112+
└── util/
113+
├── http.go # HTTP utilities (IP extraction)
114+
├── log.go # Request logging
115+
├── util.go # File utilities
116+
└── *_test.go # Util tests
117+
```
118+
119+
## Common Modifications
120+
121+
**Adding a new CLI option**:
122+
1. Add flag definition to `param/param.go` in the `Flags` slice
123+
2. Add field to `Params` struct
124+
3. Map the flag in `ContextToParams()`
125+
4. Use the parameter in `app/app.go`
126+
127+
**Changing compression behavior**:
128+
- Modify `CompressFiles()` for startup compression
129+
- Modify `HandlerFuncNew()` for runtime compression negotiation
130+
- Update `ShouldSkipCompression()` for exclusion logic
131+
132+
**Modifying cache behavior**:
133+
- Cache control headers: `HandlerFuncNew()` around line 256
134+
- LRU cache: `GetOrCreateResponseItem()` handles cache read/write
135+
- Cache initialization: `NewApp()` in app.go:44-56

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+
}

0 commit comments

Comments
 (0)