tenseleyflow/shithub / 440f2be

Browse files

session: thread session_epoch through middleware + login + password-change

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
440f2be3c910ee17dc1bbe2e8f840d2a51bab124
Parents
42e74ce
Tree
433598a

8 changed files

StatusFile+-
M internal/auth/session/session.go 14 8
M internal/web/auth_wiring.go 6 4
M internal/web/handlers/auth/auth.go 5 0
M internal/web/handlers/auth/auth_test.go 12 6
M internal/web/handlers/auth/password_change.go 11 0
M internal/web/handlers/auth/tokens_test.go 7 4
M internal/web/handlers/auth/twofactor.go 8 0
M internal/web/middleware/auth.go 25 7
internal/auth/session/session.gomodified
@@ -28,14 +28,20 @@ const DefaultMaxAge = 30 * 24 * time.Hour
2828
 // Session is the data carried in a cookie. The shape is intentionally
2929
 // small; anything that doesn't fit a few hundred bytes belongs server-side.
3030
 type Session struct {
31
-	UserID       int64             `json:"uid,omitempty"`
32
-	Pre2FAUserID int64             `json:"p2,omitempty"` // set after password OK, before TOTP step
33
-	Recent2FAAt  int64             `json:"r2,omitempty"` // unix-seconds of last successful 2FA challenge
34
-	CSRFToken    string            `json:"csrf,omitempty"`
35
-	Theme        string            `json:"theme,omitempty"`
36
-	Flashes      []string          `json:"flashes,omitempty"`
37
-	Extras       map[string]string `json:"extras,omitempty"`
38
-	IssuedAt     int64             `json:"iat,omitempty"`
31
+	UserID       int64 `json:"uid,omitempty"`
32
+	Pre2FAUserID int64 `json:"p2,omitempty"` // set after password OK, before TOTP step
33
+	Recent2FAAt  int64 `json:"r2,omitempty"` // unix-seconds of last successful 2FA challenge
34
+	// Epoch is the users.session_epoch value at issue time. The session
35
+	// loader compares it against the current DB value on every request;
36
+	// "log out everywhere" bumps the column, invalidating every cookie
37
+	// that still carries the old epoch. Zero is the unbumped baseline
38
+	// and matches the migration's column default.
39
+	Epoch     int32             `json:"e,omitempty"`
40
+	CSRFToken string            `json:"csrf,omitempty"`
41
+	Theme     string            `json:"theme,omitempty"`
42
+	Flashes   []string          `json:"flashes,omitempty"`
43
+	Extras    map[string]string `json:"extras,omitempty"`
44
+	IssuedAt  int64             `json:"iat,omitempty"`
3945
 }
4046
 
4147
 // IsAnonymous returns true when no user is bound to the session.
internal/web/auth_wiring.gomodified
@@ -42,14 +42,16 @@ func buildAPIHandlers(pool *pgxpool.Pool) (*apih.Handlers, error) {
4242
 }
4343
 
4444
 // usernameLookup returns the lookup function consumed by middleware.OptionalUser.
45
-func usernameLookup(pool *pgxpool.Pool) func(context.Context, int64) (string, error) {
45
+// It resolves both the username and the user's current session_epoch so the
46
+// auth middleware can refuse stale cookies (bumped by "log out everywhere").
47
+func usernameLookup(pool *pgxpool.Pool) func(context.Context, int64) (string, int32, error) {
4648
 	q := usersdb.New()
47
-	return func(ctx context.Context, id int64) (string, error) {
49
+	return func(ctx context.Context, id int64) (string, int32, error) {
4850
 		u, err := q.GetUserByID(ctx, pool, id)
4951
 		if err != nil {
50
-			return "", err
52
+			return "", 0, err
5153
 		}
52
-		return u.Username, nil
54
+		return u.Username, u.SessionEpoch, nil
5355
 	}
5456
 }
5557
 
internal/web/handlers/auth/auth.gomodified
@@ -147,6 +147,8 @@ func (h *Handlers) Mount(r chi.Router) {
147147
 			r.Post("/settings/emails/{id}/remove", h.settingsEmailsRemove)
148148
 			r.Get("/settings/notifications", h.settingsNotificationsForm)
149149
 			r.Post("/settings/notifications", h.settingsNotificationsSubmit)
150
+			r.Get("/settings/sessions", h.settingsSessionsList)
151
+			r.Post("/settings/sessions/logout-everywhere", h.settingsSessionsLogoutAll)
150152
 			r.Get("/settings/keys", h.sshKeysList)
151153
 			r.Post("/settings/keys", h.sshKeysAdd)
152154
 			r.Post("/settings/keys/{id}/delete", h.sshKeysDelete)
@@ -434,9 +436,12 @@ func (h *Handlers) loginSubmit(w http.ResponseWriter, r *http.Request) {
434436
 
435437
 	// Session-fixation defense: bind user_id and re-issue cookie. The
436438
 	// AEAD store re-encrypts on every Save, producing a fresh ciphertext.
439
+	// Epoch snapshotting is what powers "log out everywhere": this cookie
440
+	// is invalidated the moment users.session_epoch advances past it.
437441
 	s := middleware.SessionFromContext(r.Context())
438442
 	s.UserID = user.ID
439443
 	s.Pre2FAUserID = 0
444
+	s.Epoch = user.SessionEpoch
440445
 	s.IssuedAt = time.Now().Unix()
441446
 	if err := h.d.SessionStore.Save(w, r, s); err != nil {
442447
 		h.d.Logger.ErrorContext(r.Context(), "login: save session", "error", err)
internal/web/handlers/auth/auth_test.gomodified
@@ -123,17 +123,21 @@ func newTestServer(t *testing.T, requireVerify bool) (*httptest.Server, *capture
123123
 	r.Use(middleware.RequestID)
124124
 	r.Use(middleware.RealIP(middleware.RealIPConfig{}))
125125
 	r.Use(middleware.SessionLoader(store, logger))
126
-	r.Use(middleware.OptionalUser(func(ctx context.Context, id int64) (string, error) {
127
-		// Cheap username lookup against the test pool — RequireUser only
128
-		// checks ID == 0, but settings handlers use the username.
126
+	r.Use(middleware.OptionalUser(func(ctx context.Context, id int64) (string, int32, error) {
127
+		// Cheap lookup against the test pool — settings handlers use the
128
+		// username, and the epoch comparison enforces log-out-everywhere
129
+		// across the same suite.
129130
 		u, err := pool.Acquire(ctx)
130131
 		if err != nil {
131
-			return "", err
132
+			return "", 0, err
132133
 		}
133134
 		defer u.Release()
134135
 		var name string
135
-		err = u.QueryRow(ctx, "SELECT username FROM users WHERE id = $1", id).Scan(&name)
136
-		return name, err
136
+		var epoch int32
137
+		err = u.QueryRow(ctx,
138
+			"SELECT username, session_epoch FROM users WHERE id = $1", id,
139
+		).Scan(&name, &epoch)
140
+		return name, epoch, err
137141
 	}))
138142
 	csrf := middleware.CSRF(middleware.CSRFConfig{
139143
 		FailureHandler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -175,6 +179,7 @@ func authTemplatesFS() fs.FS {
175179
 	apprTpl := `{{ define "page" }}<h1>Appearance</h1>{{ with .Error }}<p class=error>{{.}}</p>{{ end }}{{ with .Success }}<p class=notice>{{.}}</p>{{ end }}<form action="/settings/appearance" method=POST><input name=csrf_token value="{{.CSRFToken}}">THEME={{.CurrentTheme}};</form>{{ end }}`
176180
 	emailsTpl := `{{ define "page" }}<h1>Emails</h1>{{ with .Error }}<p class=error>{{.}}</p>{{ end }}{{ with .Success }}<p class=notice>{{.}}</p>{{ end }}<form action="/settings/emails" method=POST><input name=csrf_token value="{{.CSRFToken}}"></form>EMAILS={{ range .Emails }}{{.ID}}:{{.Email}}:p={{.IsPrimary}}:v={{.Verified}};{{ end }}{{ end }}`
177181
 	notifTpl := `{{ define "page" }}<h1>Notifications</h1>{{ with .Success }}<p class=notice>{{.}}</p>{{ end }}<form action="/settings/notifications" method=POST><input name=csrf_token value="{{.CSRFToken}}">CHANNELS={{ range .Channels }}{{.Key}}:e={{.Enabled}}:r={{.Required}};{{ end }}</form>{{ end }}`
182
+	sessTpl := `{{ define "page" }}<h1>Sessions</h1>{{ with .Success }}<p class=notice>{{.}}</p>{{ end }}<form action="/settings/sessions/logout-everywhere" method=POST><input name=csrf_token value="{{.CSRFToken}}">UA={{.UserAgent}};</form>{{ end }}`
178183
 	errorPage := `{{ define "page" }}<h1>{{.Status}} {{.StatusText}}</h1><p>{{.Message}}</p>{{ end }}`
179184
 	return fstest.MapFS{
180185
 		"_layout.html":                {Data: []byte(layout)},
@@ -196,6 +201,7 @@ func authTemplatesFS() fs.FS {
196201
 		"settings/appearance.html":    {Data: []byte(apprTpl)},
197202
 		"settings/emails.html":        {Data: []byte(emailsTpl)},
198203
 		"settings/notifications.html": {Data: []byte(notifTpl)},
204
+		"settings/sessions.html":      {Data: []byte(sessTpl)},
199205
 		"errors/404.html":             {Data: []byte(errorPage)},
200206
 		"errors/403.html":             {Data: []byte(errorPage)},
201207
 		"errors/429.html":             {Data: []byte(errorPage)},
internal/web/handlers/auth/password_change.gomodified
@@ -106,6 +106,17 @@ func (h *Handlers) settingsPasswordSubmit(w http.ResponseWriter, r *http.Request
106106
 		h.d.Logger.WarnContext(r.Context(), "password: audit", "error", err)
107107
 	}
108108
 
109
+	// Sync the *current* session's epoch with the post-bump value so the
110
+	// user staying on this browser doesn't get signed out by their own
111
+	// password change.
112
+	if epoch, err := h.q.GetUserSessionEpoch(r.Context(), h.d.Pool, user.ID); err == nil {
113
+		s := middleware.SessionFromContext(r.Context())
114
+		s.Epoch = epoch
115
+		if err := h.d.SessionStore.Save(w, r, s); err != nil {
116
+			h.d.Logger.WarnContext(r.Context(), "password: re-issue session", "error", err)
117
+		}
118
+	}
119
+
109120
 	h.renderPasswordForm(w, r, "", "Password updated. Other sessions have been signed out.")
110121
 }
111122
 
internal/web/handlers/auth/tokens_test.gomodified
@@ -72,15 +72,18 @@ func newTokenServer(t *testing.T) (srv *httptest.Server, cli *client, captor *ca
7272
 	r.Use(middleware.RequestID)
7373
 	r.Use(middleware.RealIP(middleware.RealIPConfig{}))
7474
 	r.Use(middleware.SessionLoader(store, logger))
75
-	r.Use(middleware.OptionalUser(func(ctx context.Context, id int64) (string, error) {
75
+	r.Use(middleware.OptionalUser(func(ctx context.Context, id int64) (string, int32, error) {
7676
 		c, err := pool.Acquire(ctx)
7777
 		if err != nil {
78
-			return "", err
78
+			return "", 0, err
7979
 		}
8080
 		defer c.Release()
8181
 		var name string
82
-		err = c.QueryRow(ctx, "SELECT username FROM users WHERE id = $1", id).Scan(&name)
83
-		return name, err
82
+		var epoch int32
83
+		err = c.QueryRow(ctx,
84
+			"SELECT username, session_epoch FROM users WHERE id = $1", id,
85
+		).Scan(&name, &epoch)
86
+		return name, epoch, err
8487
 	}))
8588
 	csrf := middleware.CSRF(middleware.CSRFConfig{
8689
 		FailureHandler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
internal/web/handlers/auth/twofactor.gomodified
@@ -119,8 +119,16 @@ func (h *Handlers) twoFactorChallengeSubmit(w http.ResponseWriter, r *http.Reque
119119
 	// Upgrade session: drop pre-2FA marker, set UserID, reissue.
120120
 	// Recent2FAAt timestamps the just-completed challenge so the recent-
121121
 	// auth gate (PAT creation, etc.) can verify a fresh second factor.
122
+	// Epoch snapshot powers "log out everywhere".
123
+	epoch, err := h.q.GetUserSessionEpoch(r.Context(), h.d.Pool, userID)
124
+	if err != nil {
125
+		h.d.Logger.ErrorContext(r.Context(), "2fa: load epoch", "error", err)
126
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
127
+		return
128
+	}
122129
 	s.Pre2FAUserID = 0
123130
 	s.UserID = userID
131
+	s.Epoch = epoch
124132
 	s.Recent2FAAt = time.Now().Unix()
125133
 	s.IssuedAt = time.Now().Unix()
126134
 	if err := h.d.SessionStore.Save(w, r, s); err != nil {
internal/web/middleware/auth.gomodified
@@ -20,26 +20,44 @@ type CurrentUser struct {
2020
 // IsAnonymous reports whether this is an unauthenticated request.
2121
 func (u CurrentUser) IsAnonymous() bool { return u.ID == 0 }
2222
 
23
+// UserLookup resolves a user_id into the data the auth middleware needs.
24
+// epoch is the users.session_epoch column; the middleware compares it to
25
+// the session's recorded epoch on every request so "log out everywhere"
26
+// (which bumps the column) invalidates stale cookies on the next hit.
27
+type UserLookup func(ctx context.Context, userID int64) (username string, epoch int32, err error)
28
+
2329
 // OptionalUser populates CurrentUser into context from the loaded session
2430
 // when present. Does not redirect or reject — pages that don't need a
2531
 // user (homepage, public repo views) just ignore an empty CurrentUser.
2632
 //
27
-// Lookup is the function that resolves a user_id to a username. Pass nil
28
-// to skip the lookup (CurrentUser.Username will be empty); handlers that
29
-// need the username supply Lookup.
30
-func OptionalUser(lookup func(ctx context.Context, userID int64) (string, error)) func(http.Handler) http.Handler {
33
+// When lookup returns successfully and the user's current session_epoch
34
+// does NOT match the session's recorded epoch, the binding is skipped so
35
+// the request looks anonymous. The stale cookie itself isn't actively
36
+// cleared — RequireUser will redirect the next protected hit to /login,
37
+// at which point a fresh session is minted with the current epoch.
38
+//
39
+// Pass nil to skip the lookup entirely (CurrentUser.Username will be
40
+// empty and epoch checks are bypassed).
41
+func OptionalUser(lookup UserLookup) func(http.Handler) http.Handler {
3142
 	return func(next http.Handler) http.Handler {
3243
 		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
3344
 			ctx := r.Context()
3445
 			s := SessionFromContext(ctx)
3546
 			if s.UserID != 0 {
3647
 				u := CurrentUser{ID: s.UserID}
48
+				bind := true
3749
 				if lookup != nil {
38
-					if name, err := lookup(ctx, s.UserID); err == nil {
39
-						u.Username = name
50
+					if name, epoch, err := lookup(ctx, s.UserID); err == nil {
51
+						if epoch != s.Epoch {
52
+							bind = false
53
+						} else {
54
+							u.Username = name
55
+						}
4056
 					}
4157
 				}
42
-				ctx = context.WithValue(ctx, currentUserKey, u)
58
+				if bind {
59
+					ctx = context.WithValue(ctx, currentUserKey, u)
60
+				}
4361
 			}
4462
 			next.ServeHTTP(w, r.WithContext(ctx))
4563
 		})