'Remember Me' using the Go gorilla framework

A ‘Remember Me’ type functionality is something that is available on most websites today. Essentially what it does is that if the checkbox is checked when logging in, the session is kept logged in for a much longer duration (say a week or month). Otherwise, when you close the browser, the session terminates and you have to log in again the next time you visit the site.

In golang, it is implemented easily using the gorilla toolkit. In this example we will be using the sessions package. The basic idea is as follows:

We will first show the code to do the above and then show that it does not actually work ! The problem and fix is outlined next.

The basic idea

The pseudo code for making it work is as follows:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
var sessionStore *pgstore.PGStore

type SessionData struct {
	UserID  uint
	Auth    bool
}

func init() {
    // psqlInfo will contain ur db connection info in the format: "host=%s port=%d user=%s password=%s dbname=%s sslmode=disable"
	sessionStore, err = pgstore.NewPGStore(psqlInfo, []byte(os.Getenv("YOUR_AUTH_KEY")))
    ...
    ...
}

// Load the session in a middleware
func sessionMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

		session, err := sessionStore.Get(r, CookieName)
		if err != nil {
			// redirect the user to the home page
		}

		ctx := context.WithValue(r.Context(), SessionCtxKey, session)

		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

func handleLogin(w http.ResponseWriter, r *http.Request) {
    session := r.Context().Value(SessionCtxKey).(*sessions.Session)

    // check the login here, return if no good

    // user login checks out fine
    if r.FormValue("rememberme") == "on" {
		session.Options.MaxAge = 86400 * 7 // 1 week
	} else {
		session.Options.MaxAge = 0
	}

	var data *SessionData
    data = &SessionData{
        // store anything you want here like a user_id, authenticated or not etc
    }

	session.Values["data"] = data
	err = session.Save(r, w)

    // The user is logged in now...
    http.Redirect(w, r, "/dashboard", http.StatusFound)
	return
}

But it does not work !

Only problem is, the above does not actually work. If you log in with the “remember me” checked and you examine the cookie after logging in, you will see that it is still a session cookie i.e. it will expire after the browser is closed !

The reason it is happening is that when the new request comes in after logging in, the session parameters are reset to the default of the toolkit. The session data from the DB will be there, but the options on the request session like MaxAge etc will be set to the default of the framework. e.g.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
func authMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        session := r.Context().Value(SessionCtxKey).(*sessions.Session)

		if session.IsNew == true {
			http.Redirect(w, r, "/login", http.StatusFound)
			return
		}

		val := session.Values["data"]
		cd, ok := val.(SessionData)
		if !ok {
			http.Redirect(w, r, "/login", http.StatusFound)
			return
		}

        // At this point cd contains all the data you stored in the DB for the session
        // However the properties like session.Options.MaxAge are set to the default which is probably 0
        // i.e. you get back to a session cookie

    })
}

The fix

In order to fix this, we need to store information about the expiry time in the session data and then when we pull the session data out, update the request session properties. We can change it like so:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
type SessionData struct {
	UserID  uint
	Auth    bool
	Expires time.Time
}

func authMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        session := r.Context().Value(SessionCtxKey).(*sessions.Session)

		...

        if !cd.Expires.IsZero() {
			timeDelta := cd.Expires.Sub(time.Now()).Seconds()
			if timeDelta < 0 {
				session.Options.MaxAge = -1
			} else {
				session.Options.MaxAge = int(timeDelta)
			}

			err := session.Save(r, w)
			if err != nil {
				http.Error(w, err.Error(), http.StatusInternalServerError)
				return
			}
		}

        // From now on, the session MaxAge is correctly populated and hence the returned cookie will have the correct Expires header set

    })
}

I spent a couple of hours chasing this bug down. Hopefully it saves someone else time !