Skip to content

Commit 7d2d112

Browse files
committed
test/docs: cover basic auth and update docs
1 parent 1e20473 commit 7d2d112

8 files changed

Lines changed: 324 additions & 1 deletion

File tree

docs/configuration.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121
| CACHE_BUFFER | `--cache-buffer <number>` | Max size of the LRU cache in bytes | `51200` |
2222
| LOGGER | `--logger` | Enable request logging | `false` |
2323
| LOG_PRETTY | `--log-pretty` | Pretty-print logs instead of JSON | `false` |
24+
| BASIC_AUTH | `--basic-auth <username:password>` | Enable Basic Auth (username:password) | (empty) |
25+
| BASIC_AUTH_REALM | `--basic-auth-realm <string>` | Basic Auth realm name | `Restricted` |
2426

2527
## Examples
2628

docs/getting-started.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,16 @@ DIRECTORY=../test/frontend/dist \
5353
go run .
5454
```
5555

56+
### Basic Auth (Console)
57+
58+
```bash
59+
cd src
60+
go run . \
61+
--directory ../test/frontend/dist \
62+
--basic-auth "admin:secret" \
63+
--basic-auth-realm "SPA Server"
64+
```
65+
5666
Full list of options is in [Configuration](configuration.md).
5767

5868
## Serve a Local Build (Docker)
@@ -64,6 +74,16 @@ docker run --rm -p 8080:8080 -v $(pwd)/dist:/code devforth/spa-to-http:latest
6474

6575
Open `http://localhost:8080` in your browser.
6676

77+
### Basic Auth (Docker)
78+
79+
```bash
80+
docker run --rm -p 8080:8080 \
81+
-e BASIC_AUTH="admin:secret" \
82+
-e BASIC_AUTH_REALM="SPA Server" \
83+
-v $(pwd)/dist:/code \
84+
devforth/spa-to-http:latest
85+
```
86+
6787
## Build + Run in One Dockerfile
6888

6989
Use this pattern when you want to build the SPA and ship a small runtime image:

src/app/app.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,16 @@ func (app *App) HandlerFuncNew(w http.ResponseWriter, r *http.Request) {
325325

326326
func (app *App) Listen() {
327327
var handlerFunc http.Handler = http.HandlerFunc(app.HandlerFuncNew)
328+
if app.params.BasicAuthEnabled {
329+
handlerFunc = app.BasicAuthMiddleware(handlerFunc)
330+
if logger := app.authLogger(); logger != nil {
331+
realm := app.params.BasicAuthRealm
332+
if realm == "" {
333+
realm = defaultBasicAuthRealm
334+
}
335+
logger.Info("[AUTH] enabled", "realm", realm)
336+
}
337+
}
328338
if app.params.Logger {
329339
handlerFunc = util.LogRequestHandler(handlerFunc, &util.LogRequestHandlerOptions{
330340
Pretty: app.params.LogPretty,

src/app/app_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,8 @@ func TestCompressFiles(t *testing.T) {
9999
app5.CompressFiles()
100100
os.Remove("app_test.go.br")
101101
os.Remove("app_internal_test.go.br")
102+
os.Remove("auth.go.br")
103+
os.Remove("auth_test.go.br")
102104
os.Remove("app.go.br")
103105

104106
params.Directory = "../../test/frontend/dist/vite.svg.br"

src/app/auth.go

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package app
2+
3+
import (
4+
"crypto/sha256"
5+
"crypto/subtle"
6+
"fmt"
7+
"log/slog"
8+
"net/http"
9+
"os"
10+
"strings"
11+
)
12+
13+
const defaultBasicAuthRealm = "Restricted"
14+
15+
func (app *App) BasicAuthMiddleware(next http.Handler) http.Handler {
16+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
17+
if !app.params.BasicAuthEnabled {
18+
next.ServeHTTP(w, r)
19+
return
20+
}
21+
22+
realm := app.params.BasicAuthRealm
23+
if realm == "" {
24+
realm = defaultBasicAuthRealm
25+
}
26+
27+
logger := app.authLogger()
28+
if logger != nil {
29+
logger.Debug("[AUTH] start",
30+
"path", r.URL.Path,
31+
"remoteAddr", r.RemoteAddr,
32+
"hasAuthHeader", r.Header.Get("Authorization") != "",
33+
)
34+
}
35+
36+
user, pass, ok := r.BasicAuth()
37+
userMatch := constantTimeStringMatch(user, app.params.BasicAuthUser)
38+
passMatch := constantTimeStringMatch(pass, app.params.BasicAuthPass)
39+
authorized := ok && userMatch && passMatch
40+
41+
if !authorized {
42+
w.Header().Set("WWW-Authenticate", fmt.Sprintf("Basic realm=\"%s\"", realm))
43+
w.WriteHeader(http.StatusUnauthorized)
44+
if logger != nil {
45+
logger.Info("[AUTH] failure",
46+
"path", r.URL.Path,
47+
"remoteAddr", r.RemoteAddr,
48+
"userProvided", user != "",
49+
"matchedUser", userMatch,
50+
"matchedPass", passMatch,
51+
)
52+
}
53+
return
54+
}
55+
56+
if logger != nil {
57+
logger.Info("[AUTH] success",
58+
"path", r.URL.Path,
59+
"remoteAddr", r.RemoteAddr,
60+
"user", user,
61+
)
62+
}
63+
64+
next.ServeHTTP(w, r)
65+
})
66+
}
67+
68+
func (app *App) authLogger() *slog.Logger {
69+
if !app.params.Logger {
70+
return nil
71+
}
72+
73+
level := slog.LevelInfo
74+
switch strings.ToLower(os.Getenv("LOG_LEVEL")) {
75+
case "debug":
76+
level = slog.LevelDebug
77+
case "warn", "warning":
78+
level = slog.LevelWarn
79+
case "error":
80+
level = slog.LevelError
81+
}
82+
83+
opts := &slog.HandlerOptions{Level: level}
84+
if app.params.LogPretty {
85+
return slog.New(slog.NewTextHandler(os.Stdout, opts))
86+
}
87+
return slog.New(slog.NewJSONHandler(os.Stdout, opts))
88+
}
89+
90+
func constantTimeStringMatch(a string, b string) bool {
91+
sumA := sha256.Sum256([]byte(a))
92+
sumB := sha256.Sum256([]byte(b))
93+
return subtle.ConstantTimeCompare(sumA[:], sumB[:]) == 1
94+
}

src/app/auth_test.go

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
package app
2+
3+
import (
4+
"go-http-server/param"
5+
"net/http"
6+
"net/http/httptest"
7+
"testing"
8+
)
9+
10+
func TestBasicAuthMiddlewareDisabled(t *testing.T) {
11+
params := param.Params{
12+
BasicAuthEnabled: false,
13+
}
14+
app := NewApp(&params)
15+
16+
handler := app.BasicAuthMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
17+
w.WriteHeader(http.StatusOK)
18+
w.Write([]byte("ok"))
19+
}))
20+
21+
req := httptest.NewRequest("GET", "/", nil)
22+
rec := httptest.NewRecorder()
23+
handler.ServeHTTP(rec, req)
24+
25+
if rec.Code != http.StatusOK {
26+
t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code)
27+
}
28+
if rec.Body.String() != "ok" {
29+
t.Fatalf("expected body ok, got %s", rec.Body.String())
30+
}
31+
}
32+
33+
func TestBasicAuthMiddlewareMissingHeader(t *testing.T) {
34+
params := param.Params{
35+
BasicAuthEnabled: true,
36+
BasicAuthUser: "user",
37+
BasicAuthPass: "pass",
38+
}
39+
app := NewApp(&params)
40+
41+
handler := app.BasicAuthMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
42+
w.WriteHeader(http.StatusOK)
43+
}))
44+
45+
req := httptest.NewRequest("GET", "/", nil)
46+
rec := httptest.NewRecorder()
47+
handler.ServeHTTP(rec, req)
48+
49+
if rec.Code != http.StatusUnauthorized {
50+
t.Fatalf("expected status %d, got %d", http.StatusUnauthorized, rec.Code)
51+
}
52+
if got := rec.Header().Get("WWW-Authenticate"); got != "Basic realm=\"Restricted\"" {
53+
t.Fatalf("expected realm header, got %s", got)
54+
}
55+
}
56+
57+
func TestBasicAuthMiddlewareWrongCredentials(t *testing.T) {
58+
params := param.Params{
59+
BasicAuthEnabled: true,
60+
BasicAuthUser: "user",
61+
BasicAuthPass: "pass",
62+
BasicAuthRealm: "CustomRealm",
63+
}
64+
app := NewApp(&params)
65+
66+
handler := app.BasicAuthMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
67+
w.WriteHeader(http.StatusOK)
68+
}))
69+
70+
req := httptest.NewRequest("GET", "/", nil)
71+
req.SetBasicAuth("user", "wrong")
72+
rec := httptest.NewRecorder()
73+
handler.ServeHTTP(rec, req)
74+
75+
if rec.Code != http.StatusUnauthorized {
76+
t.Fatalf("expected status %d, got %d", http.StatusUnauthorized, rec.Code)
77+
}
78+
if got := rec.Header().Get("WWW-Authenticate"); got != "Basic realm=\"CustomRealm\"" {
79+
t.Fatalf("expected realm header, got %s", got)
80+
}
81+
}
82+
83+
func TestBasicAuthMiddlewareSuccess(t *testing.T) {
84+
params := param.Params{
85+
BasicAuthEnabled: true,
86+
BasicAuthUser: "user",
87+
BasicAuthPass: "pass",
88+
}
89+
app := NewApp(&params)
90+
91+
handler := app.BasicAuthMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
92+
w.WriteHeader(http.StatusOK)
93+
w.Write([]byte("ok"))
94+
}))
95+
96+
req := httptest.NewRequest("GET", "/", nil)
97+
req.SetBasicAuth("user", "pass")
98+
rec := httptest.NewRecorder()
99+
handler.ServeHTTP(rec, req)
100+
101+
if rec.Code != http.StatusOK {
102+
t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code)
103+
}
104+
if rec.Body.String() != "ok" {
105+
t.Fatalf("expected body ok, got %s", rec.Body.String())
106+
}
107+
}

src/param/param.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package param
33
import (
44
"github.com/urfave/cli/v2"
55
"path/filepath"
6+
"strings"
67
)
78

89
var Flags = []cli.Flag{
@@ -85,6 +86,16 @@ var Flags = []cli.Flag{
8586
Name: "no-compress",
8687
Value: nil,
8788
},
89+
&cli.StringFlag{
90+
EnvVars: []string{"BASIC_AUTH"},
91+
Name: "basic-auth",
92+
Value: "",
93+
},
94+
&cli.StringFlag{
95+
EnvVars: []string{"BASIC_AUTH_REALM"},
96+
Name: "basic-auth-realm",
97+
Value: "",
98+
},
8899
}
89100

90101
type Params struct {
@@ -102,6 +113,11 @@ type Params struct {
102113
Logger bool
103114
LogPretty bool
104115
NoCompress []string
116+
BasicAuthRaw string
117+
BasicAuthUser string
118+
BasicAuthPass string
119+
BasicAuthRealm string
120+
BasicAuthEnabled bool
105121
//DirectoryListing bool
106122
}
107123

@@ -115,6 +131,25 @@ func ContextToParamsWithAbs(c *cli.Context, abs func(string) (string, error)) (*
115131
return nil, err
116132
}
117133

134+
basicAuthRaw := c.String("basic-auth")
135+
basicAuthRealm := c.String("basic-auth-realm")
136+
var basicAuthUser string
137+
var basicAuthPass string
138+
var basicAuthEnabled bool
139+
140+
if basicAuthRaw != "" {
141+
user, pass, ok := strings.Cut(basicAuthRaw, ":")
142+
if !ok || user == "" || pass == "" {
143+
return nil, cli.Exit("invalid basic-auth format, expected username:password", 1)
144+
}
145+
basicAuthUser = user
146+
basicAuthPass = pass
147+
basicAuthEnabled = true
148+
if basicAuthRealm == "" {
149+
basicAuthRealm = "Restricted"
150+
}
151+
}
152+
118153
return &Params{
119154
Address: c.String("address"),
120155
Port: c.Int("port"),
@@ -130,6 +165,11 @@ func ContextToParamsWithAbs(c *cli.Context, abs func(string) (string, error)) (*
130165
Logger: c.Bool("logger"),
131166
LogPretty: c.Bool("log-pretty"),
132167
NoCompress: c.StringSlice("no-compress"),
168+
BasicAuthRaw: basicAuthRaw,
169+
BasicAuthUser: basicAuthUser,
170+
BasicAuthPass: basicAuthPass,
171+
BasicAuthRealm: basicAuthRealm,
172+
BasicAuthEnabled: basicAuthEnabled,
133173
//DirectoryListing: c.Bool("directory-listing"),
134174
}, nil
135175
}

0 commit comments

Comments
 (0)