Files
llmgw/internal/store/sessions.go
T
Ilker ManapandClaude Opus 5 e519dc587c
CI / test (push) Successful in 1m21s
Serve the cluster dashboard over the web, without serving pprof with it
Reaching it meant an SSH tunnel, because the page lived on the admin listener
and that listener is bound to loopback. Opening the port was never the way to
fix that: it also serves pprof, and pprof can dump the process heap, where
every API key sits in plaintext. Exposing the port would have been exposing
the key store.

So the page moves rather than the port. The same dashboard is now mounted at
/admin on the client-facing port, behind the session and CSRF machinery that
already guards /profile, gated on a flag on the account. The admin listener
keeps pprof and metrics and stays on loopback.

One bit, not a role system: it grants looking. Everything that changes the
cluster or issues a credential stays on the CLI, where it needs a shell rather
than a session — which keeps the worst case of a stolen admin cookie to "saw
the cluster" rather than "drained your fleet".

Someone without the flag gets 404, not 403. A 403 confirms the page is there
and that admin accounts exist here, which is free reconnaissance; 404 is what
every other unknown path returns. The profile page does not show a link to a
door the visitor cannot open.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 10:46:45 +02:00

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 *SQLite) 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 *SQLite) 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 *SQLite) 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 *SQLite) 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 *SQLite) 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 *SQLite) 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
}