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

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:
wangjia
2026-06-29 00:50:24 +08:00
parent 8370ee1eb7
commit 2f298f0a0a
23 changed files with 709 additions and 159 deletions
+22 -3
View File
@@ -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<String, dynamic> 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'),
);
}
}
+30
View File
@@ -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);
});
}
@@ -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_activeLogout `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 storeJTI surfaceReportUsage touchListDevices 计算;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_activeLogout `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`
+19 -10
View File
@@ -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
}
+7 -6
View File
@@ -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 {
+1 -1
View File
@@ -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
+60 -30
View File
@@ -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
}
+12 -12
View File
@@ -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)
}
+41 -15
View File
@@ -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).
@@ -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)
+67 -32
View File
@@ -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)
+38 -19
View File
@@ -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 {
+8
View File
@@ -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()
+5
View File
@@ -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
+14
View File
@@ -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
+124
View File
@@ -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
}
@@ -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)
}
}
@@ -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)
}
}
+3 -3
View File
@@ -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(
@@ -0,0 +1,3 @@
DROP TABLE IF EXISTS sessions;
ALTER TABLE devices DROP COLUMN totp_trusted_until;
ALTER TABLE devices DROP COLUMN client_version;
@@ -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;
@@ -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;
@@ -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;