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