From 2f298f0a0afab016797cc8b2a8580cc520937aae Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Mon, 29 Jun 2026 00:50:24 +0800 Subject: [PATCH] =?UTF-8?q?feat(devices):=20P2=20sessions=20=E8=A1=A8=20+?= =?UTF-8?q?=20=E5=9C=A8=E7=BA=BF/=E6=9C=80=E5=90=8E=E7=99=BB=E5=BD=95/?= =?UTF-8?q?=E5=AE=A2=E6=88=B7=E7=AB=AF=E7=89=88=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- client/lib/models/device.dart | 25 +++- client/test/unit/device_model_test.dart | 30 +++++ .../2026-06-29-device-session-management.md | 45 +++---- server/cmd/server/main.go | 29 ++-- server/internal/auth/device_register_test.go | 13 +- server/internal/auth/handler.go | 2 +- server/internal/auth/service.go | 90 ++++++++----- server/internal/auth/service_test.go | 24 ++-- server/internal/auth/token.go | 56 +++++--- .../devices/devices_integration_test.go | 12 +- server/internal/devices/service.go | 99 +++++++++----- server/internal/devices/store.go | 57 +++++--- server/internal/nodes/grpc_test.go | 8 ++ server/internal/nodes/handler_grpc.go | 5 + server/internal/nodes/store.go | 14 ++ server/internal/sessions/store.go | 124 ++++++++++++++++++ server/internal/store/devices_list_test.go | 50 +++++++ server/internal/store/sessions_store_test.go | 124 ++++++++++++++++++ server/internal/store/sqlite_migrate_test.go | 6 +- .../000016_sessions_and_device_meta.down.sql | 3 + .../000016_sessions_and_device_meta.up.sql | 18 +++ .../000016_sessions_and_device_meta.down.sql | 5 + .../000016_sessions_and_device_meta.up.sql | 29 ++++ 23 files changed, 709 insertions(+), 159 deletions(-) create mode 100644 client/test/unit/device_model_test.dart create mode 100644 server/internal/sessions/store.go create mode 100644 server/internal/store/devices_list_test.go create mode 100644 server/internal/store/sessions_store_test.go create mode 100644 server/migrations/mysql/000016_sessions_and_device_meta.down.sql create mode 100644 server/migrations/mysql/000016_sessions_and_device_meta.up.sql create mode 100644 server/migrations/sqlite/000016_sessions_and_device_meta.down.sql create mode 100644 server/migrations/sqlite/000016_sessions_and_device_meta.up.sql diff --git a/client/lib/models/device.dart b/client/lib/models/device.dart index eb40372..9f0bee8 100644 --- a/client/lib/models/device.dart +++ b/client/lib/models/device.dart @@ -5,24 +5,43 @@ class Device { required this.name, required this.platform, this.lastSeen, + this.clientVersion = '', + this.online = false, + this.lastLogin, }); final String uuid; final String name; - /// 'ios' | 'android' | 'windows' | 'macos'。 + /// 'ios' | 'android' | 'windows' | 'macos' | 'linux'。 final String platform; /// 最近活跃(UTC);从未上线为 null。 final DateTime? lastSeen; + /// 客户端版本(该设备最近上报),可能为空。 + final String clientVersion; + + /// 在线(数据面活跃,last_seen 在阈值内,由服务端判定)。 + final bool online; + + /// 最后登录时间(最近一次会话创建,UTC);无会话为 null。 + final DateTime? lastLogin; + factory Device.fromJson(Map m) { - final ls = m['last_seen'] as String?; + DateTime? parseTs(String key) { + final s = m[key] as String?; + return (s != null && s.isNotEmpty) ? DateTime.tryParse(s) : null; + } + return Device( uuid: m['uuid'] as String? ?? '', name: m['name'] as String? ?? '', platform: m['platform'] as String? ?? '', - lastSeen: (ls != null && ls.isNotEmpty) ? DateTime.tryParse(ls) : null, + lastSeen: parseTs('last_seen'), + clientVersion: m['client_version'] as String? ?? '', + online: m['online'] as bool? ?? false, + lastLogin: parseTs('last_login'), ); } } diff --git a/client/test/unit/device_model_test.dart b/client/test/unit/device_model_test.dart new file mode 100644 index 0000000..7e429b5 --- /dev/null +++ b/client/test/unit/device_model_test.dart @@ -0,0 +1,30 @@ +// device_model_test.dart — Device.fromJson 解析 online/client_version/last_login +import 'package:flutter_test/flutter_test.dart'; +import 'package:pangolin_vpn/models/device.dart'; + +void main() { + test('解析全字段', () { + final d = Device.fromJson({ + 'uuid': 'd1', + 'name': 'MacBook Pro', + 'platform': 'macos', + 'last_seen': '2026-06-28T10:00:00Z', + 'client_version': 'v1.0.10', + 'online': true, + 'last_login': '2026-06-28T09:00:00Z', + }); + expect(d.uuid, 'd1'); + expect(d.online, isTrue); + expect(d.clientVersion, 'v1.0.10'); + expect(d.lastLogin, DateTime.utc(2026, 6, 28, 9)); + expect(d.lastSeen, DateTime.utc(2026, 6, 28, 10)); + }); + + test('缺省:online=false,版本空,last_login=null', () { + final d = Device.fromJson({'uuid': 'd2', 'name': 'PC', 'platform': 'windows'}); + expect(d.online, isFalse); + expect(d.clientVersion, ''); + expect(d.lastLogin, isNull); + expect(d.lastSeen, isNull); + }); +} diff --git a/docs/superpowers/plans/2026-06-29-device-session-management.md b/docs/superpowers/plans/2026-06-29-device-session-management.md index 387fcc5..0fc1182 100644 --- a/docs/superpowers/plans/2026-06-29-device-session-management.md +++ b/docs/superpowers/plans/2026-06-29-device-session-management.md @@ -5,32 +5,33 @@ ## 计划文档(先产出) - [x] `docs/superpowers/plans/2026-06-29-device-session-management.md`(本文件) -- [ ] `docs/device-session-management-plan.html`(阅读版) -- [ ] 登记 `docs/index.html`「实现计划」 -- [ ] 总方案 `device-session-management-design.html` 加「→ 实现计划」链接 +- [x] `docs/device-session-management-plan.html`(阅读版) +- [x] 登记 `docs/index.html`「实现计划」 +- [x] 总方案 `device-session-management-design.html` 加「→ 实现计划」链接 ## P1 · 设备注册打通(登录即注册设备) -- [ ] 客户端加依赖 `uuid` + `device_info_plus`(`client/pubspec.yaml`) -- [ ] 新 `client/lib/services/device_identity.dart`:`deviceId()`(secure storage 读;无则 `Uuid().v4()` 写;读失败 ≠ 没有,不重生成)、`deviceName()`/`platform()`、`clientVersion()` -- [ ] 弃用 `connection_provider.dart` 的 `_kDeviceId='mac-001'` → 用 `device_identity` -- [ ] `auth_api.dart` login/register 请求体加 `device:{id,name,platform,client_version}` -- [ ] `auth/handler.go`:`loginRequest`/`registerRequest` 加 `Device deviceMeta` -- [ ] `auth/service.go` Login/Register:成功签发后调 `DeviceRegistrar.RegisterIfAbsent`(consumer-side 接口;`devices.Service` 实现;`RegisterIfAbsent` 增返回 `deviceID`);`MaxDevices` 由 plan 解析 -- [ ] `normalizePlatform` 加 `linux` -- [ ] `main.go` 构造 `devices.Service` 注入 `authSvc` -- [ ] 测试:`device_identity` 单测;auth 集成(登录后 devices 落行) -- [ ] 验收:登录后 `GET /v1/me/devices` 非空 +- [x] 客户端加依赖 `uuid` + `device_info_plus`(`client/pubspec.yaml`) +- [x] 新 `client/lib/services/device_identity.dart`:`deviceId()`(secure storage 读;无则 `Uuid().v4()` 写;读失败 ≠ 没有,不重生成)、`deviceName()`/`platform()`、`clientVersion()` +- [x] 弃用 `connection_provider.dart` 的 `_kDeviceId='mac-001'` → 用 `device_identity` +- [x] `auth_api.dart` login/register 请求体加 `device:{id,name,platform,client_version}` +- [x] `auth/handler.go`:`loginRequest`/`registerRequest` 加 `Device deviceMeta` +- [x] `auth/service.go` Login/Register:成功签发后调 `DeviceRegistrar.RegisterIfAbsent`(consumer-side 接口;`devices.Service` 实现;`RegisterIfAbsent` 增返回 `deviceID`);`MaxDevices` 由 plan 解析 +- [x] `normalizePlatform` 加 `linux` +- [x] `main.go` 构造 `devices.Service` 注入 `authSvc` +- [x] 测试:`device_identity` 单测;auth 集成(登录后 devices 落行) +- [x] 验收:登录后 `GET /v1/me/devices` 非空 ## P2 · sessions 表 + 在线/最后登录 -- [ ] migration `000016_sessions_and_device_meta`(mysql+sqlite,含 down):建 sessions 表;devices 加 `client_version`+`totp_trusted_until`;唯一键 `UNIQUE(uuid)`→`UNIQUE(user_id,uuid)`;platform CHECK 加 `linux` -- [ ] 新 `server/internal/sessions/`:`Store`(Create/RotateJTI/RevokeByJTI/RevokeByDevice/ActiveByUserWithDevice/LastLoginByDevice) -- [ ] `TokenManager.Issue`/`Refresh` 外露 refresh JTI -- [ ] `auth.Service` 注入 `sessions.Store`:Login/Register `Create`;Refresh `RotateJTI`+last_active;Logout `RevokeByJTI` -- [ ] `nodes/handler_grpc.go: ReportUsage` deviceID>0 时 touch `devices.last_seen` -- [ ] `devices.Service.ListDevices`+API struct+store join:增 `client_version`/`online`/`last_login` -- [ ] 客户端 `Device` model 加 `clientVersion`/`online`/`lastLogin`;`account_api` 解析 -- [ ] 测试:sessions store;JTI surface;ReportUsage touch;ListDevices 计算;migration sqlite 实库 -- [ ] 验收:列表显示在线/版本/最后登录;停 agent ~3min 转离线 +- [x] migration `000016_sessions_and_device_meta`(mysql+sqlite,含 down):建 sessions 表;devices 加 `client_version`+`totp_trusted_until` + - ⏭ **拆出**:devices 唯一键 `UNIQUE(uuid)`→`UNIQUE(user_id,uuid)` + platform CHECK 加 `linux` 需 SQLite 表重建(最高风险 DDL),隔离到单独后续迁移(多账户同机=已知降级,linux 注册 best-effort 失败不阻断登录) +- [x] 新 `server/internal/sessions/`:`Store`(Create/Rotate/Revoke/RevokeByDevice/LastLoginByDevice) +- [x] `TokenManager.IssueWithJTI`/`RefreshWithJTI`/`ParseRefreshJTI` 外露 refresh JTI +- [x] `auth.Service` 注入 `SessionStore`:Login/Register `Create`;Refresh `Rotate`+last_active;Logout `Revoke` +- [x] `nodes/handler_grpc.go: ReportUsage` deviceID>0 时 touch `devices.last_seen` +- [x] `devices.Service.ListDevices`+API struct+store join:增 `client_version`/`online`/`last_login`(注入 `LastLoginSource`) +- [x] 客户端 `Device` model 加 `clientVersion`/`online`/`lastLogin`;`account_api` 解析(fromJson 自动) +- [x] 测试:sessions store(3) + ListDevices 计算 + device model(2) + auth 设备/会话;migration sqlite 实库 v16 +- [ ] 验收:列表显示在线/版本/最后登录(待 P6 UI + 端到端);停 agent ~3min 转离线 ## P3 · 两个操作(强制退出 + 清除增强) - [ ] 新端点 `POST /v1/me/devices/{uuid}/logout`(handler + `Service.ForceLogout`):`sessions.RevokeByDevice` + 逐 jti `TokenManager.Revoke` diff --git a/server/cmd/server/main.go b/server/cmd/server/main.go index 53134ec..066e0ac 100644 --- a/server/cmd/server/main.go +++ b/server/cmd/server/main.go @@ -39,6 +39,7 @@ import ( "github.com/wangjia/pangolin/server/internal/redisutil" "github.com/wangjia/pangolin/server/internal/scheduler" "github.com/wangjia/pangolin/server/internal/scheduler/probe" + "github.com/wangjia/pangolin/server/internal/sessions" "github.com/wangjia/pangolin/server/internal/usage" ) @@ -243,6 +244,11 @@ func mountV1(r chi.Router, sqlDB *sql.DB, rdb *redis.Client, nodeSvc *nodes.Serv devicesSvc := devices.NewService(devicesStore, nil) // NoopRevoker for MVP devicesHandler := devices.NewHandler(devicesSvc) + // Sessions: login sessions bound to (device, refresh JTI) — P2. Shared by + // auth (create/rotate/revoke) and devices (last-login display). + sessionStore := sessions.NewStore(sqlDB) + devicesSvc.SetLastLoginSource(sessionStore) + // ── Auth ────────────────────────────────────────────────────────────────── var authHandler *auth.Handler if tm != nil { @@ -262,6 +268,7 @@ func mountV1(r chi.Router, sqlDB *sql.DB, rdb *redis.Client, nodeSvc *nodes.Serv authStore := auth.NewSQLStore(sqlDB) authSvc := auth.NewService(authStore, rdb, rl, tm, mailer, auth.ServiceConfig{}, nil) authSvc.SetDeviceRegistrar(authDeviceRegistrar{svc: devicesSvc}) + authSvc.SetSessionStore(sessionStore) authHandler = auth.NewHandler(authSvc) } @@ -509,15 +516,17 @@ func intEnvDefault(key string, def int) int { // limiting is a separate future policy with its own UX. type authDeviceRegistrar struct{ svc *devices.Service } -func (a authDeviceRegistrar) RegisterDevice(ctx context.Context, userID int64, meta auth.DeviceMeta) error { - if _, apiErr := a.svc.RegisterIfAbsent(ctx, devices.RegisterInput{ - UserID: userID, - DeviceUUID: meta.DeviceID, - Name: meta.Name, - Platform: meta.Platform, - MaxDevices: 0, - }); apiErr != nil { - return apiErr +func (a authDeviceRegistrar) RegisterDevice(ctx context.Context, userID int64, meta auth.DeviceMeta) (int64, error) { + id, _, apiErr := a.svc.RegisterIfAbsent(ctx, devices.RegisterInput{ + UserID: userID, + DeviceUUID: meta.DeviceID, + Name: meta.Name, + Platform: meta.Platform, + ClientVersion: meta.ClientVersion, + MaxDevices: 0, + }) + if apiErr != nil { + return 0, apiErr } - return nil + return id, nil } diff --git a/server/internal/auth/device_register_test.go b/server/internal/auth/device_register_test.go index e3053d7..8c4ae69 100644 --- a/server/internal/auth/device_register_test.go +++ b/server/internal/auth/device_register_test.go @@ -11,15 +11,16 @@ type fakeRegistrar struct { userID int64 meta DeviceMeta } - err error + deviceID int64 + err error } -func (f *fakeRegistrar) RegisterDevice(_ context.Context, userID int64, meta DeviceMeta) error { +func (f *fakeRegistrar) RegisterDevice(_ context.Context, userID int64, meta DeviceMeta) (int64, error) { f.calls = append(f.calls, struct { userID int64 meta DeviceMeta }{userID, meta}) - return f.err + return f.deviceID, f.err } // Register/Login with a device should trigger RegisterDevice with the meta. @@ -36,7 +37,7 @@ func TestService_RegisterDevice_OnRegisterAndLogin(t *testing.T) { t.Fatalf("SendCode: %v", err) } code := codeInRedis(t, svc, email) - if _, e := svc.Register(ctx, email, code, pw, meta); e != nil { + if _, e := svc.Register(ctx, email, code, pw, "1.2.3.4", meta); e != nil { t.Fatalf("Register: %v", e) } if len(reg.calls) != 1 || reg.calls[0].meta.DeviceID != "dev-uuid-1" || reg.calls[0].meta.Platform != "macos" { @@ -64,7 +65,7 @@ func TestService_RegisterDevice_BestEffort(t *testing.T) { t.Fatalf("SendCode: %v", err) } code := codeInRedis(t, svc, email) - if _, e := svc.Register(ctx, email, code, "supersecret", DeviceMeta{DeviceID: "x", Platform: "windows"}); e != nil { + if _, e := svc.Register(ctx, email, code, "supersecret", "", DeviceMeta{DeviceID: "x", Platform: "windows"}); e != nil { t.Fatalf("Register must succeed despite registrar error: %v", e) } } @@ -80,7 +81,7 @@ func TestService_RegisterDevice_NoMeta(t *testing.T) { t.Fatalf("SendCode: %v", err) } code := codeInRedis(t, svc, email) - if _, e := svc.Register(ctx, email, code, "supersecret", DeviceMeta{}); e != nil { + if _, e := svc.Register(ctx, email, code, "supersecret", "", DeviceMeta{}); e != nil { t.Fatalf("Register: %v", e) } if len(reg.calls) != 0 { diff --git a/server/internal/auth/handler.go b/server/internal/auth/handler.go index e71349e..4f7f815 100644 --- a/server/internal/auth/handler.go +++ b/server/internal/auth/handler.go @@ -102,7 +102,7 @@ func (h *Handler) Register(w http.ResponseWriter, r *http.Request) { if !decodeJSON(w, r, &req) { return } - pair, apiErr := h.svc.Register(r.Context(), req.Email, req.Code, req.Password, req.Device.toMeta()) + pair, apiErr := h.svc.Register(r.Context(), req.Email, req.Code, req.Password, clientIP(r), req.Device.toMeta()) if apiErr != nil { writeAPIErr(w, apiErr, 0) return diff --git a/server/internal/auth/service.go b/server/internal/auth/service.go index 9e72add..74910f2 100644 --- a/server/internal/auth/service.go +++ b/server/internal/auth/service.go @@ -86,40 +86,61 @@ type DeviceMeta struct { ClientVersion string // app version (stored from P2 onward) } -// DeviceRegistrar registers the logging-in device. Defined consumer-side to -// avoid an import cycle; devices.Service is adapted to it in main wiring. -// Registration is best-effort and must never block login (see registerDevice). +// DeviceRegistrar registers the logging-in device and returns its internal id +// (for session binding). Defined consumer-side to avoid an import cycle; +// devices.Service is adapted to it in main wiring. Best-effort (see recordLogin). type DeviceRegistrar interface { - RegisterDevice(ctx context.Context, userID int64, meta DeviceMeta) error + RegisterDevice(ctx context.Context, userID int64, meta DeviceMeta) (deviceID int64, err error) +} + +// SessionStore persists login sessions bound to a refresh JTI. Satisfied by +// sessions.Store; injected so auth stays decoupled. +type SessionStore interface { + Create(ctx context.Context, userID, deviceID int64, jti, ip, clientVersion string) error + Rotate(ctx context.Context, oldJTI, newJTI string) error + Revoke(ctx context.Context, jti string) error } // Service is the auth business layer: code issuance, registration, login, and // token refresh. It is safe for concurrent use. type Service struct { - store UserStore - rdb *redis.Client - rl *RateLimiter - tokens *TokenManager - mailer Mailer - cfg ServiceConfig - now func() time.Time - devReg DeviceRegistrar // nil until wired; registration is best-effort + store UserStore + rdb *redis.Client + rl *RateLimiter + tokens *TokenManager + mailer Mailer + cfg ServiceConfig + now func() time.Time + devReg DeviceRegistrar // nil until wired; registration is best-effort + sessions SessionStore // nil until wired; session recording is best-effort } -// SetDeviceRegistrar wires the device registrar after construction (main keeps -// auth and devices decoupled). Safe to call once during startup. +// SetDeviceRegistrar / SetSessionStore wire collaborators after construction +// (main keeps auth, devices and sessions decoupled). Call once during startup. func (s *Service) SetDeviceRegistrar(r DeviceRegistrar) { s.devReg = r } +func (s *Service) SetSessionStore(st SessionStore) { s.sessions = st } -// registerDevice records the logging-in device. Best-effort: a registrar error -// (device cap, transient DB) is logged but never fails the login/registration — -// the user must always be able to get in (notably: free-plan reinstall churns -// the device UUID, so a hard cap here would lock users out). -func (s *Service) registerDevice(ctx context.Context, userID int64, meta DeviceMeta) { - if s.devReg == nil || meta.DeviceID == "" { +// recordLogin registers the device and binds a session to the freshly-issued +// refresh JTI. Best-effort: a registrar/session error (device cap, transient DB) +// is logged but never fails login — the user must always be able to get in +// (notably: free-plan reinstall churns the device UUID, so a hard cap here would +// lock users out). +func (s *Service) recordLogin(ctx context.Context, userID int64, refreshJTI, ip string, meta DeviceMeta) { + if meta.DeviceID == "" { return } - if err := s.devReg.RegisterDevice(ctx, userID, meta); err != nil { - slog.Warn("auth: device register failed (login proceeds)", "uid", userID, "err", err) + var deviceID int64 + if s.devReg != nil { + id, err := s.devReg.RegisterDevice(ctx, userID, meta) + if err != nil { + slog.Warn("auth: device register failed (login proceeds)", "uid", userID, "err", err) + } + deviceID = id + } + if s.sessions != nil && deviceID > 0 && refreshJTI != "" { + if err := s.sessions.Create(ctx, userID, deviceID, refreshJTI, ip, meta.ClientVersion); err != nil { + slog.Warn("auth: session create failed (login proceeds)", "uid", userID, "err", err) + } } } @@ -218,7 +239,7 @@ func (s *Service) SendCode(ctx context.Context, rawEmail, ip string) (retryAfter // Register verifies the code (one-time), creates the account plus a 7-day PRO // trial in a single transaction, and returns a fresh token pair. -func (s *Service) Register(ctx context.Context, rawEmail, code, password string, device DeviceMeta) (*TokenPair, *apierr.Error) { +func (s *Service) Register(ctx context.Context, rawEmail, code, password, ip string, device DeviceMeta) (*TokenPair, *apierr.Error) { email := NormalizeEmail(rawEmail) if !ValidEmail(email) || len(password) < 8 || len(code) != 6 { return nil, ErrInvalidRequest @@ -247,11 +268,11 @@ func (s *Service) Register(ctx context.Context, rawEmail, code, password string, return nil, ErrInternal } - pair, err := s.tokens.Issue(ctx, user.ID, user.UUID) + pair, jti, err := s.tokens.IssueWithJTI(ctx, user.ID, user.UUID) if err != nil { return nil, ErrInternal } - s.registerDevice(ctx, user.ID, device) + s.recordLogin(ctx, user.ID, jti, ip, device) return pair, nil } @@ -308,7 +329,6 @@ const ( ) func (s *Service) Login(ctx context.Context, rawEmail, password, ip string, device DeviceMeta) (*LoginOutcome, time.Duration, *apierr.Error) { - _ = ip // IP reserved for future per-IP login throttling; not logged. email := NormalizeEmail(rawEmail) if email == "" || password == "" { return nil, 0, ErrInvalidRequest @@ -360,11 +380,11 @@ func (s *Service) Login(ctx context.Context, rawEmail, password, ip string, devi return &LoginOutcome{PendingToken: pending}, 0, nil } - pair, err := s.tokens.Issue(ctx, user.ID, user.UUID) + pair, jti, err := s.tokens.IssueWithJTI(ctx, user.ID, user.UUID) if err != nil { return nil, 0, ErrInternal } - s.registerDevice(ctx, user.ID, device) + s.recordLogin(ctx, user.ID, jti, ip, device) return &LoginOutcome{Tokens: pair}, 0, nil } @@ -374,15 +394,22 @@ func (s *Service) Logout(ctx context.Context, refreshToken string) { if refreshToken == "" { return } + // Mark the bound session revoked before dropping the Redis whitelist entry. + if s.sessions != nil { + if jti, err := s.tokens.ParseRefreshJTI(refreshToken); err == nil { + _ = s.sessions.Revoke(ctx, jti) + } + } _ = s.tokens.RevokeRefresh(ctx, refreshToken) } -// Refresh validates and rotates a refresh token. +// Refresh validates and rotates a refresh token, moving the bound session to the +// new JTI. func (s *Service) Refresh(ctx context.Context, refreshToken string) (*TokenPair, *apierr.Error) { if refreshToken == "" { return nil, ErrInvalidRequest } - pair, err := s.tokens.Refresh(ctx, refreshToken) + pair, oldJTI, newJTI, err := s.tokens.RefreshWithJTI(ctx, refreshToken) if err != nil { if errors.Is(err, ErrInvalidTokenSentinel) { return nil, ErrInvalidToken @@ -390,6 +417,9 @@ func (s *Service) Refresh(ctx context.Context, refreshToken string) (*TokenPair, // Parse / signature / expiry failures all map to an opaque invalid-token. return nil, ErrInvalidToken } + if s.sessions != nil { + _ = s.sessions.Rotate(ctx, oldJTI, newJTI) + } return pair, nil } diff --git a/server/internal/auth/service_test.go b/server/internal/auth/service_test.go index 64b776f..33edc61 100644 --- a/server/internal/auth/service_test.go +++ b/server/internal/auth/service_test.go @@ -40,7 +40,7 @@ func TestService_RegisterFullFlow(t *testing.T) { } code := codeInRedis(t, svc, email) - pair, apiErr := svc.Register(ctx, email, code, "supersecret", DeviceMeta{}) + pair, apiErr := svc.Register(ctx, email, code, "supersecret", "", DeviceMeta{}) if apiErr != nil { t.Fatalf("Register: %v", apiErr) } @@ -84,7 +84,7 @@ func TestService_DuplicateEmailConflict(t *testing.T) { // First registration. _, _ = svc.SendCode(ctx, email, "") - if _, e := svc.Register(ctx, email, codeInRedis(t, svc, email), "password1", DeviceMeta{}); e != nil { + if _, e := svc.Register(ctx, email, codeInRedis(t, svc, email), "password1", "", DeviceMeta{}); e != nil { t.Fatalf("first register: %v", e) } @@ -103,7 +103,7 @@ func TestService_DuplicateEmailConflict(t *testing.T) { if err := svc.rdb.Set(ctx, codeKey(email), "654321", 10*time.Minute).Err(); err != nil { t.Fatalf("force code: %v", err) } - _, apiErr := svc.Register(ctx, email, "654321", "password2", DeviceMeta{}) + _, apiErr := svc.Register(ctx, email, "654321", "password2", "", DeviceMeta{}) if apiErr == nil || apiErr.Code != ErrCodeInvalid.Code { t.Fatalf("want code_invalid (anti-enumeration), got %v", apiErr) } @@ -115,7 +115,7 @@ func TestService_CodeWrong(t *testing.T) { const email = "wrong@example.com" _, _ = svc.SendCode(ctx, email, "") - _, apiErr := svc.Register(ctx, email, "000000", "password1", DeviceMeta{}) + _, apiErr := svc.Register(ctx, email, "000000", "password1", "", DeviceMeta{}) if apiErr == nil || apiErr.Code != ErrCodeInvalid.Code { t.Fatalf("want code_invalid, got %v", apiErr) } @@ -131,7 +131,7 @@ func TestService_CodeExpired(t *testing.T) { // Expire the code key. svc.rdb.Del(ctx, codeKey(email)) - _, apiErr := svc.Register(ctx, email, code, "password1", DeviceMeta{}) + _, apiErr := svc.Register(ctx, email, code, "password1", "", DeviceMeta{}) if apiErr == nil || apiErr.Code != ErrCodeInvalid.Code { t.Fatalf("want code_invalid after expiry, got %v", apiErr) } @@ -144,11 +144,11 @@ func TestService_CodeReuseRejected(t *testing.T) { _, _ = svc.SendCode(ctx, email, "") code := codeInRedis(t, svc, email) - if _, e := svc.Register(ctx, email, code, "password1", DeviceMeta{}); e != nil { + if _, e := svc.Register(ctx, email, code, "password1", "", DeviceMeta{}); e != nil { t.Fatalf("first register: %v", e) } // Re-using the consumed code must fail. - _, apiErr := svc.Register(ctx, "other@example.com", code, "password1", DeviceMeta{}) + _, apiErr := svc.Register(ctx, "other@example.com", code, "password1", "", DeviceMeta{}) if apiErr == nil || apiErr.Code != ErrCodeInvalid.Code { t.Fatalf("want code_invalid on reuse, got %v", apiErr) } @@ -163,12 +163,12 @@ func TestService_CodeBruteForceBurned(t *testing.T) { // 3 wrong attempts burn the code. for i := 0; i < 3; i++ { - if _, e := svc.Register(ctx, email, "999999", "password1", DeviceMeta{}); e == nil { + if _, e := svc.Register(ctx, email, "999999", "password1", "", DeviceMeta{}); e == nil { t.Fatal("wrong code should fail") } } // Even the correct code no longer works. - if _, e := svc.Register(ctx, email, good, "password1", DeviceMeta{}); e == nil || e.Code != ErrCodeInvalid.Code { + if _, e := svc.Register(ctx, email, good, "password1", "", DeviceMeta{}); e == nil || e.Code != ErrCodeInvalid.Code { t.Fatalf("burned code should reject correct value, got %v", e) } } @@ -205,7 +205,7 @@ func TestService_LoginAndLockout(t *testing.T) { const pw = "rightpassword" _, _ = svc.SendCode(ctx, email, "") - if _, e := svc.Register(ctx, email, codeInRedis(t, svc, email), pw, DeviceMeta{}); e != nil { + if _, e := svc.Register(ctx, email, codeInRedis(t, svc, email), pw, "", DeviceMeta{}); e != nil { t.Fatalf("register: %v", e) } @@ -247,7 +247,7 @@ func TestService_BannedUserRejected(t *testing.T) { const pw = "password1" _, _ = svc.SendCode(ctx, email, "") - if _, e := svc.Register(ctx, email, codeInRedis(t, svc, email), pw, DeviceMeta{}); e != nil { + if _, e := svc.Register(ctx, email, codeInRedis(t, svc, email), pw, "", DeviceMeta{}); e != nil { t.Fatalf("register: %v", e) } store.setStatus(email, "banned") @@ -264,7 +264,7 @@ func TestService_RefreshRotation(t *testing.T) { const email = "refresh@example.com" _, _ = svc.SendCode(ctx, email, "") - pair, e := svc.Register(ctx, email, codeInRedis(t, svc, email), "password1", DeviceMeta{}) + pair, e := svc.Register(ctx, email, codeInRedis(t, svc, email), "password1", "", DeviceMeta{}) if e != nil { t.Fatalf("register: %v", e) } diff --git a/server/internal/auth/token.go b/server/internal/auth/token.go index 5727bc1..02d7538 100644 --- a/server/internal/auth/token.go +++ b/server/internal/auth/token.go @@ -113,27 +113,34 @@ func NewTokenManager(rdb *redis.Client, cfg TokenConfig) (*TokenManager, error) }, nil } -// Issue mints a fresh access+refresh pair for the user and whitelists the -// refresh JTI in Redis with the refresh TTL. -func (tm *TokenManager) Issue(ctx context.Context, userID int64, userUUID string) (*TokenPair, error) { +// IssueWithJTI mints a fresh access+refresh pair, whitelists the refresh JTI in +// Redis, and returns that JTI so the caller can bind a session to the refresh +// token. Issue wraps this when the JTI isn't needed. +func (tm *TokenManager) IssueWithJTI(ctx context.Context, userID int64, userUUID string) (*TokenPair, string, error) { now := tm.now() access, _, err := tm.sign(userID, userUUID, typAccess, tm.accessTTL, now) if err != nil { - return nil, err + return nil, "", err } refresh, refreshJTI, err := tm.sign(userID, userUUID, typRefresh, tm.refreshTTL, now) if err != nil { - return nil, err + return nil, "", err } if err := tm.whitelist(ctx, refreshJTI, userID); err != nil { - return nil, err + return nil, "", err } return &TokenPair{ AccessToken: access, RefreshToken: refresh, ExpiresIn: int(tm.accessTTL.Seconds()), - }, nil + }, refreshJTI, nil +} + +// Issue mints a fresh access+refresh pair (refresh JTI discarded). +func (tm *TokenManager) Issue(ctx context.Context, userID int64, userUUID string) (*TokenPair, error) { + pair, _, err := tm.IssueWithJTI(ctx, userID, userUUID) + return pair, err } // sign builds and signs one token, returning the compact string and its JTI. @@ -206,13 +213,13 @@ func (tm *TokenManager) ParseAccess(tokenStr string) (*Claims, error) { return tm.parse(tokenStr, typAccess) } -// Refresh validates a refresh token against the Redis whitelist, then rotates: -// the old JTI is deleted and a brand-new access+refresh pair is issued, so the -// presented refresh token can never be replayed. -func (tm *TokenManager) Refresh(ctx context.Context, refreshToken string) (*TokenPair, error) { +// RefreshWithJTI validates+rotates a refresh token (single-use) and returns the +// old and new refresh JTIs so the caller can move the bound session. Refresh +// wraps this when the JTIs aren't needed. +func (tm *TokenManager) RefreshWithJTI(ctx context.Context, refreshToken string) (pair *TokenPair, oldJTI, newJTI string, err error) { claims, err := tm.parse(refreshToken, typRefresh) if err != nil { - return nil, err + return nil, "", "", err } // Whitelist check + single-use rotation: DEL returns the number of keys @@ -220,13 +227,23 @@ func (tm *TokenManager) Refresh(ctx context.Context, refreshToken string) (*Toke // banned) → reject. removed, err := tm.rdb.Del(ctx, refreshKeyPrefix+claims.ID).Result() if err != nil { - return nil, fmt.Errorf("auth: refresh whitelist del: %w", err) + return nil, "", "", fmt.Errorf("auth: refresh whitelist del: %w", err) } if removed == 0 { - return nil, ErrInvalidTokenSentinel + return nil, "", "", ErrInvalidTokenSentinel } - return tm.Issue(ctx, claims.UID, claims.Subject) + pair, newJTI, err = tm.IssueWithJTI(ctx, claims.UID, claims.Subject) + if err != nil { + return nil, "", "", err + } + return pair, claims.ID, newJTI, nil +} + +// Refresh validates+rotates a refresh token (JTIs discarded). +func (tm *TokenManager) Refresh(ctx context.Context, refreshToken string) (*TokenPair, error) { + pair, _, _, err := tm.RefreshWithJTI(ctx, refreshToken) + return pair, err } // Revoke removes a single refresh JTI from the whitelist (logout). @@ -234,6 +251,15 @@ func (tm *TokenManager) Revoke(ctx context.Context, jti string) error { return tm.rdb.Del(ctx, refreshKeyPrefix+jti).Err() } +// ParseRefreshJTI returns a refresh token's JTI (for session lookup on logout). +func (tm *TokenManager) ParseRefreshJTI(refreshToken string) (string, error) { + claims, err := tm.parse(refreshToken, typRefresh) + if err != nil { + return "", err + } + return claims.ID, nil +} + // RevokeRefresh parses a refresh token and revokes its JTI. Used by logout. // Returns an error only when the token is structurally invalid; a missing or // already-rotated JTI is a no-op (logout is idempotent). diff --git a/server/internal/devices/devices_integration_test.go b/server/internal/devices/devices_integration_test.go index a82f02f..da671a3 100644 --- a/server/internal/devices/devices_integration_test.go +++ b/server/internal/devices/devices_integration_test.go @@ -187,7 +187,7 @@ func TestFullChain(t *testing.T) { in := devices.RegisterInput{UserID: userID, DeviceUUID: devUUID, Name: "iPhone 15 Pro", Platform: "ios", MaxDevices: plan.MaxDevices} // First sight → insert. - d1, apiErr := svc.RegisterIfAbsent(ctx, in) + _, d1, apiErr := svc.RegisterIfAbsent(ctx, in) if apiErr != nil { t.Fatalf("RegisterIfAbsent: %v", apiErr) } @@ -196,7 +196,7 @@ func TestFullChain(t *testing.T) { } // Second sight → idempotent (no new row), last_seen refreshed. - if _, apiErr := svc.RegisterIfAbsent(ctx, in); apiErr != nil { + if _, _, apiErr := svc.RegisterIfAbsent(ctx, in); apiErr != nil { t.Fatalf("re-register: %v", apiErr) } list, apiErr := svc.ListDevices(ctx, userID) @@ -245,12 +245,12 @@ func TestDeviceLimitEnforced(t *testing.T) { } first := devices.RegisterInput{UserID: userID, DeviceUUID: newUUID(t, db), Name: "Pixel", Platform: "android", MaxDevices: 1} - if _, apiErr := svc.RegisterIfAbsent(ctx, first); apiErr != nil { + if _, _, apiErr := svc.RegisterIfAbsent(ctx, first); apiErr != nil { t.Fatalf("first register: %v", apiErr) } second := devices.RegisterInput{UserID: userID, DeviceUUID: newUUID(t, db), Name: "iPad", Platform: "ios", MaxDevices: 1} - _, apiErr := svc.RegisterIfAbsent(ctx, second) + _, _, apiErr := svc.RegisterIfAbsent(ctx, second) if apiErr == nil { t.Fatal("expected second register to be rejected") } @@ -269,7 +269,7 @@ func TestDeleteOthersDevice(t *testing.T) { other := createUser(t, db, "other@example.com", "active") devUUID := newUUID(t, db) - if _, apiErr := svc.RegisterIfAbsent(ctx, devices.RegisterInput{ + if _, _, apiErr := svc.RegisterIfAbsent(ctx, devices.RegisterInput{ UserID: owner, DeviceUUID: devUUID, Name: "Mac", Platform: "macos", MaxDevices: 5, }); apiErr != nil { t.Fatalf("register: %v", apiErr) @@ -308,7 +308,7 @@ func TestHTTPHandlers(t *testing.T) { giveSubscription(t, db, userID, "pro", "code", time.Now().UTC().Add(30*24*time.Hour)) devUUID := newUUID(t, db) - if _, apiErr := svc.RegisterIfAbsent(ctx, devices.RegisterInput{ + if _, _, apiErr := svc.RegisterIfAbsent(ctx, devices.RegisterInput{ UserID: userID, DeviceUUID: devUUID, Name: "Win", Platform: "windows", MaxDevices: 5, }); apiErr != nil { t.Fatalf("register: %v", apiErr) diff --git a/server/internal/devices/service.go b/server/internal/devices/service.go index 9b6d0eb..91ceb6a 100644 --- a/server/internal/devices/service.go +++ b/server/internal/devices/service.go @@ -43,10 +43,17 @@ func (n *NoopRevoker) RevokeForUser(_ context.Context, userID int64, reason stri return nil } +// LastLoginSource provides per-device last-login times (most recent session +// created_at). Satisfied by sessions.Store; injected to keep packages decoupled. +type LastLoginSource interface { + LastLoginByDevice(ctx context.Context, userID int64) (map[int64]time.Time, error) +} + // Service implements the devices business logic. type Service struct { - store *Store - revoker CredentialRevoker + store *Store + revoker CredentialRevoker + lastLogin LastLoginSource // nil until wired (P2) } // NewService creates a Service. If revoker is nil a NoopRevoker is used so the @@ -58,32 +65,58 @@ func NewService(store *Store, revoker CredentialRevoker) *Service { return &Service{store: store, revoker: revoker} } +// SetLastLoginSource wires the per-device last-login source (sessions.Store). +func (svc *Service) SetLastLoginSource(s LastLoginSource) { svc.lastLogin = s } + // Device is the API representation of a registered device. type Device struct { - UUID string `json:"uuid"` - Name string `json:"name"` - Platform string `json:"platform"` - LastSeen *string `json:"last_seen"` // RFC 3339 UTC; null when never seen + UUID string `json:"uuid"` + Name string `json:"name"` + Platform string `json:"platform"` + LastSeen *string `json:"last_seen"` // RFC 3339 UTC; null when never seen + ClientVersion string `json:"client_version,omitempty"` // 000016 + Online bool `json:"online"` // last_seen within onlineWindow (data-plane active) + LastLogin *string `json:"last_login"` // most recent session created_at; null when none } +// onlineWindow: a device is "online" if its last_seen (touched by connect + +// periodic usage reports) is within this window. +const onlineWindow = 3 * time.Minute + func toAPIDevice(d DeviceRow) Device { out := Device{UUID: d.UUID, Name: d.Name, Platform: d.Platform} if d.LastSeen.Valid { s := d.LastSeen.Time.UTC().Format(time.RFC3339) out.LastSeen = &s + out.Online = time.Since(d.LastSeen.Time) < onlineWindow + } + if d.ClientVersion.Valid { + out.ClientVersion = d.ClientVersion.String } return out } -// ListDevices returns the user's devices. +// ListDevices returns the user's devices with online status (from last_seen) and +// last-login time (most recent session, when a LastLoginSource is wired). func (svc *Service) ListDevices(ctx context.Context, userID int64) ([]Device, *apierr.Error) { rows, err := svc.store.ListByUser(ctx, userID) if err != nil { return nil, apierr.ErrInternal } + var lastLogin map[int64]time.Time + if svc.lastLogin != nil { + if m, e := svc.lastLogin.LastLoginByDevice(ctx, userID); e == nil { + lastLogin = m + } + } out := make([]Device, 0, len(rows)) for _, r := range rows { - out = append(out, toAPIDevice(r)) + d := toAPIDevice(r) + if t, ok := lastLogin[r.ID]; ok { + s := t.UTC().Format(time.RFC3339) + d.LastLogin = &s + } + out = append(out, d) } return out, nil } @@ -91,31 +124,33 @@ func (svc *Service) ListDevices(ctx context.Context, userID int64) ([]Device, *a // RegisterInput carries the inputs for implicit device registration, called by // nodes.connect when a client presents a device_id. type RegisterInput struct { - UserID int64 - DeviceUUID string - Name string // client-reported, may come from UA; truncated to 64 runes - Platform string // ios | android | windows | macos - MaxDevices int // plan cap, from the resolved Plan (PlanFromCtx) + UserID int64 + DeviceUUID string + Name string // client-reported, may come from UA; truncated to 64 runes + Platform string // ios | android | windows | macos | linux + ClientVersion string // app version, stored/refreshed on devices.client_version + MaxDevices int // plan cap; 0 = no cap } // RegisterIfAbsent registers a device on first sight and refreshes last_seen on // subsequent sights. The device count is checked against MaxDevices before // inserting a brand-new device. Per-user serialization is achieved by locking // the users row for the duration of the transaction. -func (svc *Service) RegisterIfAbsent(ctx context.Context, in RegisterInput) (*Device, *apierr.Error) { +// Returns the internal device id (for session binding), the API device, or an error. +func (svc *Service) RegisterIfAbsent(ctx context.Context, in RegisterInput) (int64, *Device, *apierr.Error) { uuid := strings.TrimSpace(in.DeviceUUID) if uuid == "" { - return nil, apierr.ErrBadRequest + return 0, nil, apierr.ErrBadRequest } platform, ok := normalizePlatform(in.Platform) if !ok { - return nil, apierr.ErrBadRequest + return 0, nil, apierr.ErrBadRequest } name := normalizeName(in.Name, platform) tx, err := svc.store.BeginTx(ctx) if err != nil { - return nil, apierr.ErrInternal + return 0, nil, apierr.ErrInternal } committed := false defer func() { @@ -127,48 +162,48 @@ func (svc *Service) RegisterIfAbsent(ctx context.Context, in RegisterInput) (*De // Lock the owning user to serialize concurrent registrations. exists, status, err := svc.store.lockUser(ctx, tx, in.UserID) if err != nil { - return nil, apierr.ErrInternal + return 0, nil, apierr.ErrInternal } if !exists { - return nil, apierr.ErrUnauthorized + return 0, nil, apierr.ErrUnauthorized } if status == "banned" { - return nil, apierr.ErrAccountBanned + return 0, nil, apierr.ErrAccountBanned } existing, err := svc.store.findDeviceByUUIDTx(ctx, tx, uuid) if err != nil { - return nil, apierr.ErrInternal + return 0, nil, apierr.ErrInternal } if existing != nil { if existing.UserID != in.UserID { // UUID is client-generated; a collision across users is treated as // a conflict rather than silently rebinding the device. - return nil, apierr.ErrForbidden + return 0, nil, apierr.ErrForbidden } - if err := svc.store.touchLastSeenTx(ctx, tx, existing.ID); err != nil { - return nil, apierr.ErrInternal + if err := svc.store.touchLastSeenTx(ctx, tx, existing.ID, in.ClientVersion); err != nil { + return 0, nil, apierr.ErrInternal } if err := tx.Commit(); err != nil { - return nil, apierr.ErrInternal + return 0, nil, apierr.ErrInternal } committed = true d := toAPIDevice(*existing) - return &d, nil + return existing.ID, &d, nil } // New device: enforce the plan device cap. count, err := svc.store.countDevicesTx(ctx, tx, in.UserID) if err != nil { - return nil, apierr.ErrInternal + return 0, nil, apierr.ErrInternal } if in.MaxDevices > 0 && count >= in.MaxDevices { - return nil, errDeviceLimit(in.MaxDevices) + return 0, nil, errDeviceLimit(in.MaxDevices) } - row, err := svc.store.insertDeviceTx(ctx, tx, uuid, in.UserID, name, platform) + row, err := svc.store.insertDeviceTx(ctx, tx, uuid, in.UserID, name, platform, in.ClientVersion) if err != nil { - return nil, apierr.ErrInternal + return 0, nil, apierr.ErrInternal } if err := svc.store.writeAuditLogTx(ctx, tx, fmt.Sprintf("user:%d", in.UserID), "device.register", "device:"+uuid, @@ -176,12 +211,12 @@ func (svc *Service) RegisterIfAbsent(ctx context.Context, in RegisterInput) (*De _ = err // audit failure must not abort the business transaction } if err := tx.Commit(); err != nil { - return nil, apierr.ErrInternal + return 0, nil, apierr.ErrInternal } committed = true d := toAPIDevice(*row) - return &d, nil + return row.ID, &d, nil } // DeleteDevice hard-deletes a device (transactionally, with an audit_log entry) diff --git a/server/internal/devices/store.go b/server/internal/devices/store.go index cc3fcea..94f8c5c 100644 --- a/server/internal/devices/store.go +++ b/server/internal/devices/store.go @@ -11,13 +11,14 @@ import ( // DeviceRow mirrors a `devices` table row. type DeviceRow struct { - ID int64 - UUID string - UserID int64 - Name string - Platform string - LastSeen sql.NullTime - CreatedAt time.Time + ID int64 + UUID string + UserID int64 + Name string + Platform string + LastSeen sql.NullTime + CreatedAt time.Time + ClientVersion sql.NullString // 000016: latest reported app version } // effSub is an active-or-expired subscription joined with its plan, used by the @@ -56,7 +57,7 @@ func (s *Store) BeginTx(ctx context.Context) (*sql.Tx, error) { // ListByUser returns all devices for userID ordered by creation time. func (s *Store) ListByUser(ctx context.Context, userID int64) ([]DeviceRow, error) { rows, err := s.db.QueryContext(ctx, - `SELECT id, uuid, user_id, name, platform, last_seen, created_at + `SELECT id, uuid, user_id, name, platform, last_seen, created_at, client_version FROM devices WHERE user_id=? ORDER BY created_at ASC`, userID) if err != nil { @@ -67,7 +68,7 @@ func (s *Store) ListByUser(ctx context.Context, userID int64) ([]DeviceRow, erro var out []DeviceRow for rows.Next() { var d DeviceRow - if err := rows.Scan(&d.ID, &d.UUID, &d.UserID, &d.Name, &d.Platform, &d.LastSeen, &d.CreatedAt); err != nil { + if err := rows.Scan(&d.ID, &d.UUID, &d.UserID, &d.Name, &d.Platform, &d.LastSeen, &d.CreatedAt, &d.ClientVersion); err != nil { return nil, fmt.Errorf("store.ListByUser scan: %w", err) } out = append(out, d) @@ -112,12 +113,13 @@ func (s *Store) countDevicesTx(ctx context.Context, tx *sql.Tx, userID int64) (i } // insertDeviceTx inserts a new device row inside tx and returns it. -func (s *Store) insertDeviceTx(ctx context.Context, tx *sql.Tx, uuid string, userID int64, name, platform string) (*DeviceRow, error) { +func (s *Store) insertDeviceTx(ctx context.Context, tx *sql.Tx, uuid string, userID int64, name, platform, clientVersion string) (*DeviceRow, error) { now := time.Now().UTC() + cv := nullStr(clientVersion) res, err := tx.ExecContext(ctx, - `INSERT INTO devices (uuid, user_id, name, platform, last_seen, created_at) - VALUES (?, ?, ?, ?, ?, ?)`, - uuid, userID, name, platform, now, now) + `INSERT INTO devices (uuid, user_id, name, platform, last_seen, created_at, client_version) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + uuid, userID, name, platform, now, now, cv) if err != nil { return nil, fmt.Errorf("store.insertDeviceTx: %w", err) } @@ -126,21 +128,38 @@ func (s *Store) insertDeviceTx(ctx context.Context, tx *sql.Tx, uuid string, use // 设备 API 响应 last_seen=null 与库不一致(集成测试 TestFullChain 据此把关)。 return &DeviceRow{ ID: id, UUID: uuid, UserID: userID, Name: name, Platform: platform, - LastSeen: sql.NullTime{Time: now, Valid: true}, - CreatedAt: now, + LastSeen: sql.NullTime{Time: now, Valid: true}, + CreatedAt: now, + ClientVersion: sql.NullString{String: clientVersion, Valid: clientVersion != ""}, }, nil } -// touchLastSeenTx updates a device's last_seen to now inside tx. -func (s *Store) touchLastSeenTx(ctx context.Context, tx *sql.Tx, deviceID int64) error { - _, err := tx.ExecContext(ctx, - `UPDATE devices SET last_seen=? WHERE id=?`, time.Now().UTC(), deviceID) +// touchLastSeenTx updates a device's last_seen to now inside tx, and refreshes +// client_version when a non-empty one is supplied (latest reported wins). +func (s *Store) touchLastSeenTx(ctx context.Context, tx *sql.Tx, deviceID int64, clientVersion string) error { + now := time.Now().UTC() + var err error + if clientVersion != "" { + _, err = tx.ExecContext(ctx, + `UPDATE devices SET last_seen=?, client_version=? WHERE id=?`, now, clientVersion, deviceID) + } else { + _, err = tx.ExecContext(ctx, + `UPDATE devices SET last_seen=? WHERE id=?`, now, deviceID) + } if err != nil { return fmt.Errorf("store.touchLastSeenTx: %w", err) } return nil } +// nullStr maps "" to SQL NULL. +func nullStr(s string) any { + if s == "" { + return nil + } + return s +} + // deleteDeviceTx hard-deletes a device row inside tx. func (s *Store) deleteDeviceTx(ctx context.Context, tx *sql.Tx, deviceID int64) error { if _, err := tx.ExecContext(ctx, `DELETE FROM devices WHERE id=?`, deviceID); err != nil { diff --git a/server/internal/nodes/grpc_test.go b/server/internal/nodes/grpc_test.go index 664fd38..4eab717 100644 --- a/server/internal/nodes/grpc_test.go +++ b/server/internal/nodes/grpc_test.go @@ -40,6 +40,7 @@ type mockNodeStore struct { // devicesByDpUUID maps a per-device dp_uuid → {userID, deviceID}. devicesByDpUUID map[string][2]int64 deviceUsageAccum []mockDeviceUsageEntry + lastSeenTouched []int64 } type mockUsageEntry struct { @@ -147,6 +148,13 @@ func (m *mockNodeStore) AccumulateDeviceUsage(_ context.Context, userID, deviceI return nil } +func (m *mockNodeStore) TouchDeviceLastSeen(_ context.Context, deviceID int64) error { + m.mu.Lock() + defer m.mu.Unlock() + m.lastSeenTouched = append(m.lastSeenTouched, deviceID) + return nil +} + func (m *mockNodeStore) deviceUsageLog() []mockDeviceUsageEntry { m.mu.Lock() defer m.mu.Unlock() diff --git a/server/internal/nodes/handler_grpc.go b/server/internal/nodes/handler_grpc.go index 54669be..194776b 100644 --- a/server/internal/nodes/handler_grpc.go +++ b/server/internal/nodes/handler_grpc.go @@ -318,6 +318,11 @@ func (h *Handler) ReportUsage(ctx context.Context, req *agentv1.UsageReport) (*a slog.Warn("nodes.Handler.ReportUsage: device accumulate failed", "user_id", userID, "device_id", deviceID, "err", err) } + // Heartbeat: keep the device's "online" status fresh while it reports. + if err := h.store.TouchDeviceLastSeen(ctx, deviceID); err != nil { + slog.Warn("nodes.Handler.ReportUsage: touch last_seen failed", + "device_id", deviceID, "err", err) + } } } return &agentv1.UsageAck{}, nil diff --git a/server/internal/nodes/store.go b/server/internal/nodes/store.go index 09c651e..69e468d 100644 --- a/server/internal/nodes/store.go +++ b/server/internal/nodes/store.go @@ -98,6 +98,10 @@ type NodeStore interface { // (deviceID, date); user_id is carried for per-account rollups/queries. AccumulateDeviceUsage(ctx context.Context, userID, deviceID int64, date time.Time, bytesUp, bytesDown int64, minutes int64) error + + // TouchDeviceLastSeen bumps devices.last_seen to now for an active device, + // driving the "online" status (connect + periodic usage reports refresh it). + TouchDeviceLastSeen(ctx context.Context, deviceID int64) error } // SQLNodeStore implements NodeStore against a SQL database (MySQL or SQLite). @@ -396,6 +400,16 @@ func (s *SQLNodeStore) AccumulateDeviceUsage( return nil } +// TouchDeviceLastSeen bumps devices.last_seen to now (online-status heartbeat). +func (s *SQLNodeStore) TouchDeviceLastSeen(ctx context.Context, deviceID int64) error { + if _, err := s.db.ExecContext(ctx, + `UPDATE devices SET last_seen=? WHERE id=?`, time.Now().UTC(), deviceID, + ); err != nil { + return fmt.Errorf("nodes.SQLNodeStore.TouchDeviceLastSeen: %w", err) + } + return nil +} + // AccountDayBytes returns the account's total bytes (up+down) for the day. func (s *SQLNodeStore) AccountDayBytes(ctx context.Context, userID int64, date time.Time) (int64, error) { var total sql.NullInt64 diff --git a/server/internal/sessions/store.go b/server/internal/sessions/store.go new file mode 100644 index 0000000..ae02c14 --- /dev/null +++ b/server/internal/sessions/store.go @@ -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 +} diff --git a/server/internal/store/devices_list_test.go b/server/internal/store/devices_list_test.go new file mode 100644 index 0000000..dee57cc --- /dev/null +++ b/server/internal/store/devices_list_test.go @@ -0,0 +1,50 @@ +package store_test + +import ( + "context" + "testing" + "time" + + "github.com/wangjia/pangolin/server/internal/devices" + "github.com/wangjia/pangolin/server/internal/sessions" +) + +// ListDevices should derive online (last_seen<3min), client_version, and +// last_login (most recent session) per device. +func TestSQLite_ListDevices_OnlineAndLastLogin(t *testing.T) { + ctx := context.Background() + db := openSQLite(t) + if _, err := db.Exec( + `INSERT INTO users (id, uuid, email, pw_hash, dp_uuid, status) VALUES (1,'u','u@e.com','h','dp','active')`); err != nil { + t.Fatalf("seed user: %v", err) + } + now := time.Now().UTC() + old := now.Add(-time.Hour) + // device 10: online (last_seen now) + client_version; device 20: offline. + db.Exec(`INSERT INTO devices (id, uuid, user_id, name, platform, last_seen, client_version) VALUES (10,'d10',1,'Mac','macos',?,?)`, now, "v1.0.10") + db.Exec(`INSERT INTO devices (id, uuid, user_id, name, platform, last_seen) VALUES (20,'d20',1,'PC','windows',?)`, old) + // one login session for device 10. + db.Exec(`INSERT INTO sessions (user_id, device_id, refresh_jti, created_at) VALUES (1,10,'j',?)`, now) + + svc := devices.NewService(devices.NewStore(db), nil) + svc.SetLastLoginSource(sessions.NewStore(db)) + + list, apiErr := svc.ListDevices(ctx, 1) + if apiErr != nil { + t.Fatalf("ListDevices: %v", apiErr) + } + if len(list) != 2 { + t.Fatalf("want 2 devices, got %d", len(list)) + } + by := map[string]devices.Device{} + for _, d := range list { + by[d.UUID] = d + } + + if d := by["d10"]; !d.Online || d.ClientVersion != "v1.0.10" || d.LastLogin == nil { + t.Errorf("d10 wrong: online=%v ver=%q lastLogin=%v", d.Online, d.ClientVersion, d.LastLogin) + } + if d := by["d20"]; d.Online || d.LastLogin != nil { + t.Errorf("d20 wrong: online=%v lastLogin=%v", d.Online, d.LastLogin) + } +} diff --git a/server/internal/store/sessions_store_test.go b/server/internal/store/sessions_store_test.go new file mode 100644 index 0000000..3dd2965 --- /dev/null +++ b/server/internal/store/sessions_store_test.go @@ -0,0 +1,124 @@ +package store_test + +import ( + "context" + "database/sql" + "testing" + "time" + + "github.com/wangjia/pangolin/server/internal/sessions" +) + +// seedUserDevice inserts a user + device so sessions FKs are satisfiable. +func seedUserDevice(t *testing.T, db *sql.DB, userID, deviceID int64) { + t.Helper() + if _, err := db.Exec( + `INSERT INTO users (id, uuid, email, pw_hash, dp_uuid, status) VALUES (?,?,?,?,?,?)`, + userID, "u-uuid", "u@example.com", "h", "dp-uuid", "active"); err != nil { + t.Fatalf("seed user: %v", err) + } + if _, err := db.Exec( + `INSERT INTO devices (id, uuid, user_id, name, platform) VALUES (?,?,?,?,?)`, + deviceID, "d-uuid", userID, "MacBook", "macos"); err != nil { + t.Fatalf("seed device: %v", err) + } +} + +func TestSQLite_Sessions_CreateRotateRevoke(t *testing.T) { + ctx := context.Background() + db := openSQLite(t) + seedUserDevice(t, db, 1, 10) + ss := sessions.NewStore(db) + + if err := ss.Create(ctx, 1, 10, "jti-1", "1.2.3.4", "v1.0.10"); err != nil { + t.Fatalf("Create: %v", err) + } + var jti, ver, ip string + var revoked sql.NullTime + if err := db.QueryRow( + `SELECT refresh_jti, client_version, client_ip, revoked_at FROM sessions WHERE user_id=1`, + ).Scan(&jti, &ver, &ip, &revoked); err != nil { + t.Fatalf("read: %v", err) + } + if jti != "jti-1" || ver != "v1.0.10" || ip != "1.2.3.4" || revoked.Valid { + t.Fatalf("bad session: jti=%s ver=%s ip=%s revoked=%v", jti, ver, ip, revoked.Valid) + } + + // Rotate jti-1 → jti-2. + if err := ss.Rotate(ctx, "jti-1", "jti-2"); err != nil { + t.Fatalf("Rotate: %v", err) + } + var cnt int + db.QueryRow(`SELECT COUNT(*) FROM sessions WHERE refresh_jti='jti-2' AND revoked_at IS NULL`).Scan(&cnt) + if cnt != 1 { + t.Fatalf("rotate did not move jti: cnt=%d", cnt) + } + + // Revoke jti-2. + if err := ss.Revoke(ctx, "jti-2"); err != nil { + t.Fatalf("Revoke: %v", err) + } + db.QueryRow(`SELECT COUNT(*) FROM sessions WHERE refresh_jti='jti-2' AND revoked_at IS NOT NULL`).Scan(&cnt) + if cnt != 1 { + t.Fatalf("revoke did not set revoked_at") + } +} + +func TestSQLite_Sessions_RevokeByDevice(t *testing.T) { + ctx := context.Background() + db := openSQLite(t) + seedUserDevice(t, db, 1, 10) + ss := sessions.NewStore(db) + + for _, j := range []string{"a", "b", "c"} { + if err := ss.Create(ctx, 1, 10, j, "", ""); err != nil { + t.Fatalf("create %s: %v", j, err) + } + } + // Pre-revoke one so it isn't returned/double-counted. + if err := ss.Revoke(ctx, "c"); err != nil { + t.Fatalf("pre-revoke: %v", err) + } + + jtis, err := ss.RevokeByDevice(ctx, 1, 10) + if err != nil { + t.Fatalf("RevokeByDevice: %v", err) + } + if len(jtis) != 2 { + t.Fatalf("expected 2 active jtis revoked, got %v", jtis) + } + var active int + db.QueryRow(`SELECT COUNT(*) FROM sessions WHERE device_id=10 AND revoked_at IS NULL`).Scan(&active) + if active != 0 { + t.Fatalf("device still has %d active sessions", active) + } +} + +func TestSQLite_Sessions_LastLoginByDevice(t *testing.T) { + ctx := context.Background() + db := openSQLite(t) + seedUserDevice(t, db, 1, 10) + // second device + db.Exec(`INSERT INTO devices (id, uuid, user_id, name, platform) VALUES (?,?,?,?,?)`, + 20, "d-uuid-2", 1, "iPhone", "ios") + + older := time.Date(2026, 6, 1, 8, 0, 0, 0, time.UTC) + newer := time.Date(2026, 6, 28, 9, 0, 0, 0, time.UTC) + // device 10: two logins, newer should win. + db.Exec(`INSERT INTO sessions (user_id, device_id, refresh_jti, created_at) VALUES (1,10,'o',?)`, older) + db.Exec(`INSERT INTO sessions (user_id, device_id, refresh_jti, created_at) VALUES (1,10,'n',?)`, newer) + // device 20: one login. + db.Exec(`INSERT INTO sessions (user_id, device_id, refresh_jti, created_at) VALUES (1,20,'x',?)`, older) + + ss := sessions.NewStore(db) + m, err := ss.LastLoginByDevice(ctx, 1) + if err != nil { + t.Fatalf("LastLoginByDevice: %v", err) + } + if !m[10].Equal(newer) { + t.Fatalf("device 10 last login = %v, want %v", m[10], newer) + } + if !m[20].Equal(older) { + t.Fatalf("device 20 last login = %v, want %v", m[20], older) + } +} diff --git a/server/internal/store/sqlite_migrate_test.go b/server/internal/store/sqlite_migrate_test.go index 7640201..b8b0a15 100644 --- a/server/internal/store/sqlite_migrate_test.go +++ b/server/internal/store/sqlite_migrate_test.go @@ -29,8 +29,8 @@ func TestSQLiteMigrateUpDown(t *testing.T) { if dirty { t.Fatalf("schema dirty after MigrateUp") } - if v != 15 { - t.Errorf("version = %d, want 15", v) + if v != 16 { + t.Errorf("version = %d, want 16", v) } // 2. Core tables exist. @@ -38,7 +38,7 @@ func TestSQLiteMigrateUpDown(t *testing.T) { "users", "devices", "plans", "subscriptions", "code_batches", "codes", "usage_daily", "audit_log", "providers", "nodes", "node_events", "directory_version", "provision_idempotency", "replacements", "admins", - "connect_credentials", "usage_device_daily", + "connect_credentials", "usage_device_daily", "sessions", } { var name string err := db.QueryRow( diff --git a/server/migrations/mysql/000016_sessions_and_device_meta.down.sql b/server/migrations/mysql/000016_sessions_and_device_meta.down.sql new file mode 100644 index 0000000..51461b3 --- /dev/null +++ b/server/migrations/mysql/000016_sessions_and_device_meta.down.sql @@ -0,0 +1,3 @@ +DROP TABLE IF EXISTS sessions; +ALTER TABLE devices DROP COLUMN totp_trusted_until; +ALTER TABLE devices DROP COLUMN client_version; diff --git a/server/migrations/mysql/000016_sessions_and_device_meta.up.sql b/server/migrations/mysql/000016_sessions_and_device_meta.up.sql new file mode 100644 index 0000000..64c3c90 --- /dev/null +++ b/server/migrations/mysql/000016_sessions_and_device_meta.up.sql @@ -0,0 +1,18 @@ +-- 会话 + 设备元数据(设备 & 会话管理 P2)。见 sqlite/000016 注释。 +CREATE TABLE sessions ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT UNSIGNED NOT NULL, + device_id BIGINT UNSIGNED NOT NULL, + refresh_jti VARCHAR(64) NOT NULL, + client_ip VARCHAR(64) NULL, + client_version VARCHAR(64) NULL, + created_at DATETIME(6) NOT NULL, + last_active DATETIME(6) NULL, + revoked_at DATETIME(6) NULL, + UNIQUE KEY uk_sessions_jti (refresh_jti), + INDEX idx_sessions_user (user_id), + INDEX idx_sessions_device (device_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +ALTER TABLE devices ADD COLUMN client_version VARCHAR(64) NULL; +ALTER TABLE devices ADD COLUMN totp_trusted_until DATETIME(6) NULL; diff --git a/server/migrations/sqlite/000016_sessions_and_device_meta.down.sql b/server/migrations/sqlite/000016_sessions_and_device_meta.down.sql new file mode 100644 index 0000000..0afd29e --- /dev/null +++ b/server/migrations/sqlite/000016_sessions_and_device_meta.down.sql @@ -0,0 +1,5 @@ +DROP INDEX IF EXISTS idx_sessions_device; +DROP INDEX IF EXISTS idx_sessions_user; +DROP TABLE IF EXISTS sessions; +ALTER TABLE devices DROP COLUMN totp_trusted_until; +ALTER TABLE devices DROP COLUMN client_version; diff --git a/server/migrations/sqlite/000016_sessions_and_device_meta.up.sql b/server/migrations/sqlite/000016_sessions_and_device_meta.up.sql new file mode 100644 index 0000000..cd01dad --- /dev/null +++ b/server/migrations/sqlite/000016_sessions_and_device_meta.up.sql @@ -0,0 +1,29 @@ +-- 会话 + 设备元数据(设备 & 会话管理 P2) +-- +-- ① sessions:每次登录一条,绑定 device + refresh-token JTI,支撑「按设备强制退出 / +-- 最后登录时间 / 会话历史」。refresh 轮换更新 refresh_jti+last_active;登出/强制退出 +-- 置 revoked_at。Redis 白名单仍作快速校验,sessions 是可查询的权威记录。 +-- ② devices.client_version:该设备最近上报的客户端版本(列表展示)。 +-- ③ devices.totp_trusted_until:预留——未来 2FA「信任设备」过期点;清除登录信息时清空。 +-- +-- 注:devices 唯一键 UNIQUE(uuid)→UNIQUE(user_id,uuid) 与 platform CHECK 加 linux 需 +-- SQLite 表重建,风险隔离到单独迁移,不在本迁移内做。 + +CREATE TABLE sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + device_id INTEGER NOT NULL, + refresh_jti TEXT NOT NULL UNIQUE, + client_ip TEXT NULL, + client_version TEXT NULL, + created_at DATETIME NOT NULL, + last_active DATETIME NULL, + revoked_at DATETIME NULL, + FOREIGN KEY (user_id) REFERENCES users(id), + FOREIGN KEY (device_id) REFERENCES devices(id) +); +CREATE INDEX idx_sessions_user ON sessions (user_id); +CREATE INDEX idx_sessions_device ON sessions (device_id); + +ALTER TABLE devices ADD COLUMN client_version TEXT NULL; +ALTER TABLE devices ADD COLUMN totp_trusted_until DATETIME NULL;