Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/vole.git

Write over SSH with a registered key:

git clone ssh://git@rodent.software:2222/vole.git

A free and open-source forge that lives in your Git repo.

Blame · forge/session/session.go

Line attribution on main · 473 line(s)

6c84d387
Keenan Nemetz
16d ago
1 // Package session stores browser login sessions in the instance database.
2 //
3 // A session is an opaque random token held in a cookie. The database stores only
4 // the token's hash, so a copy of users.db cannot be replayed as a login. Sessions
62576fa8
Keenan Nemetz
14d ago
5 // carry the account name and the passkey that created them: revoking that passkey
6c84d387
Keenan Nemetz
16d ago
6 // deletes exactly the sessions it authorized, and retiring the account deletes
7 // all of them.
8 //
9 // The table lives beside the roster in users.db. That is what makes the
10 // lifecycle rules enforceable with foreign keys and one transaction instead of
11 // a cleanup routine that can drift. The trade-off is write traffic, so last_seen
12 // is updated coarsely rather than on every request.
13 package session
14
15 import (
16 "crypto/hmac"
17 "crypto/rand"
18 "crypto/sha256"
19 "database/sql"
20 "encoding/base64"
21 "encoding/hex"
22 "errors"
23 "fmt"
24 "os"
25 "time"
edf9db6e
Keenan Nemetz
13d ago
26 "unicode/utf8"
6c84d387
Keenan Nemetz
16d ago
27
95425af7
Keenan Nemetz
10d ago
28 _ "github.com/mattn/go-sqlite3"
b1499c72
Keenan Nemetz
13d ago
29
d37d301f
Keenan Nemetz
19h ago
30 "rodent.software/vole/internal/audit"
6c84d387
Keenan Nemetz
16d ago
31 )
32
33 const (
34 // DefaultIdle ends a session that has not been used for this long.
35 DefaultIdle = 12 * time.Hour
36 // DefaultAbsolute ends a session regardless of activity.
37 DefaultAbsolute = 30 * 24 * time.Hour
38 // LastSeenEvery is how often a request may write last_seen. Updating on every
39 // request would turn a read-mostly roster database into a write-hot one.
40 LastSeenEvery = 5 * time.Minute
41
42 tokenBytes = 32
edf9db6e
Keenan Nemetz
13d ago
43 // maxTokenBytes bounds a submitted cookie value. A token is exactly 43 base64
44 // characters; the margin is for a future longer token, not for a client, and
45 // anything past it is refused before it is hashed or looked up.
46 maxTokenBytes = 64
47 // maxUserAgentBytes bounds the stored User-Agent. It is client-supplied and
48 // shown to an operator on the account page, so it is a display field with a
49 // length an attacker chooses unless it is cut.
50 maxUserAgentBytes = 256
6c84d387
Keenan Nemetz
16d ago
51 )
52
53 var (
54 // ErrNoSession is returned for missing, expired, retired and revoked
55 // sessions alike. The web treats them all as "log in again".
56 ErrNoSession = errors.New("session: no valid session")
57 // ErrNoAccount is returned when a session is created for an account that is
58 // missing or retired.
59 ErrNoAccount = errors.New("session: account is not active")
60 // ErrCredentialMismatch is returned when a session names a passkey that
61 // belongs to another account.
62 ErrCredentialMismatch = errors.New("session: passkey does not belong to the account")
63 )
64
65 type Session struct {
66 // Token is set only by Create. Lookup never returns the plaintext token,
67 // because the database does not have it.
68 Token string
69 // CSRF is derived from Token, so it needs no column of its own and changes
70 // whenever the session does. It is safe to put in a form; the cookie stays
71 // HttpOnly.
72 CSRF string
62576fa8
Keenan Nemetz
14d ago
73 Name string
6c84d387
Keenan Nemetz
16d ago
74 CredentialID []byte
75 Created time.Time
76 LastSeen time.Time
77 Expires time.Time
78 UserAgent string
79 }
80
81 type Store struct {
82 // Idle and Absolute are the expiry policy. Zero means the default.
83 Idle time.Duration
84 Absolute time.Duration
85 // Now is injectable so expiry tests do not sleep.
86 Now func() time.Time
b1499c72
Keenan Nemetz
13d ago
87 // AuditPath receives one line per session creation and revocation. A session
88 // is a credential, so a creation or revocation that cannot be recorded is
89 // refused rather than performed silently.
6c84d387
Keenan Nemetz
16d ago
90 AuditPath string
91
92 db *sql.DB
93 path string
94 }
95
96 // Open opens the session table in an existing users.db. The schema is created
97 // with the roster by identity.Init or identity.Open, so a missing database is a
98 // configuration error rather than something this package creates silently.
99 func Open(path string) (*Store, error) {
100 if _, err := os.Stat(path); err != nil {
101 return nil, fmt.Errorf("session: %s is missing; run vole admin init", path)
102 }
95425af7
Keenan Nemetz
10d ago
103 db, err := sql.Open("sqlite3", path+"?_busy_timeout=5000&_journal_mode=WAL&_foreign_keys=1&_txlock=immediate")
6c84d387
Keenan Nemetz
16d ago
104 if err != nil {
105 return nil, fmt.Errorf("session: %w", err)
106 }
107 db.SetMaxOpenConns(2)
108 // A clear failure here means an operator opened an old database without
109 // running the schema upgrade (identity.Open does that on every start).
110 var n int
111 if err := db.QueryRow(`SELECT count(*) FROM sessions`).Scan(&n); err != nil {
112 db.Close()
113 return nil, fmt.Errorf("session: %s has no session table; start the server or run vole admin init: %w", path, err)
114 }
115 return &Store{db: db, path: path, Idle: DefaultIdle, Absolute: DefaultAbsolute}, nil
116 }
117
118 func (s *Store) Close() error { return s.db.Close() }
119 func (s *Store) Path() string { return s.path }
120
121 // Create issues a fresh session token for an active account. credentialID may
122 // be nil for a session that was not created by a passkey (a future SSH-assisted
123 // login, for example); a non-nil id ties the session's life to that passkey.
62576fa8
Keenan Nemetz
14d ago
124 func (s *Store) Create(name string, credentialID []byte, userAgent string) (*Session, error) {
125 if name == "" {
6c84d387
Keenan Nemetz
16d ago
126 return nil, ErrNoAccount
127 }
128 if len(credentialID) > 0 {
129 var owner string
62576fa8
Keenan Nemetz
14d ago
130 err := s.db.QueryRow(`SELECT user_name FROM user_credentials WHERE credential_id=?`, credentialIDDB(credentialID)).Scan(&owner)
6c84d387
Keenan Nemetz
16d ago
131 if errors.Is(err, sql.ErrNoRows) {
132 return nil, ErrCredentialMismatch
133 }
134 if err != nil {
135 return nil, fmt.Errorf("session: read passkey: %w", err)
136 }
62576fa8
Keenan Nemetz
14d ago
137 if owner != name {
6c84d387
Keenan Nemetz
16d ago
138 return nil, ErrCredentialMismatch
139 }
140 }
141 token, err := newToken()
142 if err != nil {
143 return nil, err
144 }
edf9db6e
Keenan Nemetz
13d ago
145 userAgent = truncateBytes(userAgent, maxUserAgentBytes)
6c84d387
Keenan Nemetz
16d ago
146 now := s.now()
147 expires := now.Add(s.absolute())
148 sess := &Session{
149 Token: token,
150 CSRF: csrfToken(token),
62576fa8
Keenan Nemetz
14d ago
151 Name: name,
6c84d387
Keenan Nemetz
16d ago
152 CredentialID: credentialID,
153 Created: now,
154 LastSeen: now,
155 Expires: expires,
156 UserAgent: userAgent,
157 }
b1499c72
Keenan Nemetz
13d ago
158 // Recorded before the row exists: a session that cannot be recorded is not
159 // issued, because a login nobody can account for is the one thing an audit
160 // log exists to prevent.
161 if err := s.audit("session.create", name, credentialDetail(credentialID)); err != nil {
162 return nil, err
163 }
6c84d387
Keenan Nemetz
16d ago
164 // INSERT ... SELECT checks the account's status in the same statement that
165 // writes the row. A retire racing this create cannot leave a usable session
166 // behind, and Lookup checks status again on every request anyway.
62576fa8
Keenan Nemetz
14d ago
167 res, err := s.db.Exec(`INSERT INTO sessions(token_hash, user_name, credential_id, created, last_seen, expires, user_agent)
168 SELECT ?, name, ?, ?, ?, ?, ? FROM users WHERE name=? AND status='active'`,
6c84d387
Keenan Nemetz
16d ago
169 hashToken(token), nullableCredentialID(credentialID),
62576fa8
Keenan Nemetz
14d ago
170 now.Unix(), now.Unix(), expires.Unix(), userAgent, name)
6c84d387
Keenan Nemetz
16d ago
171 if err != nil {
172 return nil, fmt.Errorf("session: create: %w", err)
173 }
174 if n, err := res.RowsAffected(); err != nil {
175 return nil, err
176 } else if n == 0 {
177 return nil, ErrNoAccount
178 }
179 // Expired rows are cheap to remove and would otherwise accumulate forever on
180 // an instance nobody logs out of.
181 _, _ = s.DeleteExpired(now)
182 return sess, nil
183 }
184
185 // Lookup resolves a cookie token to an active session. It enforces absolute
186 // expiry, idle expiry and account retirement, and updates last_seen at most once
187 // per LastSeenEvery.
188 func (s *Store) Lookup(token string) (*Session, error) {
189 token = trimToken(token)
edf9db6e
Keenan Nemetz
13d ago
190 if !validToken(token) {
6c84d387
Keenan Nemetz
16d ago
191 return nil, ErrNoSession
192 }
193 now := s.now()
194 var (
195 sess Session
196 credentialID sql.NullString
197 status string
198 created int64
199 lastSeen int64
200 expires int64
201 )
202 err := s.db.QueryRow(`
62576fa8
Keenan Nemetz
14d ago
203 SELECT s.user_name, s.credential_id, s.created, s.last_seen, s.expires, s.user_agent, u.status
204 FROM sessions s JOIN users u ON u.name = s.user_name
6c84d387
Keenan Nemetz
16d ago
205 WHERE s.token_hash = ?`, hashToken(token)).
62576fa8
Keenan Nemetz
14d ago
206 Scan(&sess.Name, &credentialID, &created, &lastSeen, &expires, &sess.UserAgent, &status)
6c84d387
Keenan Nemetz
16d ago
207 if errors.Is(err, sql.ErrNoRows) {
208 return nil, ErrNoSession
209 }
210 if err != nil {
211 return nil, fmt.Errorf("session: lookup: %w", err)
212 }
213 sess.CSRF = csrfToken(token)
214 sess.Created = time.Unix(created, 0).UTC()
215 sess.LastSeen = time.Unix(lastSeen, 0).UTC()
216 sess.Expires = time.Unix(expires, 0).UTC()
217 if credentialID.Valid {
218 raw, err := base64.RawURLEncoding.DecodeString(credentialID.String)
219 if err != nil {
220 return nil, fmt.Errorf("session: stored credential id: %w", err)
221 }
222 sess.CredentialID = raw
223 }
224 if status != "active" || !now.Before(sess.Expires) || now.Sub(sess.LastSeen) > s.idle() {
225 _, _ = s.db.Exec(`DELETE FROM sessions WHERE token_hash=?`, hashToken(token))
226 return nil, ErrNoSession
227 }
228 if now.Sub(sess.LastSeen) >= LastSeenEvery {
229 if _, err := s.db.Exec(`UPDATE sessions SET last_seen=? WHERE token_hash=?`, now.Unix(), hashToken(token)); err == nil {
230 sess.LastSeen = now
231 }
232 }
233 return &sess, nil
234 }
235
236 // Revoke deletes one session. It is idempotent: logging out twice is not an
237 // error, and an unknown token reveals nothing.
238 func (s *Store) Revoke(token string) error {
239 token = trimToken(token)
edf9db6e
Keenan Nemetz
13d ago
240 if !validToken(token) {
6c84d387
Keenan Nemetz
16d ago
241 return nil
242 }
62576fa8
Keenan Nemetz
14d ago
243 var name string
244 err := s.db.QueryRow(`SELECT user_name FROM sessions WHERE token_hash=?`, hashToken(token)).Scan(&name)
6c84d387
Keenan Nemetz
16d ago
245 if errors.Is(err, sql.ErrNoRows) {
246 return nil
247 }
248 if err != nil {
249 return fmt.Errorf("session: revoke: %w", err)
250 }
b1499c72
Keenan Nemetz
13d ago
251 if err := s.audit("session.revoke", name, ""); err != nil {
252 return err
253 }
6c84d387
Keenan Nemetz
16d ago
254 if _, err := s.db.Exec(`DELETE FROM sessions WHERE token_hash=?`, hashToken(token)); err != nil {
255 return fmt.Errorf("session: revoke: %w", err)
256 }
257 return nil
258 }
259
260 // RevokeAccount deletes every session for an account, for the operator command
261 // and as a backstop after restoring an old database.
62576fa8
Keenan Nemetz
14d ago
262 func (s *Store) RevokeAccount(name string) (int64, error) {
b1499c72
Keenan Nemetz
13d ago
263 var n int64
264 _ = s.db.QueryRow(`SELECT COUNT(*) FROM sessions WHERE user_name=?`, name).Scan(&n)
265 if n > 0 {
266 if err := s.audit("session.revoke-account", name, fmt.Sprintf("%d session(s)", n)); err != nil {
267 return 0, err
268 }
269 }
62576fa8
Keenan Nemetz
14d ago
270 res, err := s.db.Exec(`DELETE FROM sessions WHERE user_name=?`, name)
6c84d387
Keenan Nemetz
16d ago
271 if err != nil {
272 return 0, fmt.Errorf("session: revoke account: %w", err)
273 }
b1499c72
Keenan Nemetz
13d ago
274 gone, _ := res.RowsAffected()
275 return gone, nil
6c84d387
Keenan Nemetz
16d ago
276 }
277
278 // RevokeAll deletes every session on the instance. It is the recovery step after
279 // restoring an old users.db, where the restored rows would otherwise still name
280 // sessions whose cookies may exist.
281 func (s *Store) RevokeAll() (int64, error) {
b1499c72
Keenan Nemetz
13d ago
282 var n int64
283 _ = s.db.QueryRow(`SELECT COUNT(*) FROM sessions`).Scan(&n)
284 if n > 0 {
285 if err := s.audit("session.revoke-all", "", fmt.Sprintf("%d session(s)", n)); err != nil {
286 return 0, err
287 }
288 }
6c84d387
Keenan Nemetz
16d ago
289 res, err := s.db.Exec(`DELETE FROM sessions`)
290 if err != nil {
291 return 0, fmt.Errorf("session: revoke all: %w", err)
292 }
b1499c72
Keenan Nemetz
13d ago
293 gone, _ := res.RowsAffected()
294 return gone, nil
6c84d387
Keenan Nemetz
16d ago
295 }
296
297 // RevokeCredential deletes the sessions created by one passkey. Removing the
298 // passkey itself cascades, but an explicit revoke is useful before rotating one.
299 func (s *Store) RevokeCredential(credentialID []byte) (int64, error) {
300 if len(credentialID) == 0 {
301 return 0, nil
302 }
62576fa8
Keenan Nemetz
14d ago
303 var name string
304 _ = s.db.QueryRow(`SELECT user_name FROM user_credentials WHERE credential_id=?`, credentialIDDB(credentialID)).Scan(&name)
b1499c72
Keenan Nemetz
13d ago
305 var n int64
306 _ = s.db.QueryRow(`SELECT COUNT(*) FROM sessions WHERE credential_id=?`, credentialIDDB(credentialID)).Scan(&n)
307 if n > 0 {
308 if err := s.audit("session.revoke-credential", name, credentialDetail(credentialID)); err != nil {
309 return 0, err
310 }
311 }
6c84d387
Keenan Nemetz
16d ago
312 res, err := s.db.Exec(`DELETE FROM sessions WHERE credential_id=?`, credentialIDDB(credentialID))
313 if err != nil {
314 return 0, fmt.Errorf("session: revoke passkey sessions: %w", err)
315 }
b1499c72
Keenan Nemetz
13d ago
316 gone, _ := res.RowsAffected()
317 return gone, nil
6c84d387
Keenan Nemetz
16d ago
318 }
319
320 // List returns an account's sessions, newest first. Expired rows are removed by
321 // Create and Lookup; this is an operator view, not an authorization check.
62576fa8
Keenan Nemetz
14d ago
322 func (s *Store) List(name string) ([]Session, error) {
6c84d387
Keenan Nemetz
16d ago
323 rows, err := s.db.Query(`
62576fa8
Keenan Nemetz
14d ago
324 SELECT user_name, credential_id, created, last_seen, expires, user_agent
325 FROM sessions WHERE user_name=? ORDER BY created DESC`, name)
6c84d387
Keenan Nemetz
16d ago
326 if err != nil {
327 return nil, fmt.Errorf("session: list: %w", err)
328 }
329 defer rows.Close()
330 var out []Session
331 for rows.Next() {
332 var (
333 sess Session
334 credentialID sql.NullString
335 created int64
336 lastSeen int64
337 expires int64
338 )
62576fa8
Keenan Nemetz
14d ago
339 if err := rows.Scan(&sess.Name, &credentialID, &created, &lastSeen, &expires, &sess.UserAgent); err != nil {
6c84d387
Keenan Nemetz
16d ago
340 return nil, fmt.Errorf("session: list: %w", err)
341 }
342 sess.Created = time.Unix(created, 0).UTC()
343 sess.LastSeen = time.Unix(lastSeen, 0).UTC()
344 sess.Expires = time.Unix(expires, 0).UTC()
345 if credentialID.Valid {
346 raw, err := base64.RawURLEncoding.DecodeString(credentialID.String)
347 if err == nil {
348 sess.CredentialID = raw
349 }
350 }
351 out = append(out, sess)
352 }
353 if err := rows.Err(); err != nil {
354 return nil, err
355 }
356 return out, nil
357 }
358
359 // DeleteExpired removes rows whose absolute lifetime has run out.
360 func (s *Store) DeleteExpired(now time.Time) (int64, error) {
361 res, err := s.db.Exec(`DELETE FROM sessions WHERE expires <= ?`, now.Unix())
362 if err != nil {
363 return 0, fmt.Errorf("session: delete expired: %w", err)
364 }
365 n, _ := res.RowsAffected()
366 return n, nil
367 }
368
369 func (s *Store) now() time.Time {
370 if s.Now != nil {
371 return s.Now().UTC()
372 }
373 return time.Now().UTC()
374 }
375
376 func (s *Store) idle() time.Duration {
377 if s.Idle <= 0 {
378 return DefaultIdle
379 }
380 return s.Idle
381 }
382
383 func (s *Store) absolute() time.Duration {
384 if s.Absolute <= 0 {
385 return DefaultAbsolute
386 }
387 return s.Absolute
388 }
389
390 func newToken() (string, error) {
391 b := make([]byte, tokenBytes)
392 if _, err := rand.Read(b); err != nil {
393 return "", fmt.Errorf("session: token: %w", err)
394 }
395 return base64.RawURLEncoding.EncodeToString(b), nil
396 }
397
398 func hashToken(token string) string {
399 sum := sha256.Sum256([]byte(token))
400 return hex.EncodeToString(sum[:])
401 }
402
403 // csrfToken is a one-way function of the session token. It cannot be used to
404 // recover the cookie value, and it changes when the session is replaced.
405 func csrfToken(token string) string {
406 mac := hmac.New(sha256.New, []byte(token))
407 _, _ = mac.Write([]byte("vole-csrf"))
408 return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
409 }
410
411 func nullableCredentialID(id []byte) any {
412 if len(id) == 0 {
413 return nil
414 }
415 return credentialIDDB(id)
416 }
417
418 func credentialIDDB(id []byte) string { return base64.RawURLEncoding.EncodeToString(id) }
419
420 func credentialDetail(id []byte) string {
421 if len(id) == 0 {
422 return ""
423 }
424 return credentialIDDB(id)
425 }
426
edf9db6e
Keenan Nemetz
13d ago
427 // validToken reports whether a cookie value could be a session token at all. The
428 // length check is the point: a value that is not the shape this store issues is
429 // rejected before it is hashed or queried, so an oversized cookie cannot make the
430 // server work for it.
431 func validToken(token string) bool {
432 if token == "" || len(token) > maxTokenBytes {
433 return false
434 }
435 for i := 0; i < len(token); i++ {
436 c := token[i]
437 if (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') ||
438 c == '-' || c == '_' {
439 continue
440 }
441 return false
442 }
443 return true
444 }
445
446 // truncateBytes cuts s to at most n bytes without splitting a rune.
447 func truncateBytes(s string, n int) string {
448 if len(s) <= n {
449 return s
450 }
451 cut := n
452 for cut > 0 && !utf8.RuneStart(s[cut]) {
453 cut--
454 }
455 return s[:cut]
456 }
457
6c84d387
Keenan Nemetz
16d ago
458 func trimToken(s string) string {
459 // Cookie values are already opaque; trimming guards against a stray newline
460 // in a hand-written test or CLI call, not against a valid token.
461 for len(s) > 0 && (s[len(s)-1] == '\n' || s[len(s)-1] == '\r' || s[len(s)-1] == ' ') {
462 s = s[:len(s)-1]
463 }
464 return s
465 }
466
b1499c72
Keenan Nemetz
13d ago
467 func (s *Store) audit(action, account, detail string) error {
468 return audit.Append(s.AuditPath, audit.Record{
469 Action: action,
470 Actor: account,
471 Detail: detail,
472 })
6c84d387
Keenan Nemetz
16d ago
473 }