alexedwards / scs

HTTP Session Management for Go
MIT License
2.09k stars 166 forks source link

Action on cookie create/update #112

Closed ivanduka closed 3 years ago

ivanduka commented 3 years ago

Hi,

Thank you for creating this awesome library!

I'm currently using it in a single-page application, and trying to figure out how to safely let the front-end "know" the authentication status. The problem is that I want to keep the authentication cookie HttpOnly, and my JavaScript code cannot access that.

One of the ways to solve it is to have two cookies: one is HttpOnly and contains the secret hash, and the second one is not HttpOnly, and does not contain any secret information, but has the same expiration date as the HttpOnly one.

Observing the behaviour of the library, the only way I found to achieve this is through custom LoadAndSave method. The only change I added (see below between "CUSTOM CODE" headers) is to create a copy of an authentication cookie, but with no value (secret payload) and HttpOnly set to false.

However, now I am on the hook to carefully review any updates to this library and 'sync' the changes to the standard LoadAndSave method and my custom one.

Is there a simpler way to achieve it?

func (s *MySessionManager) CustomLoadAndSave(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        var token string
        cookie, err := r.Cookie(s.Cookie.Name)
        if err == nil {
            token = cookie.Value
        }

        ctx, err := s.Load(r.Context(), token)
        if err != nil {
            s.ErrorFunc(w, r, err)
            return
        }

        sr := r.WithContext(ctx)
        bw := &bufferedResponseWriter{ResponseWriter: w}
        next.ServeHTTP(bw, sr)

        if sr.MultipartForm != nil {
            _ = sr.MultipartForm.RemoveAll()
        }

        if s.Status(ctx) != scs.Unmodified {
            responseCookie := &http.Cookie{
                Name:     s.Cookie.Name,
                Path:     s.Cookie.Path,
                Domain:   s.Cookie.Domain,
                Secure:   s.Cookie.Secure,
                HttpOnly: s.Cookie.HttpOnly,
                SameSite: s.Cookie.SameSite,
            }

            switch s.Status(ctx) {
            case scs.Modified:
                token, expiry, err := s.Commit(ctx)
                if err != nil {
                    s.ErrorFunc(w, r, err)
                    return
                }

                responseCookie.Value = token

                if s.Cookie.Persist || s.GetBool(ctx, "__rememberMe") {
                    responseCookie.Expires = time.Unix(expiry.Unix()+1, 0)        // Round up to the nearest second.
                    responseCookie.MaxAge = int(time.Until(expiry).Seconds() + 1) // Round up to the nearest second.
                }
            case scs.Destroyed:
                responseCookie.Expires = time.Unix(1, 0)
                responseCookie.MaxAge = -1
            }

            w.Header().Add("Set-Cookie", responseCookie.String())
            addHeaderIfMissing(w, "Cache-Control", `no-cache="Set-Cookie"`)
            addHeaderIfMissing(w, "Vary", "Cookie")

                        // -------------- CUSTOM CODE BEGINS --------------------------
            // this cookie is set so the front-end "knows" the authentication status
            twinNonHTTPOnlyCookie := http.Cookie{
                Name:     responseCookie.Name + "_twin",
                Path:     responseCookie.Path,
                Domain:   responseCookie.Domain,
                Secure:   responseCookie.Secure,
                HttpOnly: false, // important so that it is accessible to the front-end JavaScript
                SameSite: responseCookie.SameSite,
                Expires:  responseCookie.Expires,
                MaxAge:   responseCookie.MaxAge,
            }

            http.SetCookie(w, &twinNonHTTPOnlyCookie)
                        // -------------- CUSTOM CODE ENDS --------------------------
        }

        if bw.code != 0 {
            w.WriteHeader(bw.code)
        }
        _, _ = w.Write(bw.buf.Bytes())
    })
}
alexedwards commented 3 years ago

I'm not an expert in SPAs, but I think that a common way of handling this is to implement some kind of /profile endpoint in your API. The JS frontend can then make a request to /profile, which (CORS and WithCredentials issues aside) will pass the HttpOnly session cookie along with it. The /profile endpoint can then check for the presence of a session cookie in the request and return the user information and you know they are 'logged in' (if no valid session cookie is present, then it can return a error code instead). This user information can then be cached by the SPA and used for the duration of the browser session for things like displaying the user name etc. The session cookie should continue to be sent to all subsequent requests to the API and checked before doing anything that requires authentication.