Commit 887952c35a

887952c35a8b8c86767bdb84a8bf8473ec868e4c

parent: 592e7bcb92

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-29 00:08 UTC

store: a web session's user carries its sign-in time

Ref #297

Layout: unified · split

internal/store/sessions.go +16 −6
@@ -80,15 +80,18 @@ func (s *Store) CreateWebSession(hash string, userID int64, ttl time.Duration) e
8080 return err
8181}
8282
83// WebSessionUser resolves a session cookie hash to its user and renews
84// the session's idle expiry. A session is written at most once a
85// minute, so a burst of requests costs one UPDATE.
83// WebSessionUser resolves a session cookie hash to its user, with the
84// session's sign-in time, and renews the session's idle expiry. A
85// session is written at most once a minute, so a burst of requests
86// costs one UPDATE. Renewal never moves created_at: only a login
87// creates a session, so created_at is when it signed in.
8688func (s *Store) WebSessionUser(hash string) (User, error) {
8789 now := time.Now()
8890 var userID int64
91 var created string
8992 err := s.DB.QueryRow(
90 "SELECT user_id FROM web_sessions WHERE token_hash = ? AND expires_at > ?",
91 hash, fmtTime(now)).Scan(&userID)
93 "SELECT user_id, created_at FROM web_sessions WHERE token_hash = ? AND expires_at > ?",
94 hash, fmtTime(now)).Scan(&userID, &created)
9295 if errors.Is(err, sql.ErrNoRows) {
9396 return User{}, ErrNotFound
9497 }
@@ -98,7 +101,14 @@ func (s *Store) WebSessionUser(hash string) (User, error) {
98101 s.DB.Exec(`UPDATE web_sessions SET last_used_at = ?, expires_at = min(absolute_expires_at, ?)
99102 WHERE token_hash = ? AND last_used_at < ?`,
100103 fmtTime(now), fmtTime(now.Add(WebSessionIdle)), hash, fmtTime(now.Add(-time.Minute)))
101 return s.UserByID(userID)
104 u, err := s.UserByID(userID)
105 if err != nil {
106 return User{}, err
107 }
108 if t := parseTime(sql.NullString{String: created, Valid: true}); t != nil {
109 u.SignedInAt = *t
110 }
111 return u, nil
102112}
103113
104114func (s *Store) DeleteWebSession(hash string) error {
internal/store/sessions_test.go +40
@@ -117,3 +117,43 @@ func TestWebSessionRenewsUpToTheCap(t *testing.T) {
117117 t.Fatalf("list: %+v %v", list, err)
118118 }
119119}
120
121// A session's sign-in time is its creation; using the session renews
122// its idle expiry and leaves the sign-in time alone (#297).
123func TestWebSessionUserSignedInAt(t *testing.T) {
124 s, uid := sessionFixture(t)
125 _, hash, err := NewToken()
126 if err != nil {
127 t.Fatal(err)
128 }
129 if err := s.CreateWebSession(hash, uid, 7*24*time.Hour); err != nil {
130 t.Fatal(err)
131 }
132 u, err := s.WebSessionUser(hash)
133 if err != nil {
134 t.Fatal(err)
135 }
136 if age := time.Since(u.SignedInAt); age < 0 || age > time.Minute {
137 t.Fatalf("fresh session signed in %v ago", age)
138 }
139
140 signedIn := time.Now().Add(-2 * time.Hour)
141 if _, err := s.DB.Exec("UPDATE web_sessions SET created_at = ?, last_used_at = ? WHERE token_hash = ?",
142 fmtTime(signedIn), fmtTime(signedIn), hash); err != nil {
143 t.Fatal(err)
144 }
145 u, err = s.WebSessionUser(hash)
146 if err != nil {
147 t.Fatal(err)
148 }
149 var last string
150 if err := s.DB.QueryRow("SELECT last_used_at FROM web_sessions WHERE token_hash = ?", hash).Scan(&last); err != nil {
151 t.Fatal(err)
152 }
153 if last == fmtTime(signedIn) {
154 t.Fatal("using the session did not renew it")
155 }
156 if want := signedIn.UTC().Truncate(time.Millisecond); !u.SignedInAt.Equal(want) {
157 t.Fatalf("SignedInAt = %v, want %v", u.SignedInAt, want)
158 }
159}
internal/store/users.go +3
@@ -14,6 +14,9 @@ type User struct {
1414 IsAdmin bool
1515 Pending bool // self-registered, email not yet verified
1616 Disabled bool // administratively suspended
17 // SignedInAt is when the browser session this user came from was
18 // created by a login. Set by WebSessionUser only; zero elsewhere.
19 SignedInAt time.Time
1720}
1821
1922type SSHKey struct {