CI / test (push) Successful in 1m21s
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>
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 *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
|
|
}
|