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:
parent
d96cf3d34d
commit
dbd711ded8
3 changed files with 168 additions and 0 deletions
|
@ -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
47
web/fup/fuphttp/auth.go
Normal 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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
113
web/fup/fuphttp/auth_test.go
Normal file
113
web/fup/fuphttp/auth_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue