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.

Add browser auth, contributor access, and gated CI

6c84d387e5f608360e30484a1a5b1cff41076850 · Keenan Nemetz · 16d ago · parent c36d5073

Add browser auth, contributor access, and gated CI

Browser sign-in now uses passkeys. Enrollment links are one-time, sessions are hashed in users.db, and issue/comment/approval writes use the session account. HTTPS public_url is required for WebAuthn.

Add the contributor role between read and maintain. Contributors can discuss and open change requests, and may push only refs/heads/contrib/<account-id>/. Contributor CI is held per commit until a maintainer allows the exact tip from the repository page.

Rename the write role to maintain. write remains accepted as the historical spelling, and access.Write remains as a Go alias so existing callers and ACL rows keep working.

Update the web, CLI, queue schema, and guides for the new flows, and cover the role, namespace, auth, and CI-gating paths in tests.

forge/session/session.go +416

@@ -0,0 +1,416 @@
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
5 // carry the account id and the passkey that created them: revoking that passkey
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"
26
27 _ "modernc.org/sqlite"
28 )
29
30 const (
31 // DefaultIdle ends a session that has not been used for this long.
32 DefaultIdle = 12 * time.Hour
33 // DefaultAbsolute ends a session regardless of activity.
34 DefaultAbsolute = 30 * 24 * time.Hour
35 // LastSeenEvery is how often a request may write last_seen. Updating on every
36 // request would turn a read-mostly roster database into a write-hot one.
37 LastSeenEvery = 5 * time.Minute
38
39 tokenBytes = 32
40 )
41
42 var (
43 // ErrNoSession is returned for missing, expired, retired and revoked
44 // sessions alike. The web treats them all as "log in again".
45 ErrNoSession = errors.New("session: no valid session")
46 // ErrNoAccount is returned when a session is created for an account that is
47 // missing or retired.
48 ErrNoAccount = errors.New("session: account is not active")
49 // ErrCredentialMismatch is returned when a session names a passkey that
50 // belongs to another account.
51 ErrCredentialMismatch = errors.New("session: passkey does not belong to the account")
52 )
53
54 type Session struct {
55 // Token is set only by Create. Lookup never returns the plaintext token,
56 // because the database does not have it.
57 Token string
58 // CSRF is derived from Token, so it needs no column of its own and changes
59 // whenever the session does. It is safe to put in a form; the cookie stays
60 // HttpOnly.
61 CSRF string
62 AccountID string
63 CredentialID []byte
64 Created time.Time
65 LastSeen time.Time
66 Expires time.Time
67 UserAgent string
68 }
69
70 type Store struct {
71 // Idle and Absolute are the expiry policy. Zero means the default.
72 Idle time.Duration
73 Absolute time.Duration
74 // Now is injectable so expiry tests do not sleep.
75 Now func() time.Time
76 // AuditPath receives one line per session creation and revocation.
77 AuditPath string
78
79 db *sql.DB
80 path string
81 }
82
83 // Open opens the session table in an existing users.db. The schema is created
84 // with the roster by identity.Init or identity.Open, so a missing database is a
85 // configuration error rather than something this package creates silently.
86 func Open(path string) (*Store, error) {
87 if _, err := os.Stat(path); err != nil {
88 return nil, fmt.Errorf("session: %s is missing; run vole admin init", path)
89 }
90 db, err := sql.Open("sqlite", path+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)&_txlock=immediate")
91 if err != nil {
92 return nil, fmt.Errorf("session: %w", err)
93 }
94 db.SetMaxOpenConns(2)
95 // A clear failure here means an operator opened an old database without
96 // running the schema upgrade (identity.Open does that on every start).
97 var n int
98 if err := db.QueryRow(`SELECT count(*) FROM sessions`).Scan(&n); err != nil {
99 db.Close()
100 return nil, fmt.Errorf("session: %s has no session table; start the server or run vole admin init: %w", path, err)
101 }
102 return &Store{db: db, path: path, Idle: DefaultIdle, Absolute: DefaultAbsolute}, nil
103 }
104
105 func (s *Store) Close() error { return s.db.Close() }
106 func (s *Store) Path() string { return s.path }
107
108 // Create issues a fresh session token for an active account. credentialID may
109 // be nil for a session that was not created by a passkey (a future SSH-assisted
110 // login, for example); a non-nil id ties the session's life to that passkey.
111 func (s *Store) Create(accountID string, credentialID []byte, userAgent string) (*Session, error) {
112 if accountID == "" {
113 return nil, ErrNoAccount
114 }
115 if len(credentialID) > 0 {
116 var owner string
117 err := s.db.QueryRow(`SELECT user_id FROM user_credentials WHERE credential_id=?`, credentialIDDB(credentialID)).Scan(&owner)
118 if errors.Is(err, sql.ErrNoRows) {
119 return nil, ErrCredentialMismatch
120 }
121 if err != nil {
122 return nil, fmt.Errorf("session: read passkey: %w", err)
123 }
124 if owner != accountID {
125 return nil, ErrCredentialMismatch
126 }
127 }
128 token, err := newToken()
129 if err != nil {
130 return nil, err
131 }
132 now := s.now()
133 expires := now.Add(s.absolute())
134 sess := &Session{
135 Token: token,
136 CSRF: csrfToken(token),
137 AccountID: accountID,
138 CredentialID: credentialID,
139 Created: now,
140 LastSeen: now,
141 Expires: expires,
142 UserAgent: userAgent,
143 }
144 // INSERT ... SELECT checks the account's status in the same statement that
145 // writes the row. A retire racing this create cannot leave a usable session
146 // behind, and Lookup checks status again on every request anyway.
147 res, err := s.db.Exec(`INSERT INTO sessions(token_hash, account_id, credential_id, created, last_seen, expires, user_agent)
148 SELECT ?, id, ?, ?, ?, ?, ? FROM users WHERE id=? AND status='active'`,
149 hashToken(token), nullableCredentialID(credentialID),
150 now.Unix(), now.Unix(), expires.Unix(), userAgent, accountID)
151 if err != nil {
152 return nil, fmt.Errorf("session: create: %w", err)
153 }
154 if n, err := res.RowsAffected(); err != nil {
155 return nil, err
156 } else if n == 0 {
157 return nil, ErrNoAccount
158 }
159 // Expired rows are cheap to remove and would otherwise accumulate forever on
160 // an instance nobody logs out of.
161 _, _ = s.DeleteExpired(now)
162 s.audit("session.create", accountID, credentialDetail(credentialID))
163 return sess, nil
164 }
165
166 // Lookup resolves a cookie token to an active session. It enforces absolute
167 // expiry, idle expiry and account retirement, and updates last_seen at most once
168 // per LastSeenEvery.
169 func (s *Store) Lookup(token string) (*Session, error) {
170 token = trimToken(token)
171 if token == "" {
172 return nil, ErrNoSession
173 }
174 now := s.now()
175 var (
176 sess Session
177 credentialID sql.NullString
178 status string
179 created int64
180 lastSeen int64
181 expires int64
182 )
183 err := s.db.QueryRow(`
184 SELECT s.account_id, s.credential_id, s.created, s.last_seen, s.expires, s.user_agent, u.status
185 FROM sessions s JOIN users u ON u.id = s.account_id
186 WHERE s.token_hash = ?`, hashToken(token)).
187 Scan(&sess.AccountID, &credentialID, &created, &lastSeen, &expires, &sess.UserAgent, &status)
188 if errors.Is(err, sql.ErrNoRows) {
189 return nil, ErrNoSession
190 }
191 if err != nil {
192 return nil, fmt.Errorf("session: lookup: %w", err)
193 }
194 sess.CSRF = csrfToken(token)
195 sess.Created = time.Unix(created, 0).UTC()
196 sess.LastSeen = time.Unix(lastSeen, 0).UTC()
197 sess.Expires = time.Unix(expires, 0).UTC()
198 if credentialID.Valid {
199 raw, err := base64.RawURLEncoding.DecodeString(credentialID.String)
200 if err != nil {
201 return nil, fmt.Errorf("session: stored credential id: %w", err)
202 }
203 sess.CredentialID = raw
204 }
205 if status != "active" || !now.Before(sess.Expires) || now.Sub(sess.LastSeen) > s.idle() {
206 _, _ = s.db.Exec(`DELETE FROM sessions WHERE token_hash=?`, hashToken(token))
207 return nil, ErrNoSession
208 }
209 if now.Sub(sess.LastSeen) >= LastSeenEvery {
210 if _, err := s.db.Exec(`UPDATE sessions SET last_seen=? WHERE token_hash=?`, now.Unix(), hashToken(token)); err == nil {
211 sess.LastSeen = now
212 }
213 }
214 return &sess, nil
215 }
216
217 // Revoke deletes one session. It is idempotent: logging out twice is not an
218 // error, and an unknown token reveals nothing.
219 func (s *Store) Revoke(token string) error {
220 token = trimToken(token)
221 if token == "" {
222 return nil
223 }
224 var accountID string
225 err := s.db.QueryRow(`SELECT account_id FROM sessions WHERE token_hash=?`, hashToken(token)).Scan(&accountID)
226 if errors.Is(err, sql.ErrNoRows) {
227 return nil
228 }
229 if err != nil {
230 return fmt.Errorf("session: revoke: %w", err)
231 }
232 if _, err := s.db.Exec(`DELETE FROM sessions WHERE token_hash=?`, hashToken(token)); err != nil {
233 return fmt.Errorf("session: revoke: %w", err)
234 }
235 s.audit("session.revoke", accountID, "")
236 return nil
237 }
238
239 // RevokeAccount deletes every session for an account, for the operator command
240 // and as a backstop after restoring an old database.
241 func (s *Store) RevokeAccount(accountID string) (int64, error) {
242 res, err := s.db.Exec(`DELETE FROM sessions WHERE account_id=?`, accountID)
243 if err != nil {
244 return 0, fmt.Errorf("session: revoke account: %w", err)
245 }
246 n, _ := res.RowsAffected()
247 if n > 0 {
248 s.audit("session.revoke-account", accountID, fmt.Sprintf("%d session(s)", n))
249 }
250 return n, nil
251 }
252
253 // RevokeAll deletes every session on the instance. It is the recovery step after
254 // restoring an old users.db, where the restored rows would otherwise still name
255 // sessions whose cookies may exist.
256 func (s *Store) RevokeAll() (int64, error) {
257 res, err := s.db.Exec(`DELETE FROM sessions`)
258 if err != nil {
259 return 0, fmt.Errorf("session: revoke all: %w", err)
260 }
261 n, _ := res.RowsAffected()
262 if n > 0 {
263 s.audit("session.revoke-all", "-", fmt.Sprintf("%d session(s)", n))
264 }
265 return n, nil
266 }
267
268 // RevokeCredential deletes the sessions created by one passkey. Removing the
269 // passkey itself cascades, but an explicit revoke is useful before rotating one.
270 func (s *Store) RevokeCredential(credentialID []byte) (int64, error) {
271 if len(credentialID) == 0 {
272 return 0, nil
273 }
274 var accountID string
275 _ = s.db.QueryRow(`SELECT user_id FROM user_credentials WHERE credential_id=?`, credentialIDDB(credentialID)).Scan(&accountID)
276 res, err := s.db.Exec(`DELETE FROM sessions WHERE credential_id=?`, credentialIDDB(credentialID))
277 if err != nil {
278 return 0, fmt.Errorf("session: revoke passkey sessions: %w", err)
279 }
280 n, _ := res.RowsAffected()
281 if n > 0 {
282 s.audit("session.revoke-credential", accountID, credentialDetail(credentialID))
283 }
284 return n, nil
285 }
286
287 // List returns an account's sessions, newest first. Expired rows are removed by
288 // Create and Lookup; this is an operator view, not an authorization check.
289 func (s *Store) List(accountID string) ([]Session, error) {
290 rows, err := s.db.Query(`
291 SELECT account_id, credential_id, created, last_seen, expires, user_agent
292 FROM sessions WHERE account_id=? ORDER BY created DESC`, accountID)
293 if err != nil {
294 return nil, fmt.Errorf("session: list: %w", err)
295 }
296 defer rows.Close()
297 var out []Session
298 for rows.Next() {
299 var (
300 sess Session
301 credentialID sql.NullString
302 created int64
303 lastSeen int64
304 expires int64
305 )
306 if err := rows.Scan(&sess.AccountID, &credentialID, &created, &lastSeen, &expires, &sess.UserAgent); err != nil {
307 return nil, fmt.Errorf("session: list: %w", err)
308 }
309 sess.Created = time.Unix(created, 0).UTC()
310 sess.LastSeen = time.Unix(lastSeen, 0).UTC()
311 sess.Expires = time.Unix(expires, 0).UTC()
312 if credentialID.Valid {
313 raw, err := base64.RawURLEncoding.DecodeString(credentialID.String)
314 if err == nil {
315 sess.CredentialID = raw
316 }
317 }
318 out = append(out, sess)
319 }
320 if err := rows.Err(); err != nil {
321 return nil, err
322 }
323 return out, nil
324 }
325
326 // DeleteExpired removes rows whose absolute lifetime has run out.
327 func (s *Store) DeleteExpired(now time.Time) (int64, error) {
328 res, err := s.db.Exec(`DELETE FROM sessions WHERE expires <= ?`, now.Unix())
329 if err != nil {
330 return 0, fmt.Errorf("session: delete expired: %w", err)
331 }
332 n, _ := res.RowsAffected()
333 return n, nil
334 }
335
336 func (s *Store) now() time.Time {
337 if s.Now != nil {
338 return s.Now().UTC()
339 }
340 return time.Now().UTC()
341 }
342
343 func (s *Store) idle() time.Duration {
344 if s.Idle <= 0 {
345 return DefaultIdle
346 }
347 return s.Idle
348 }
349
350 func (s *Store) absolute() time.Duration {
351 if s.Absolute <= 0 {
352 return DefaultAbsolute
353 }
354 return s.Absolute
355 }
356
357 func newToken() (string, error) {
358 b := make([]byte, tokenBytes)
359 if _, err := rand.Read(b); err != nil {
360 return "", fmt.Errorf("session: token: %w", err)
361 }
362 return base64.RawURLEncoding.EncodeToString(b), nil
363 }
364
365 func hashToken(token string) string {
366 sum := sha256.Sum256([]byte(token))
367 return hex.EncodeToString(sum[:])
368 }
369
370 // csrfToken is a one-way function of the session token. It cannot be used to
371 // recover the cookie value, and it changes when the session is replaced.
372 func csrfToken(token string) string {
373 mac := hmac.New(sha256.New, []byte(token))
374 _, _ = mac.Write([]byte("vole-csrf"))
375 return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
376 }
377
378 func nullableCredentialID(id []byte) any {
379 if len(id) == 0 {
380 return nil
381 }
382 return credentialIDDB(id)
383 }
384
385 func credentialIDDB(id []byte) string { return base64.RawURLEncoding.EncodeToString(id) }
386
387 func credentialDetail(id []byte) string {
388 if len(id) == 0 {
389 return ""
390 }
391 return credentialIDDB(id)
392 }
393
394 func trimToken(s string) string {
395 // Cookie values are already opaque; trimming guards against a stray newline
396 // in a hand-written test or CLI call, not against a valid token.
397 for len(s) > 0 && (s[len(s)-1] == '\n' || s[len(s)-1] == '\r' || s[len(s)-1] == ' ') {
398 s = s[:len(s)-1]
399 }
400 return s
401 }
402
403 func (s *Store) audit(action, account, detail string) {
404 if s.AuditPath == "" {
405 return
406 }
407 if account == "" {
408 account = "-"
409 }
410 f, err := os.OpenFile(s.AuditPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
411 if err != nil {
412 return
413 }
414 fmt.Fprintf(f, "%s\t%s\t%s\t%s\n", time.Now().UTC().Format(time.RFC3339), action, account, detail)
415 f.Close()
416 }