Search code examples
gobasic-authenticationroutergorillamux

Unable to protect gorilla/mux Subroute with basic auth


I am trying to create routes using gorilla/mux, some of which should be protected by basic auth and others shouldn't. Specifically, every route under /v2 should require basic auth, but the routes under /health should be publicly accessible.

As you can see below, I can wrap each of my /v2 route handlers with BasicAuth(), but that's against the DRY principle, and also error prone, not to mention the security implications of forgetting to wrap one of those handlers.

I have the following output from curl. All but the last one is as I expect. One should not be able to GET /smallcat without authentication.

$ curl localhost:3000/health/ping
"PONG"
$ curl localhost:3000/health/ping/
404 page not found
$ curl localhost:3000/v2/bigcat
Unauthorised.
$ curl apiuser:apipass@localhost:3000/v2/bigcat
"Big MEOW"
$ curl localhost:3000/v2/smallcat
"Small Meow"

Here's the complete code. I believe I need to fix the v2Router definition somehow, but fail to see how.

package main

import (
    "crypto/subtle"
    "encoding/json"
    "log"
    "net/http"

    "github.com/gorilla/mux"
)

func endAPICall(w http.ResponseWriter, httpStatus int, anyStruct interface{}) {

    result, err := json.MarshalIndent(anyStruct, "", "  ")
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

    w.WriteHeader(httpStatus)

    w.Write(result)
}

func BasicAuth(handler http.HandlerFunc, username, password, realm string) http.HandlerFunc {

    return func(w http.ResponseWriter, r *http.Request) {

        user, pass, ok := r.BasicAuth()

        if !ok || subtle.ConstantTimeCompare([]byte(user), []byte(username)) != 1 || subtle.ConstantTimeCompare([]byte(pass), []byte(password)) != 1 {
            w.Header().Set("WWW-Authenticate", `Basic realm="`+realm+`"`)
            w.WriteHeader(401)
            w.Write([]byte("Unauthorised.\n"))
            return
        }

        handler(w, r)
    }
}

func routers() *mux.Router {
    username := "apiuser"
    password := "apipass"

    noopHandler := func(http.ResponseWriter, *http.Request) {}

    topRouter := mux.NewRouter().StrictSlash(false)
    healthRouter := topRouter.PathPrefix("/health/").Subrouter()
    v2Router := topRouter.PathPrefix("/v2").HandlerFunc(BasicAuth(noopHandler, username, password, "Provide username and password")).Subrouter()

    healthRouter.HandleFunc("/ping", func(w http.ResponseWriter, r *http.Request) {
        endAPICall(w, 200, "PONG")
    })

    v2Router.HandleFunc("/smallcat", func(w http.ResponseWriter, r *http.Request) {
        endAPICall(w, 200, "Small Meow")
    })

    bigMeowFn := func(w http.ResponseWriter, r *http.Request) {
        endAPICall(w, 200, "Big MEOW")
    }

    v2Router.HandleFunc("/bigcat", BasicAuth(bigMeowFn, username, password, "Provide username and password"))

    return topRouter
}

func main() {
    if r := routers(); r != nil {
        log.Fatal("Server exited:", http.ListenAndServe(":3000", r))
    }
}

Solution

  • I achieved the expected behavior by using negroni. If the BasicAuth() call fails, none of the route handlers under /v2 are invoked.

    The working code is in a Gist (with revisions, for those interested) here: https://gist.github.com/gurjeet/13b2f69af6ac80c0357ab20ee24fa575

    Per SO convention, though, here's the complete code:

    package main
    
    import (
        "crypto/subtle"
        "encoding/json"
        "log"
        "net/http"
    
        "github.com/gorilla/mux"
        "github.com/urfave/negroni"
    )
    
    func endAPICall(w http.ResponseWriter, httpStatus int, anyStruct interface{}) {
    
        result, err := json.MarshalIndent(anyStruct, "", "  ")
        if err != nil {
            http.Error(w, err.Error(), http.StatusInternalServerError)
            return
        }
    
        w.WriteHeader(httpStatus)
        w.Write(result)
    }
    
    func BasicAuth(w http.ResponseWriter, r *http.Request, username, password, realm string) bool {
    
        user, pass, ok := r.BasicAuth()
    
        if !ok || subtle.ConstantTimeCompare([]byte(user), []byte(username)) != 1 || subtle.ConstantTimeCompare([]byte(pass), []byte(password)) != 1 {
            w.Header().Set("WWW-Authenticate", `Basic realm="`+realm+`"`)
            w.WriteHeader(401)
            w.Write([]byte("Unauthorised.\n"))
            return false
        }
    
        return true
    }
    
    func routers() *mux.Router {
        username := "apiuser"
        password := "apipass"
    
        v2Path := "/v2"
        healthPath := "/health"
    
        topRouter := mux.NewRouter().StrictSlash(true)
        healthRouter := mux.NewRouter().PathPrefix(healthPath).Subrouter().StrictSlash(true)
        v2Router := mux.NewRouter().PathPrefix(v2Path).Subrouter().StrictSlash(true)
    
        healthRouter.HandleFunc("/ping", func(w http.ResponseWriter, r *http.Request) {
            endAPICall(w, 200, "PONG")
        })
    
        v2Router.HandleFunc("/smallcat", func(w http.ResponseWriter, r *http.Request) {
            endAPICall(w, 200, "Small Meow")
        })
    
        bigMeowFn := func(w http.ResponseWriter, r *http.Request) {
            endAPICall(w, 200, "Big MEOW")
        }
    
        v2Router.HandleFunc("/bigcat", bigMeowFn)
    
        topRouter.PathPrefix(healthPath).Handler(negroni.New(
            /* Health-check routes are unprotected */
            negroni.Wrap(healthRouter),
        ))
    
        topRouter.PathPrefix(v2Path).Handler(negroni.New(
            negroni.HandlerFunc(func(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) {
                if BasicAuth(w, r, username, password, "Provide user name and password") {
                    /* Call the next handler iff Basic-Auth succeeded */
                    next(w, r)
                }
            }),
            negroni.Wrap(v2Router),
        ))
    
        return topRouter
    }
    
    func main() {
        if r := routers(); r != nil {
            log.Fatal("Server exited:", http.ListenAndServe(":3000", r))
        }
    }