shaj13 / go-guardian

Go-Guardian is a golang library that provides a simple, clean, and idiomatic way to create powerful modern API and web authentication.
MIT License
543 stars 55 forks source link

Question: How to achieve digest auth that survives after service restarts. #87

Closed dhubler closed 3 years ago

dhubler commented 3 years ago

I'm using Digest strategy and works fine until I restart my service. Chrome re-challenges user to give creds again. Strategy returns: userInfo = nil, err = Invalid Response to all web requests. I'm using FIFO lib cache FWIW.

Do I need to implement my own cache implementation that stores values on disk to survive restarts?

I'm trying to replace nginx reverse proxy and this doesn't happen w/nginx so this is a loss in functionality as well and user issue.

shaj13 commented 3 years ago

@dhubler FYI, When a client first requests a protected page, the server returns a 401 status code along with a challenge in the WWW-Authenticate header. At this point, most browsers will present a dialog box to the user prompting them to log in. then the browser will keep sending the same nonce to the server as long as it's valid on the server-side, once the nonce expired then the whole process repeated. same as well when the server restarts. unless nonce persistent.

it's not recommended to persist the nonce and it may cause a security vulnerability.

I've tested the digest with FF 85, Chrome 88, Safari 14. and it keeps authenticating the user and once the server restarted it present a dialog box to the user prompting them to log in and it continues from there.

code:

package main

import (
    "crypto"
    _ "crypto/md5"
    "fmt"
    "log"
    "net/http"
    "time"

    "github.com/gorilla/mux"
    "github.com/shaj13/libcache"
    _ "github.com/shaj13/libcache/fifo"

    "github.com/shaj13/go-guardian/v2/auth"
    "github.com/shaj13/go-guardian/v2/auth/strategies/digest"
)

var strategy *digest.Digest

func init() {
    var c libcache.Cache
    c = libcache.FIFO.New(10)
    c.SetTTL(time.Minute * 3)
    c.RegisterOnExpired(func(key, _ interface{}) {
        c.Delete(key)
    })
    opt := digest.SetHash(crypto.MD5, crypto.MD5.String())
    strategy = digest.New(validateUser, c, opt)
}

func main() {
    router := mux.NewRouter()
    router.HandleFunc("/v1/book/{id}", middleware(http.HandlerFunc(getBookAuthor))).Methods("GET")
    log.Println("server started and listening on http://127.0.0.1:8080")
    http.ListenAndServe("0.0.0.0:8080", router)
}

func getBookAuthor(w http.ResponseWriter, r *http.Request) {
    vars := mux.Vars(r)
    id := vars["id"]
    books := map[string]string{
        "1449311601": "Ryan Boyd",
        "148425094X": "Yvonne Wilson",
        "1484220498": "Prabath Siriwarden",
    }
    body := fmt.Sprintf("Author: %s \n", books[id])
    w.Write([]byte(body))
}

func validateUser(userName string) (string, auth.Info, error) {
    // here connect to db or any other service to fetch user and validate it.
    if userName == "admin" {
        return "admin", auth.NewDefaultUser("admin", "1", nil, nil), nil
    }

    return "", nil, fmt.Errorf("Invalid credentials")
}

func middleware(next http.Handler) http.HandlerFunc {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        log.Println("Executing Auth Middleware")
        log.Println("Debug Authorization Header:", r.Header.Get("Authorization"))
        user, err := strategy.Authenticate(r.Context(), r)
        if err != nil {
            code := http.StatusUnauthorized
            w.Header().Add("WWW-Authenticate", strategy.GetChallenge())
            http.Error(w, http.StatusText(code), code)
            fmt.Println("send error", err)
            return
        }
        log.Printf("User %s Authenticated\n", user.GetUserName())
        next.ServeHTTP(w, r)
    })
}
dhubler commented 3 years ago

Thanks for the response.

Is nginx digest is insecure because it survives restarts? Do you have any recommendations for what I want to achieve?

shaj13 commented 3 years ago

@dhubler

nginx digest store nonce in /dev/shm so when the process restarted it all the nonce can be accessible again but once VM or bare-metal server rebooted all the nonce will be dropped.

it depends on your requirement and what security level you want to achieve, but basically, you can stick with the re-login, the server must be highly available, and restart should only happen occasionally. for scalability, you can use jwt token alongside digest so the first request will use user credentials and then switch to token once token expired ask the user to re-login using digest and so on. so if the server restarted and the token still active use it to prevent the user login.

make sure your server is listening over an HTTPS socket to mitigate MITM attacks.

sample code

package main

import (
    "crypto"
    _ "crypto/md5"
    "fmt"
    "log"
    "net/http"
    "time"

    "github.com/gorilla/mux"
    "github.com/shaj13/libcache"
    _ "github.com/shaj13/libcache/fifo"

    "github.com/shaj13/go-guardian/v2/auth"
    "github.com/shaj13/go-guardian/v2/auth/strategies/digest"
    "github.com/shaj13/go-guardian/v2/auth/strategies/jwt"
    "github.com/shaj13/go-guardian/v2/auth/strategies/token"
    "github.com/shaj13/go-guardian/v2/auth/strategies/union"
)

// Usage:
// curl --digest --user admin:admin http://127.0.0.1:8080/v1/book/1449311601

var strategy union.Union
var digestStrategy *digest.Digest
var secret jwt.SecretsKeeper

func init() {
    var c libcache.Cache
    c = libcache.FIFO.New(10)
    c.SetTTL(time.Minute * 3)
    c.RegisterOnExpired(func(key, _ interface{}) {
        c.Delete(key)
    })
    secret = jwt.StaticSecret{
        Algorithm: jwt.HS256,
        ID:        "static-kid",
        Secret:    []byte("generated-secrets"),
    }
    parser := token.SetParser(token.CookieParser("token"))
    jwtStrategy := jwt.New(c, secret, parser)
    opt := digest.SetHash(crypto.MD5, crypto.MD5.String())
    digestStrategy = digest.New(validateUser, c, opt)
    strategy = union.New(jwtStrategy, digestStrategy)
}

func main() {
    router := mux.NewRouter()
    router.HandleFunc("/v1/book/{id}", middleware(http.HandlerFunc(getBookAuthor))).Methods("GET")
    log.Println("server started and listening on http://127.0.0.1:8080")
    http.ListenAndServe("0.0.0.0:8080", router)
}

func getBookAuthor(w http.ResponseWriter, r *http.Request) {
    vars := mux.Vars(r)
    id := vars["id"]
    books := map[string]string{
        "1449311601": "Ryan Boyd",
        "148425094X": "Yvonne Wilson",
        "1484220498": "Prabath Siriwarden",
    }
    body := fmt.Sprintf("Author: %s \n", books[id])
    w.Write([]byte(body))
}

func validateUser(userName string) (string, auth.Info, error) {
    // here connect to db or any other service to fetch user and validate it.
    if userName == "admin" {
        return "admin", auth.NewDefaultUser("admin", "1", nil, nil), nil
    }

    return "", nil, fmt.Errorf("Invalid credentials")
}

func middleware(next http.Handler) http.HandlerFunc {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        log.Println("Executing Auth Middleware")

        target, user, err := strategy.AuthenticateRequest(r)
        if err != nil {
            code := http.StatusUnauthorized
            w.Header().Add("WWW-Authenticate", digestStrategy.GetChallenge())
            http.Error(w, http.StatusText(code), code)
            fmt.Println("send error", err)
            return
        }
        log.Printf("User %s Authenticated\n", user.GetUserName())

        if _, ok := target.(*digest.Digest); ok {
            opt := jwt.SetExpDuration(time.Second * 3)
            token, _ := jwt.IssueAccessToken(user, secret, opt)
            cookie := http.Cookie{Name: "token", Value: token}
            http.SetCookie(w, &cookie)
        }

        next.ServeHTTP(w, r)
    })
}

feel free to reach out or reopen.