fup: add TokenAuthMiddleware

This is an example middleware which can be used as an AuthMiddleware for only
allowing requests to non-view pages which are accompanied by an auth token via
HTTP basic auth.
This commit is contained in:
Luke Granger-Brown 2021-03-23 00:46:33 +00:00
parent d96cf3d34d
commit dbd711ded8
3 changed files with 168 additions and 0 deletions

View file

@ -37,6 +37,11 @@ func init() {
viper.BindPFlag("serve.cheddar.path", serveCmd.Flags().Lookup("cheddar-path")) viper.BindPFlag("serve.cheddar.path", serveCmd.Flags().Lookup("cheddar-path"))
serveCmd.Flags().StringVar(&serveCheddarAddr, "cheddar-address", "", "If non-empty, will be used instead of attempting to spawn a copy of cheddar.") serveCmd.Flags().StringVar(&serveCheddarAddr, "cheddar-address", "", "If non-empty, will be used instead of attempting to spawn a copy of cheddar.")
viper.BindPFlag("serve.cheddar.address", serveCmd.Flags().Lookup("cheddar-address")) viper.BindPFlag("serve.cheddar.address", serveCmd.Flags().Lookup("cheddar-address"))
serveCmd.Flags().StringVar(&serveAuthToken, "auth-token", "", "If non-empty, this auth token will be required as the Basic Auth password.")
viper.BindPFlag("serve.auth.token", serveCmd.Flags().Lookup("auth-token"))
serveCmd.Flags().StringVar(&serveAuthRealm, "auth-realm", "fup", "Will be used as the realm for Basic Auth.")
viper.BindPFlag("serve.auth.realm", serveCmd.Flags().Lookup("auth-realm"))
} }
var ( var (
@ -46,6 +51,8 @@ var (
serveDirectOnly bool serveDirectOnly bool
serveCheddarPath string serveCheddarPath string
serveCheddarAddr string serveCheddarAddr string
serveAuthToken string
serveAuthRealm string
serveCmd = &cobra.Command{ serveCmd = &cobra.Command{
Use: "serve", Use: "serve",
@ -72,6 +79,7 @@ var (
StorageURL: bucketURL, StorageURL: bucketURL,
RedirectToBlobstore: !serveDirectOnly, RedirectToBlobstore: !serveDirectOnly,
Highlighter: highlighter, Highlighter: highlighter,
AuthMiddleware: fuphttp.TokenAuthMiddleware(serveAuthToken, serveAuthRealm),
} }
a, err := fuphttp.New(ctx, cfg) a, err := fuphttp.New(ctx, cfg)
if err != nil { if err != nil {

47
web/fup/fuphttp/auth.go Normal file
View file

@ -0,0 +1,47 @@
package fuphttp
import (
"crypto/subtle"
"fmt"
"net/http"
"github.com/gorilla/mux"
)
func TokenAuthMiddleware(token, realm string) mux.MiddlewareFunc {
return func(next http.Handler) http.Handler {
if token == "" {
return next
}
tokenBytes := []byte(token)
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if !IsMutate(ctx) {
// Allow all access to public pages.
next.ServeHTTP(rw, r)
return
}
requestAuth := func(s string) {
rw.Header().Set("WWW-Authenticate", fmt.Sprintf("Basic realm=%q, charset=\"UTF-8\"", realm))
http.Error(rw, s, http.StatusUnauthorized)
}
// Check for basic auth, first.
_, pw, ok := r.BasicAuth()
switch {
case !ok:
requestAuth("unparsable or no credentials")
return
case subtle.ConstantTimeCompare([]byte(pw), tokenBytes) != 1:
requestAuth("bad credentials")
return
}
// Auth check passed, let's go.
next.ServeHTTP(rw, r)
return
})
}
}

View file

@ -0,0 +1,113 @@
package fuphttp_test
import (
"context"
"io"
"net/http"
"net/http/httptest"
"testing"
"hg.lukegb.com/lukegb/depot/web/fup/fuphttp"
)
func TestTokenAuthMiddleware(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
ccfg := *cfg
ccfg.AuthMiddleware = fuphttp.TokenAuthMiddleware("token", "realm")
a, err := fuphttp.New(ctx, &ccfg)
if err != nil {
t.Fatalf("fuphttp.New: %v", err)
}
s := httptest.NewServer(a.Handler())
t.Cleanup(s.Close)
tcs := []struct {
name string
path string
username, password string
wantStatus int
wantText string
}{{
name: "root, no creds",
path: "/",
wantStatus: http.StatusUnauthorized,
wantText: "unparsable or no credentials\n",
}, {
name: "root, with bad creds",
path: "/",
password: "wrong password",
wantStatus: http.StatusUnauthorized,
wantText: "bad credentials\n",
}, {
name: "root, with good creds",
path: "/",
password: "token",
wantStatus: http.StatusOK,
}, {
name: "raw",
path: "/raw/foo.txt",
wantStatus: http.StatusNotFound,
}, {
name: "raw, with bad creds",
path: "/raw/foo.txt",
password: "wrong password",
wantStatus: http.StatusNotFound,
}, {
name: "raw, with good creds",
path: "/raw/foo.txt",
password: "token",
wantStatus: http.StatusNotFound,
}, {
name: "pretty",
path: "/foo.txt",
wantStatus: http.StatusNotFound,
}, {
name: "pretty, with bad creds",
path: "/foo.txt",
password: "wrong password",
wantStatus: http.StatusNotFound,
}, {
name: "pretty, with good creds",
path: "/foo.txt",
password: "token",
wantStatus: http.StatusNotFound,
}}
for _, tc := range tcs {
t.Run(tc.name, func(t *testing.T) {
ctx, cancel := context.WithCancel(ctx)
t.Cleanup(cancel)
req, err := http.NewRequestWithContext(ctx, "GET", s.URL+tc.path, nil)
if err != nil {
t.Fatalf("NewRequestWithContext: %v", err)
}
if tc.password != "" {
req.SetBasicAuth("", tc.password)
}
resp, err := s.Client().Do(req)
if err != nil {
t.Fatalf("Do(%q): %v", s.URL+tc.path, err)
}
defer resp.Body.Close()
if resp.StatusCode != tc.wantStatus {
t.Errorf("StatusCode = %v; want %v", resp.StatusCode, tc.wantStatus)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("ReadAll(Body): %v", err)
}
if tc.wantText != "" && string(body) != tc.wantText {
t.Errorf("response body = %q; want %q", string(body), tc.wantText)
}
})
}
}