From dbd711ded8500431e515a07e27132cd19bab7a4e Mon Sep 17 00:00:00 2001 From: Luke Granger-Brown Date: Tue, 23 Mar 2021 00:46:33 +0000 Subject: [PATCH] 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. --- web/fup/cmd/serve.go | 8 +++ web/fup/fuphttp/auth.go | 47 +++++++++++++++ web/fup/fuphttp/auth_test.go | 113 +++++++++++++++++++++++++++++++++++ 3 files changed, 168 insertions(+) create mode 100644 web/fup/fuphttp/auth.go create mode 100644 web/fup/fuphttp/auth_test.go diff --git a/web/fup/cmd/serve.go b/web/fup/cmd/serve.go index 6a69106432..21d9d69a6e 100644 --- a/web/fup/cmd/serve.go +++ b/web/fup/cmd/serve.go @@ -37,6 +37,11 @@ func init() { 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.") 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 ( @@ -46,6 +51,8 @@ var ( serveDirectOnly bool serveCheddarPath string serveCheddarAddr string + serveAuthToken string + serveAuthRealm string serveCmd = &cobra.Command{ Use: "serve", @@ -72,6 +79,7 @@ var ( StorageURL: bucketURL, RedirectToBlobstore: !serveDirectOnly, Highlighter: highlighter, + AuthMiddleware: fuphttp.TokenAuthMiddleware(serveAuthToken, serveAuthRealm), } a, err := fuphttp.New(ctx, cfg) if err != nil { diff --git a/web/fup/fuphttp/auth.go b/web/fup/fuphttp/auth.go new file mode 100644 index 0000000000..14a1494408 --- /dev/null +++ b/web/fup/fuphttp/auth.go @@ -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 + }) + } +} diff --git a/web/fup/fuphttp/auth_test.go b/web/fup/fuphttp/auth_test.go new file mode 100644 index 0000000000..a702f43c6f --- /dev/null +++ b/web/fup/fuphttp/auth_test.go @@ -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) + } + }) + } +}