// Package sessions persists login sessions: one row per login, binding a device // to a refresh-token JTI. It backs per-device force-logout, last-login time, and // session history. The Redis refresh-JTI whitelist remains the fast-path check; // sessions is the queryable authoritative record. package sessions import ( "context" "database/sql" "fmt" "time" dbx "github.com/wangjia/pangolin/server/internal/db" ) // Store wraps the sessions-table operations. Portable SQL (`?` placeholders, // time computed in Go) so it runs on both MySQL and SQLite. type Store struct { db *sql.DB dialect dbx.Dialect } // NewStore creates a Store backed by the given connection pool. func NewStore(db *sql.DB) *Store { return &Store{db: db, dialect: dbx.DialectForDB(db)} } // Create inserts a session for a fresh login (created_at = last_active = now). func (s *Store) Create(ctx context.Context, userID, deviceID int64, jti, ip, clientVersion string) error { now := time.Now().UTC() if _, err := s.db.ExecContext(ctx, `INSERT INTO sessions (user_id, device_id, refresh_jti, client_ip, client_version, created_at, last_active) VALUES (?, ?, ?, ?, ?, ?, ?)`, userID, deviceID, jti, nullStr(ip), nullStr(clientVersion), now, now); err != nil { return fmt.Errorf("sessions.Create: %w", err) } return nil } // Rotate moves a session to a new JTI on refresh-token rotation and bumps // last_active. No-op if the old JTI is unknown or already revoked. func (s *Store) Rotate(ctx context.Context, oldJTI, newJTI string) error { now := time.Now().UTC() if _, err := s.db.ExecContext(ctx, `UPDATE sessions SET refresh_jti=?, last_active=? WHERE refresh_jti=? AND revoked_at IS NULL`, newJTI, now, oldJTI); err != nil { return fmt.Errorf("sessions.Rotate: %w", err) } return nil } // HasActiveSession reports whether the (user, device) has at least one non-revoked // session — i.e. the device has NOT been force-logged-out. Backs the client's // periodic session-validity poll: near-instant remote logout without a push channel // (access token is a stateless JWT, so we can't see revocation on normal requests). func (s *Store) HasActiveSession(ctx context.Context, userID, deviceID int64) (bool, error) { var n int if err := s.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM sessions WHERE user_id=? AND device_id=? AND revoked_at IS NULL`, userID, deviceID).Scan(&n); err != nil { return false, fmt.Errorf("sessions.HasActiveSession: %w", err) } return n > 0, nil } // ActiveSessionDeviceIDs returns the set of the user's devices that have at least // one non-revoked session. Used to mark a device "online" only while it has a live // session — so a force-logged-out device drops offline immediately, not only after // its last_seen window lapses. func (s *Store) ActiveSessionDeviceIDs(ctx context.Context, userID int64) (map[int64]bool, error) { rows, err := s.db.QueryContext(ctx, `SELECT DISTINCT device_id FROM sessions WHERE user_id=? AND revoked_at IS NULL`, userID) if err != nil { return nil, fmt.Errorf("sessions.ActiveSessionDeviceIDs: %w", err) } defer rows.Close() set := make(map[int64]bool) for rows.Next() { var id int64 if err := rows.Scan(&id); err != nil { return nil, fmt.Errorf("sessions.ActiveSessionDeviceIDs scan: %w", err) } set[id] = true } return set, rows.Err() } // Revoke marks the session with the given JTI revoked (logout). Idempotent. func (s *Store) Revoke(ctx context.Context, jti string) error { now := time.Now().UTC() if _, err := s.db.ExecContext(ctx, `UPDATE sessions SET revoked_at=? WHERE refresh_jti=? AND revoked_at IS NULL`, now, jti); err != nil { return fmt.Errorf("sessions.Revoke: %w", err) } return nil } // RevokeByDevice revokes all non-revoked sessions of a device and returns their // JTIs so the caller can also drop them from the Redis whitelist (force-logout). func (s *Store) RevokeByDevice(ctx context.Context, userID, deviceID int64) ([]string, error) { rows, err := s.db.QueryContext(ctx, `SELECT refresh_jti FROM sessions WHERE user_id=? AND device_id=? AND revoked_at IS NULL`, userID, deviceID) if err != nil { return nil, fmt.Errorf("sessions.RevokeByDevice select: %w", err) } var jtis []string for rows.Next() { var j string if err := rows.Scan(&j); err != nil { rows.Close() return nil, err } jtis = append(jtis, j) } rows.Close() if err := rows.Err(); err != nil { return nil, err } now := time.Now().UTC() if _, err := s.db.ExecContext(ctx, `UPDATE sessions SET revoked_at=? WHERE user_id=? AND device_id=? AND revoked_at IS NULL`, now, userID, deviceID); err != nil { return nil, fmt.Errorf("sessions.RevokeByDevice update: %w", err) } return jtis, nil } // LastLoginByDevice returns, per device of a user, the most recent session // created_at ("last login"). Devices with no session are absent from the map. // // Ordered ASC + last-write-wins instead of MAX(created_at): SQLite's aggregate // loses datetime affinity and yields a string that won't scan into time.Time, // whereas a plain column select converts cleanly on both SQLite and MySQL. func (s *Store) LastLoginByDevice(ctx context.Context, userID int64) (map[int64]time.Time, error) { rows, err := s.db.QueryContext(ctx, `SELECT device_id, created_at FROM sessions WHERE user_id=? ORDER BY created_at ASC`, userID) if err != nil { return nil, fmt.Errorf("sessions.LastLoginByDevice: %w", err) } defer rows.Close() out := make(map[int64]time.Time) for rows.Next() { var did int64 var ts time.Time if err := rows.Scan(&did, &ts); err != nil { return nil, err } out[did] = ts.UTC() // ASC order → last assignment per device is the max } return out, rows.Err() } // nullStr maps "" to SQL NULL so empty optional fields stay NULL. func nullStr(s string) any { if s == "" { return nil } return s }