feat(devices): P2 sessions 表 + 在线/最后登录/客户端版本
ci-pangolin / Lint — shellcheck (push) Successful in 8s
ci-pangolin / OpenAPI Sync Check (push) Successful in 18s
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (push) Successful in 6s
ci-pangolin / Flutter — analyze + test (push) Successful in 24s
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (push) Successful in 5s
ci-pangolin / Codegen Drift — token 生成物未漂移 (push) Successful in 4s
ci-pangolin / Go — build + test (push) Successful in 11s
ci-pangolin / E2E Smoke — L4 进程级端到端 (push) Successful in 14s
ci-pangolin / Go — integration (mysql/redis testcontainers) (push) Failing after 4m13s
ci-pangolin / Golden — 视觉回归 (components + auth) (push) Successful in 14s
ci-pangolin / Lint — shellcheck (push) Successful in 8s
ci-pangolin / OpenAPI Sync Check (push) Successful in 18s
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (push) Successful in 6s
ci-pangolin / Flutter — analyze + test (push) Successful in 24s
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (push) Successful in 5s
ci-pangolin / Codegen Drift — token 生成物未漂移 (push) Successful in 4s
ci-pangolin / Go — build + test (push) Successful in 11s
ci-pangolin / E2E Smoke — L4 进程级端到端 (push) Successful in 14s
ci-pangolin / Go — integration (mysql/redis testcontainers) (push) Failing after 4m13s
ci-pangolin / Golden — 视觉回归 (components + auth) (push) Successful in 14s
migration 000016(mysql+sqlite,含 down):新增 sessions 表(绑 device+refresh JTI) + devices 加 client_version/totp_trusted_until。devices 唯一键改 + platform CHECK 加 linux(需 SQLite 表重建)拆出后续迁移,降风险。 后端:新 internal/sessions Store(Create/Rotate/Revoke/RevokeByDevice/ LastLoginByDevice);TokenManager 外露 refresh JTI(IssueWithJTI/RefreshWithJTI/ ParseRefreshJTI);auth.Service 注入 SessionStore——登录建会话、刷新轮换、登出吊销; DeviceRegistrar 返回 deviceID;ReportUsage 心跳 touch devices.last_seen(在线判定); devices.ListDevices 经 LastLoginSource 注入返回 online(last_seen<3min)/client_version/ last_login;RegisterIfAbsent 存 client_version。 客户端:Device model 加 online/clientVersion/lastLogin(fromJson 自动解析)。 测试:sessions store 3 例 + ListDevices 在线/最后登录 + device model 2 例 + migration v16;全量 go test/flutter test 绿。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user