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 |
| 80 | 80 | return err |
| 81 | 81 | } |
| 82 | 82 | |
| 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. |
| 86 | 88 | func (s *Store) WebSessionUser(hash string) (User, error) { |
| 87 | 89 | now := time.Now() |
| 88 | 90 | var userID int64 |
| 91 | var created string |
| 89 | 92 | 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) |
| 92 | 95 | if errors.Is(err, sql.ErrNoRows) { |
| 93 | 96 | return User{}, ErrNotFound |
| 94 | 97 | } |
| @@ -98,7 +101,14 @@ func (s *Store) WebSessionUser(hash string) (User, error) { |
| 98 | 101 | s.DB.Exec(`UPDATE web_sessions SET last_used_at = ?, expires_at = min(absolute_expires_at, ?) |
| 99 | 102 | WHERE token_hash = ? AND last_used_at < ?`, |
| 100 | 103 | 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 |
| 102 | 112 | } |
| 103 | 113 | |
| 104 | 114 | func (s *Store) DeleteWebSession(hash string) error { |
internal/store/sessions_test.go
+40
| @@ -117,3 +117,43 @@ func TestWebSessionRenewsUpToTheCap(t *testing.T) { |
| 117 | 117 | t.Fatalf("list: %+v %v", list, err) |
| 118 | 118 | } |
| 119 | 119 | } |
| 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). |
| 123 | func 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 { |
| 14 | 14 | IsAdmin bool |
| 15 | 15 | Pending bool // self-registered, email not yet verified |
| 16 | 16 | 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 |
| 17 | 20 | } |
| 18 | 21 | |
| 19 | 22 | type SSHKey struct { |