f0af3bcc94
trick bug:强退后被踢设备的下一次会话轮询会先刷 last_seen 再发现 active=false,那一刷 把它顶成「在线」直到 90s 窗口过期。修:① online = last_seen 新 且 有未撤销会话 (ActiveSessionDeviceIDs);被强退设备无活跃会话 → 立即离线。② SessionActive 仅在 active 时才 TouchLastSeen,撤销会话的轮询不再刷新。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
161 lines
5.7 KiB
Go
161 lines
5.7 KiB
Go
// 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
|
|
}
|