Two things that were only ever confusing, and one of them stopped a command working on the cluster. The store type was named SQLite and kept the name when PostgreSQL was added, so OpenPostgres returned a *SQLite and all seventy-odd methods on the production store were spelled after the database it was not running on. Nothing broke; people did. Earlier today I added three methods to *SQLite for a table that on this cluster only ever lives in PostgreSQL, and the name did not once look wrong. It is a field, which is all it ever was. `llmgw users` and `llmgw keys` defaulted --store to a SQLite path. On a deployment whose store is PostgreSQL every one of them failed with "unable to open database file" while the right answer sat in auth.store in the config file beside them; the conclusion an operator draws is that the tool is broken, and they are not far off. They now ask the server's own configuration before falling back, and the error names which of the three sources chose the address — the failure was never the path, it was which one picked it. The single-machine default still applies last, so a deployment with no config file keeps working with no flags. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
128 lines
3.9 KiB
Go
128 lines
3.9 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"database/sql"
|
|
"errors"
|
|
"time"
|
|
)
|
|
|
|
// SessionTTL is how long a sign-in lasts.
|
|
const SessionTTL = 14 * 24 * time.Hour
|
|
|
|
// ErrNoSession says a session cookie names nothing usable.
|
|
var ErrNoSession = errors.New("not signed in")
|
|
|
|
// Session is an open sign-in.
|
|
type Session struct {
|
|
UserID string
|
|
CSRFHash []byte
|
|
ExpiresAt time.Time
|
|
}
|
|
|
|
// NewSessionToken mints a session or CSRF token.
|
|
func NewSessionToken() (string, error) {
|
|
raw := make([]byte, 32)
|
|
if _, err := rand.Read(raw); err != nil {
|
|
return "", err
|
|
}
|
|
return encodeBase62(raw), nil
|
|
}
|
|
|
|
// CreateSession opens a session and returns nothing: the caller already holds
|
|
// the tokens, and neither is stored in the clear.
|
|
func (s *DB) CreateSession(ctx context.Context, userID, token, csrf string, ttl time.Duration) error {
|
|
if ttl <= 0 {
|
|
ttl = SessionTTL
|
|
}
|
|
now := time.Now()
|
|
_, err := s.db.ExecContext(ctx,
|
|
`INSERT INTO sessions (token_hash, user_id, csrf_hash, created_at, expires_at, last_seen)
|
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
HashKey(token), userID, HashKey(csrf), millis(now), millis(now.Add(ttl)), millis(now))
|
|
return err
|
|
}
|
|
|
|
// SessionByToken resolves a cookie to its session and the person it belongs to.
|
|
//
|
|
// One query rather than two: a session whose user has been suspended must stop
|
|
// working immediately, and checking that in a second round trip is a check
|
|
// somebody eventually forgets.
|
|
func (s *DB) SessionByToken(ctx context.Context, token string) (Session, User, error) {
|
|
row := s.db.QueryRowContext(ctx,
|
|
`SELECT s.user_id, s.csrf_hash, s.expires_at,
|
|
u.id, u.email, u.name, u.password_hash, u.state, COALESCE(u.project_id, ''),
|
|
u.created_at, u.approved_at, u.approved_by, u.last_login_at, u.is_admin
|
|
FROM sessions s JOIN users u ON u.id = s.user_id
|
|
WHERE s.token_hash = ?`, HashKey(token))
|
|
|
|
var sess Session
|
|
var expires int64
|
|
var u User
|
|
var state string
|
|
var created int64
|
|
var approved, lastLogin sql.NullInt64
|
|
var approvedBy sql.NullString
|
|
|
|
if err := row.Scan(&sess.UserID, &sess.CSRFHash, &expires,
|
|
&u.ID, &u.Email, &u.Name, &u.PasswordHash, &state, &u.ProjectID,
|
|
&created, &approved, &approvedBy, &lastLogin, &u.IsAdmin); err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return Session{}, User{}, ErrNoSession
|
|
}
|
|
return Session{}, User{}, err
|
|
}
|
|
|
|
sess.ExpiresAt = fromMillis(expires)
|
|
if time.Now().After(sess.ExpiresAt) {
|
|
return Session{}, User{}, ErrNoSession
|
|
}
|
|
|
|
u.State = UserState(state)
|
|
u.CreatedAt = fromMillis(created)
|
|
u.ApprovedAt = fromNullMillis(approved)
|
|
u.ApprovedBy = approvedBy.String
|
|
u.LastLoginAt = fromNullMillis(lastLogin)
|
|
|
|
if !u.CanSignIn() {
|
|
// Suspended between sign-in and now. The session exists; it just stops
|
|
// meaning anything.
|
|
return Session{}, User{}, ErrNoSession
|
|
}
|
|
return sess, u, nil
|
|
}
|
|
|
|
// TouchSession records activity, at most once a minute.
|
|
func (s *DB) TouchSession(ctx context.Context, token string, at time.Time) error {
|
|
_, err := s.db.ExecContext(ctx,
|
|
`UPDATE sessions SET last_seen = ? WHERE token_hash = ? AND last_seen < ?`,
|
|
millis(at), HashKey(token), millis(at.Add(-time.Minute)))
|
|
return err
|
|
}
|
|
|
|
// DeleteSession signs one browser out.
|
|
func (s *DB) DeleteSession(ctx context.Context, token string) error {
|
|
_, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE token_hash = ?`, HashKey(token))
|
|
return err
|
|
}
|
|
|
|
// DeleteUserSessions signs a person out everywhere.
|
|
//
|
|
// What suspension and a password change both need: leaving old sessions open
|
|
// would make either of them advisory.
|
|
func (s *DB) DeleteUserSessions(ctx context.Context, userID string) error {
|
|
_, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = ?`, userID)
|
|
return err
|
|
}
|
|
|
|
// PurgeSessions removes expired rows.
|
|
func (s *DB) PurgeSessions(ctx context.Context, before time.Time) (int64, error) {
|
|
res, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE expires_at < ?`, millis(before))
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
n, _ := res.RowsAffected()
|
|
return n, nil
|
|
}
|