internal/server/store/sessions.go
Ref: Size: 3.0 KiB History
package store
import (
"database/sql"
"errors"
"fmt"
"time"
"github.com/a73x/eitri/internal/random"
)
// CreateSession mints a server-side console session for tenant, valid for ttl,
// and returns its id — a 256-bit random hex string used verbatim as the
// eitri_session cookie value. Server-side rows mean revocation works and a
// restart keeps users signed in.
//
// Only the SHA-256 of the id is stored, exactly as a PAT is (see hashToken):
// the row is a verifier, not a credential, so a copied database or a backup
// yields nothing anyone can present. The cookie is unchanged — the client still
// holds the id itself, which is the only place it exists.
func (s *Store) CreateSession(tenant string, ttl time.Duration) (string, error) {
id := random.Hex(32)
now := time.Now().UTC()
_, err := s.db.Exec(
`INSERT INTO sessions(id, tenant, created_at, expires_at) VALUES (?,?,?,?)`,
hashToken(id), tenant, now.Format(time.RFC3339), now.Add(ttl).Format(time.RFC3339),
)
if err != nil {
return "", fmt.Errorf("create session: %w", err)
}
return id, nil
}
// SessionTenant resolves a session id to its tenant, enforcing expiry on read.
// ok=false (no error) for an unknown, deleted, or expired session — the caller
// treats all three identically (redirect to sign-in).
//
// Keyed on the hash of the presented id, so the stored value is never compared
// as a secret and there is no timing side-channel to work.
//
// Rows written before ids were hashed hold the id itself. They cannot match a
// hashed lookup, so they authenticate nobody and simply age out at their own
// expiry — the one visible effect of the change is that everyone signs in once
// more. They are not deleted here because a stored id and a stored hash are both
// 64 hex characters and cannot be told apart, so a blanket purge would sign
// every user out on every restart rather than once.
func (s *Store) SessionTenant(id string) (string, bool, error) {
var tenant string
err := s.db.QueryRow(
`SELECT tenant FROM sessions WHERE id=? AND expires_at > ?`,
hashToken(id), time.Now().UTC().Format(time.RFC3339),
).Scan(&tenant)
if errors.Is(err, sql.ErrNoRows) {
return "", false, nil
}
if err != nil {
return "", false, fmt.Errorf("session tenant: %w", err)
}
return tenant, true, nil
}
// DeleteSession revokes a session (sign-out). Deleting an absent id is a no-op.
// Keyed on the hash, like every other read of this table.
func (s *Store) DeleteSession(id string) error {
if _, err := s.db.Exec(`DELETE FROM sessions WHERE id=?`, hashToken(id)); err != nil {
return fmt.Errorf("delete session: %w", err)
}
return nil
}
// ReapSessions deletes every expired session row and reports how many. Run
// opportunistically to keep the table bounded; expiry is already enforced on
// read, so this is hygiene, not correctness.
func (s *Store) ReapSessions() (int64, error) {
res, err := s.db.Exec(`DELETE FROM sessions WHERE expires_at <= ?`,
time.Now().UTC().Format(time.RFC3339))
if err != nil {
return 0, fmt.Errorf("reap sessions: %w", err)
}
return res.RowsAffected()
}