From 63d1baeb013789a8902e7b4a13bc24df14a65a1e Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Thu, 2 Jul 2026 13:16:40 +0800 Subject: [PATCH] =?UTF-8?q?fix(server):=20devices=20=E5=94=AF=E4=B8=80?= =?UTF-8?q?=E9=94=AE=E6=94=B9=20(user=5Fid,uuid)=20=E2=80=94=E2=80=94=20?= =?UTF-8?q?=E5=90=8C=E6=9C=BA=E6=8D=A2=E8=B4=A6=E5=8F=B7=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=20403=20=E6=AD=BB=E7=BB=93(F3,#27)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit device_id 按安装持久、跨账号复用;旧全局 UNIQUE(uuid) 使同机第二账号注册永远 403 → ConnectNode 判 DEVICE_NOT_REGISTERED,提示的「重新登录」无法自救。 - migration 21(sqlite/mysql):UNIQUE(uuid)→UNIQUE(user_id,uuid);platform 放行 linux(normalizePlatform 早已接受,旧 CHECK/ENUM 会拒)。SQLite 表重建用 rename→重建→复制→drop 次序,单事务内不触发 sessions 的级联清空(FK ON)。 - 查找全部收口为按 (user,uuid) 作用域(重复 uuid 跨用户后全局查询歧义): findDeviceByUserUUIDTx / FindByUserUUID;Register 删跨用户 Forbidden 分支; Delete/ForceLogout/Rename 对他人设备返回 404(不可见);SessionActive 删 「非本人 fail-safe」分支,dev==nil→false 语义不变。 - 测试:SQLite 真迁移库 F3 回归(两账号同 uuid 各自成行/同用户重复拒/linux 入库/ sessions 重建后级联仍成立)+ MySQL 集成测试 schema 同步与双账号用例。 Co-Authored-By: Claude Opus 4.8 --- .../devices/devices_integration_test.go | 85 ++++++++++++++++--- server/internal/devices/service.go | 51 +++++------ server/internal/devices/store.go | 19 +++-- server/internal/store/sqlite_migrate_test.go | 4 +- server/internal/store/sqlite_stores_test.go | 63 ++++++++++++++ .../000021_devices_user_scoped_uuid.down.sql | 12 +++ .../000021_devices_user_scoped_uuid.up.sql | 11 +++ .../000021_devices_user_scoped_uuid.down.sql | 59 +++++++++++++ .../000021_devices_user_scoped_uuid.up.sql | 68 +++++++++++++++ 9 files changed, 321 insertions(+), 51 deletions(-) create mode 100644 server/migrations/mysql/000021_devices_user_scoped_uuid.down.sql create mode 100644 server/migrations/mysql/000021_devices_user_scoped_uuid.up.sql create mode 100644 server/migrations/sqlite/000021_devices_user_scoped_uuid.down.sql create mode 100644 server/migrations/sqlite/000021_devices_user_scoped_uuid.up.sql diff --git a/server/internal/devices/devices_integration_test.go b/server/internal/devices/devices_integration_test.go index 2ae718a..dc605ed 100644 --- a/server/internal/devices/devices_integration_test.go +++ b/server/internal/devices/devices_integration_test.go @@ -70,13 +70,18 @@ func applySchema(db *sql.DB) error { ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`, `CREATE TABLE IF NOT EXISTS devices ( - id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, - uuid CHAR(36) NOT NULL UNIQUE, - user_id BIGINT UNSIGNED NOT NULL, - name VARCHAR(64) NOT NULL, - platform ENUM('ios','android','windows','macos') NOT NULL, - last_seen DATETIME(6) NULL, - created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + uuid CHAR(36) NOT NULL, + user_id BIGINT UNSIGNED NOT NULL, + name VARCHAR(64) NOT NULL, + platform ENUM('ios','android','windows','macos','linux') NOT NULL, + last_seen DATETIME(6) NULL, + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + client_version VARCHAR(32) NULL, + totp_trusted_until DATETIME(6) NULL, + dp_uuid CHAR(36) NULL, + UNIQUE KEY uniq_devices_user_uuid (user_id, uuid), + UNIQUE KEY idx_devices_dp_uuid (dp_uuid), FOREIGN KEY (user_id) REFERENCES users(id), INDEX idx_user (user_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`, @@ -330,9 +335,10 @@ func TestDeleteOthersDevice(t *testing.T) { t.Fatalf("register: %v", apiErr) } - // Other user cannot delete it → 403 FORBIDDEN. - if apiErr := svc.DeleteDevice(ctx, other, devUUID); apiErr == nil || apiErr.Code != "FORBIDDEN" { - t.Errorf("want FORBIDDEN, got %v", apiErr) + // Other user cannot delete it → 404 NOT_FOUND(查找按 (user,uuid) 作用域, + // 他人名下的行不可见,migration 21 起不再是 403)。 + if apiErr := svc.DeleteDevice(ctx, other, devUUID); apiErr == nil || apiErr.Code != "NOT_FOUND" { + t.Errorf("want NOT_FOUND, got %v", apiErr) } // Non-existent device → 404 NOT_FOUND. if apiErr := svc.DeleteDevice(ctx, owner, newUUID(t, db)); apiErr == nil || apiErr.Code != "NOT_FOUND" { @@ -340,6 +346,65 @@ func TestDeleteOthersDevice(t *testing.T) { } } +// TestSameDeviceUUIDTwoAccounts:F3 回归——同一物理设备(同 device uuid)先后登录 +// 两个账号,双方都能注册成功、各自成行,互不 403;各自的删除只影响自己名下的行。 +func TestSameDeviceUUIDTwoAccounts(t *testing.T) { + db := setupMySQL(t) + svc := devices.NewService(devices.NewStore(db), nil) + ctx := context.Background() + + userA := createUser(t, db, "a-shared@example.com", "active") + userB := createUser(t, db, "b-shared@example.com", "active") + devUUID := newUUID(t, db) // 同一台机器的持久 device_id + + if _, _, apiErr := svc.RegisterIfAbsent(ctx, devices.RegisterInput{ + UserID: userA, DeviceUUID: devUUID, Name: "Shared Mac", Platform: "macos", MaxDevices: 5, + }); apiErr != nil { + t.Fatalf("register user A: %v", apiErr) + } + // 换账号:同 uuid 注册到 user B —— 旧全局 UNIQUE(uuid) 下这里是 403 死结。 + if _, _, apiErr := svc.RegisterIfAbsent(ctx, devices.RegisterInput{ + UserID: userB, DeviceUUID: devUUID, Name: "Shared Mac", Platform: "macos", MaxDevices: 5, + }); apiErr != nil { + t.Fatalf("register user B (same device uuid): %v", apiErr) + } + + // 各自名下都各有一行。 + for _, uid := range []int64{userA, userB} { + list, apiErr := svc.ListDevices(ctx, uid) + if apiErr != nil { + t.Fatalf("list %d: %v", uid, apiErr) + } + n := 0 + for _, d := range list { + if d.UUID == devUUID { + n++ + } + } + if n != 1 { + t.Errorf("user %d: want 1 row for shared uuid, got %d", uid, n) + } + } + + // A 删除自己的行,不影响 B 的行。 + if apiErr := svc.DeleteDevice(ctx, userA, devUUID); apiErr != nil { + t.Fatalf("delete A: %v", apiErr) + } + listB, apiErr := svc.ListDevices(ctx, userB) + if apiErr != nil { + t.Fatalf("list B after A delete: %v", apiErr) + } + found := false + for _, d := range listB { + if d.UUID == devUUID { + found = true + } + } + if !found { + t.Errorf("user B's row must survive user A's delete") + } +} + // TestBannedUserRejected verifies the resolver/middleware path rejects banned users. func TestBannedUserRejected(t *testing.T) { db := setupMySQL(t) diff --git a/server/internal/devices/service.go b/server/internal/devices/service.go index eb01bf7..4779fbd 100644 --- a/server/internal/devices/service.go +++ b/server/internal/devices/service.go @@ -201,16 +201,13 @@ func (svc *Service) RegisterIfAbsent(ctx context.Context, in RegisterInput) (int return 0, nil, apierr.ErrAccountBanned } - existing, err := svc.store.findDeviceByUUIDTx(ctx, tx, uuid) + // 按 (user,uuid) 查:唯一键是 UNIQUE(user_id,uuid)(migration 21),同一物理设备 + // 在别的账号名下的行与本次注册无关 —— 同机换账号各自成行,不再互相 403(F3)。 + existing, err := svc.store.findDeviceByUserUUIDTx(ctx, tx, in.UserID, uuid) if err != nil { 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 0, nil, apierr.ErrForbidden - } if err := svc.store.touchLastSeenTx(ctx, tx, existing.ID, in.ClientVersion); err != nil { return 0, nil, apierr.ErrInternal } @@ -253,26 +250,25 @@ func (svc *Service) RegisterIfAbsent(ctx context.Context, in RegisterInput) (int // and then triggers per-user credential recall on the node side. // // - device not found → 404 NOT_FOUND -// - device owned by another user → 403 FORBIDDEN (does not delete) +// - device owned by another user → 404 NOT_FOUND (user-scoped lookup; invisible) func (svc *Service) DeleteDevice(ctx context.Context, userID int64, deviceUUID string) *apierr.Error { uuid := strings.TrimSpace(deviceUUID) if uuid == "" { return apierr.ErrBadRequest } - // Resolve + ownership check (non-tx) so sessions can be revoked BEFORE the - // delete tx: SQLite (_txlock=immediate) holds a write lock for the tx, so a - // session write on another pool connection would deadlock against it. - dev, err := svc.store.FindByUUID(ctx, uuid) + // Resolve (non-tx) so sessions can be revoked BEFORE the delete tx: SQLite + // (_txlock=immediate) holds a write lock for the tx, so a session write on + // another pool connection would deadlock against it. Lookup is user-scoped + // (UNIQUE(user_id,uuid)) — other users' rows with the same uuid are invisible, + // so "not mine" and "not found" are both 404. + dev, err := svc.store.FindByUserUUID(ctx, userID, uuid) if err != nil { return apierr.ErrInternal } if dev == nil { return apierr.ErrNotFound } - if dev.UserID != userID { - return apierr.ErrForbidden - } // Revoke the device's sessions (drop their refresh JTIs from Redis) while the // rows still exist; the device delete then cascades them away. @@ -319,22 +315,19 @@ func (svc *Service) DeleteDevice(ctx context.Context, userID int64, deviceUUID s // user can simply log in again. // // - device not found → 404 NOT_FOUND -// - device owned by another user → 403 FORBIDDEN +// - device owned by another user → 404 NOT_FOUND (user-scoped lookup; invisible) func (svc *Service) ForceLogout(ctx context.Context, userID int64, deviceUUID string) *apierr.Error { uuid := strings.TrimSpace(deviceUUID) if uuid == "" { return apierr.ErrBadRequest } - dev, err := svc.store.FindByUUID(ctx, uuid) + dev, err := svc.store.FindByUserUUID(ctx, userID, uuid) if err != nil { return apierr.ErrInternal } if dev == nil { return apierr.ErrNotFound } - if dev.UserID != userID { - return apierr.ErrForbidden - } svc.revokeDeviceSessions(ctx, userID, dev.ID) return nil } @@ -343,7 +336,7 @@ func (svc *Service) ForceLogout(ctx context.Context, userID int64, deviceUUID st // 400; name is trimmed + truncated to 64 runes. // // - device not found → 404 -// - device owned by another user → 403 +// - device owned by another user → 404 (user-scoped lookup; invisible) func (svc *Service) RenameDevice(ctx context.Context, userID int64, deviceUUID, rawName string) *apierr.Error { uuid := strings.TrimSpace(deviceUUID) name := strings.TrimSpace(rawName) @@ -353,16 +346,13 @@ func (svc *Service) RenameDevice(ctx context.Context, userID int64, deviceUUID, if r := []rune(name); len(r) > 64 { name = string(r[:64]) } - dev, err := svc.store.FindByUUID(ctx, uuid) + dev, err := svc.store.FindByUserUUID(ctx, userID, uuid) if err != nil { return apierr.ErrInternal } if dev == nil { return apierr.ErrNotFound } - if dev.UserID != userID { - return apierr.ErrForbidden - } if err := svc.store.UpdateName(ctx, dev.ID, name); err != nil { return apierr.ErrInternal } @@ -378,19 +368,18 @@ func (svc *Service) SessionActive(ctx context.Context, userID int64, deviceUUID if svc.sessions == nil || strings.TrimSpace(deviceUUID) == "" { return true, nil } - dev, err := svc.store.FindByUUID(ctx, deviceUUID) + dev, err := svc.store.FindByUserUUID(ctx, userID, deviceUUID) if err != nil { return false, apierr.ErrInternal } if dev == nil { - // 设备行已不存在 = 本设备被「移除」(DeleteDevice 删行)。已登录的客户端在登录时 - // 必然注册过自己的设备,轮询自身 device_id 却查无此行,只能是被移除 → 视为会话失效, - // 让其登出(否则被移除的设备永远收到 active=true,不退出,只表现为数据面被断→「节点异常」)。 + // 本用户名下无此设备行 = 本设备被「移除」(DeleteDevice 删行)。已登录的客户端在 + // 登录时必然注册过自己的设备,轮询自身 device_id 却查无此行,只能是被移除 → 视为 + // 会话失效,让其登出(否则被移除的设备永远收到 active=true,不退出,只表现为数据面 + // 被断→「节点异常」)。查找按 (user,uuid) 作用域,他人账号下的同 uuid 行不可见, + // 不存在旧「非本人设备 fail-safe」分支。 return false, nil } - if dev.UserID != userID { - return true, nil // 非本人设备 uuid:不据此登出(fail-safe) - } active, err := svc.sessions.HasActiveSession(ctx, userID, dev.ID) if err != nil { return false, apierr.ErrInternal diff --git a/server/internal/devices/store.go b/server/internal/devices/store.go index 7fdd510..d3f4ddf 100644 --- a/server/internal/devices/store.go +++ b/server/internal/devices/store.go @@ -77,21 +77,24 @@ func (s *Store) ListByUser(ctx context.Context, userID int64) ([]DeviceRow, erro return out, rows.Err() } -// findDeviceByUUIDTx looks up a device by UUID with FOR UPDATE inside tx. -// Returns (nil, nil) when the device does not exist. -func (s *Store) findDeviceByUUIDTx(ctx context.Context, tx *sql.Tx, uuid string) (*DeviceRow, error) { +// findDeviceByUserUUIDTx looks up the user's device by UUID with FOR UPDATE +// inside tx. Returns (nil, nil) when the device does not exist for this user. +// 必须带 user_id:唯一键是 UNIQUE(user_id,uuid)(migration 21),同一物理设备的 +// uuid 可在多个账号下各有一行,全局按 uuid 查会歧义。 +func (s *Store) findDeviceByUserUUIDTx(ctx context.Context, tx *sql.Tx, userID int64, uuid string) (*DeviceRow, error) { row := tx.QueryRowContext(ctx, `SELECT id, uuid, user_id, name, platform, last_seen, created_at, client_version, dp_uuid - FROM devices WHERE uuid=? `+s.dialect.LockForUpdate(), uuid) + FROM devices WHERE user_id=? AND uuid=? `+s.dialect.LockForUpdate(), userID, uuid) return scanDeviceRow(row) } -// FindByUUID looks up a device by UUID (non-tx). Returns (nil, nil) if absent. -// Used by force-logout/delete to resolve ownership + dp_uuid. -func (s *Store) FindByUUID(ctx context.Context, uuid string) (*DeviceRow, error) { +// FindByUserUUID looks up the user's device by UUID (non-tx). Returns (nil, nil) +// if absent for this user. Used by force-logout/delete/rename/session-poll to +// resolve the device row; other users' rows with the same uuid are invisible. +func (s *Store) FindByUserUUID(ctx context.Context, userID int64, uuid string) (*DeviceRow, error) { row := s.db.QueryRowContext(ctx, `SELECT id, uuid, user_id, name, platform, last_seen, created_at, client_version, dp_uuid - FROM devices WHERE uuid=?`, uuid) + FROM devices WHERE user_id=? AND uuid=?`, userID, uuid) return scanDeviceRow(row) } diff --git a/server/internal/store/sqlite_migrate_test.go b/server/internal/store/sqlite_migrate_test.go index b14da4f..3df79b7 100644 --- a/server/internal/store/sqlite_migrate_test.go +++ b/server/internal/store/sqlite_migrate_test.go @@ -29,8 +29,8 @@ func TestSQLiteMigrateUpDown(t *testing.T) { if dirty { t.Fatalf("schema dirty after MigrateUp") } - if v != 20 { - t.Errorf("version = %d, want 20", v) + if v != 21 { + t.Errorf("version = %d, want 21", v) } // 2. Core tables exist. diff --git a/server/internal/store/sqlite_stores_test.go b/server/internal/store/sqlite_stores_test.go index 49cd7b6..92d5e03 100644 --- a/server/internal/store/sqlite_stores_test.go +++ b/server/internal/store/sqlite_stores_test.go @@ -146,6 +146,69 @@ func TestSQLite_SessionHasActiveSession(t *testing.T) { } } +// TestSQLite_DevicesUserScopedUUID:F3 回归(migration 21)——同一物理设备的 +// device uuid 在两个账号下各自成行(UNIQUE(user_id,uuid)),同用户重复注册仍被 +// 唯一键拒绝;linux 平台可入库(CHECK 已放行);sessions 表在重建后 FK 仍指向新 +// devices(级联删除成立)。 +func TestSQLite_DevicesUserScopedUUID(t *testing.T) { + ctx := context.Background() + db := openSQLite(t) + + mkUser := func(u, email string) int64 { + res, err := db.ExecContext(ctx, + `INSERT INTO users (uuid, email, pw_hash, dp_uuid) VALUES (?, ?, 'h', ?)`, + u, email, "dp-"+u) + if err != nil { + t.Fatalf("user %s: %v", u, err) + } + id, _ := res.LastInsertId() + return id + } + userA := mkUser("u-a", "a@x.c") + userB := mkUser("u-b", "b@x.c") + + // 同一 device uuid,两个账号各自成行(旧全局 UNIQUE(uuid) 下第二条会失败)。 + if _, err := db.ExecContext(ctx, + `INSERT INTO devices (uuid, user_id, name, platform) VALUES ('shared-dev', ?, 'Mac', 'macos')`, userA); err != nil { + t.Fatalf("register A: %v", err) + } + res, err := db.ExecContext(ctx, + `INSERT INTO devices (uuid, user_id, name, platform) VALUES ('shared-dev', ?, 'Mac', 'macos')`, userB) + if err != nil { + t.Fatalf("register B (same uuid, other user) must succeed: %v", err) + } + devB, _ := res.LastInsertId() + + // 同用户重复注册仍被 UNIQUE(user_id,uuid) 拒绝。 + if _, err := db.ExecContext(ctx, + `INSERT INTO devices (uuid, user_id, name, platform) VALUES ('shared-dev', ?, 'Mac2', 'macos')`, userA); err == nil { + t.Fatalf("duplicate (user,uuid) must be rejected") + } + + // linux 平台可入库(migration 21 顺手放行,normalizePlatform 早已接受)。 + if _, err := db.ExecContext(ctx, + `INSERT INTO devices (uuid, user_id, name, platform) VALUES ('linux-dev', ?, 'NUC', 'linux')`, userA); err != nil { + t.Fatalf("linux platform must be accepted: %v", err) + } + + // sessions FK 重建后仍指向新 devices:删 B 的设备,B 的会话级联消失。 + ss := sessions.NewStore(db) + if err := ss.Create(ctx, userB, devB, "jti-b", "", ""); err != nil { + t.Fatalf("session B: %v", err) + } + if _, err := db.ExecContext(ctx, `DELETE FROM devices WHERE id=?`, devB); err != nil { + t.Fatalf("delete devB: %v", err) + } + var n int + if err := db.QueryRowContext(ctx, + `SELECT COUNT(1) FROM sessions WHERE device_id=?`, devB).Scan(&n); err != nil { + t.Fatalf("count sessions: %v", err) + } + if n != 0 { + t.Errorf("sessions must cascade on device delete after rebuild, got %d rows", n) + } +} + func TestSQLite_NodeAccumulateUsage(t *testing.T) { ctx := context.Background() db := openSQLite(t) diff --git a/server/migrations/mysql/000021_devices_user_scoped_uuid.down.sql b/server/migrations/mysql/000021_devices_user_scoped_uuid.down.sql new file mode 100644 index 0000000..0f902fe --- /dev/null +++ b/server/migrations/mysql/000021_devices_user_scoped_uuid.down.sql @@ -0,0 +1,12 @@ +-- 回退:UNIQUE(user_id,uuid) → 全局 UNIQUE(uuid),platform ENUM 去掉 linux。 +-- 有损:同一 uuid 跨用户的重复行只保留最早一行(id 最小),linux 平台行删除。 + +DELETE d1 FROM devices d1 +JOIN devices d2 ON d1.uuid = d2.uuid AND d1.id > d2.id; + +DELETE FROM devices WHERE platform = 'linux'; + +ALTER TABLE devices + DROP INDEX uniq_devices_user_uuid, + ADD UNIQUE KEY uuid (uuid), + MODIFY platform ENUM('ios','android','windows','macos') NOT NULL; diff --git a/server/migrations/mysql/000021_devices_user_scoped_uuid.up.sql b/server/migrations/mysql/000021_devices_user_scoped_uuid.up.sql new file mode 100644 index 0000000..be80874 --- /dev/null +++ b/server/migrations/mysql/000021_devices_user_scoped_uuid.up.sql @@ -0,0 +1,11 @@ +-- 设备唯一键 UNIQUE(uuid) → UNIQUE(user_id, uuid)(F3:同机换账号 403 死结) +-- +-- device_id 是客户端按「安装」生成并持久的,跨账号复用;全局 UNIQUE(uuid) 使同一台 +-- 机器登第二个账号时 RegisterIfAbsent 永远 403。改为按用户隔离:同一物理设备在每个 +-- 账号下各有一行。顺手给 platform ENUM 加 linux(normalizePlatform 已接受,列会拒)。 +-- MySQL 可原地 ALTER,无需重建(列级 UNIQUE 的隐式索引名 = 列名 uuid)。 + +ALTER TABLE devices + DROP INDEX uuid, + ADD UNIQUE KEY uniq_devices_user_uuid (user_id, uuid), + MODIFY platform ENUM('ios','android','windows','macos','linux') NOT NULL; diff --git a/server/migrations/sqlite/000021_devices_user_scoped_uuid.down.sql b/server/migrations/sqlite/000021_devices_user_scoped_uuid.down.sql new file mode 100644 index 0000000..87bb901 --- /dev/null +++ b/server/migrations/sqlite/000021_devices_user_scoped_uuid.down.sql @@ -0,0 +1,59 @@ +-- 回退:UNIQUE(user_id,uuid) → 全局 UNIQUE(uuid),platform CHECK 去掉 linux。 +-- 有损:同一 uuid 跨用户的重复行只保留最早一行(id 最小),linux 平台行删除 +-- (旧 CHECK 不接受)。次序与 up 相同(rename → 重建 → 复制 → drop)。 + +ALTER TABLE devices RENAME TO devices_old; + +CREATE TABLE devices ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + uuid TEXT NOT NULL UNIQUE, + user_id INTEGER NOT NULL, + name TEXT NOT NULL, + platform TEXT NOT NULL CHECK (platform IN ('ios','android','windows','macos')), + last_seen DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + client_version TEXT NULL, + totp_trusted_until DATETIME NULL, + dp_uuid TEXT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) +); + +INSERT INTO devices (id, uuid, user_id, name, platform, last_seen, created_at, + client_version, totp_trusted_until, dp_uuid) +SELECT id, uuid, user_id, name, platform, last_seen, created_at, + client_version, totp_trusted_until, dp_uuid +FROM devices_old o +WHERE o.platform IN ('ios','android','windows','macos') + AND o.id = (SELECT MIN(i.id) FROM devices_old i WHERE i.uuid = o.uuid); + +ALTER TABLE sessions RENAME TO sessions_old; + +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) ON DELETE CASCADE, + FOREIGN KEY (device_id) REFERENCES devices(id) ON DELETE CASCADE +); + +-- 只回填仍存在对应设备行的会话(被去重删掉的设备,其会话一并丢弃)。 +INSERT INTO sessions (id, user_id, device_id, refresh_jti, client_ip, client_version, + created_at, last_active, revoked_at) +SELECT s.id, s.user_id, s.device_id, s.refresh_jti, s.client_ip, s.client_version, + s.created_at, s.last_active, s.revoked_at +FROM sessions_old s +WHERE EXISTS (SELECT 1 FROM devices d WHERE d.id = s.device_id); + +DROP TABLE sessions_old; +DROP TABLE devices_old; + +CREATE INDEX idx_devices_user ON devices (user_id); +CREATE UNIQUE INDEX idx_devices_dp_uuid ON devices (dp_uuid); +CREATE INDEX idx_sessions_user ON sessions (user_id); +CREATE INDEX idx_sessions_device ON sessions (device_id); diff --git a/server/migrations/sqlite/000021_devices_user_scoped_uuid.up.sql b/server/migrations/sqlite/000021_devices_user_scoped_uuid.up.sql new file mode 100644 index 0000000..4ae5c7a --- /dev/null +++ b/server/migrations/sqlite/000021_devices_user_scoped_uuid.up.sql @@ -0,0 +1,68 @@ +-- 设备唯一键 UNIQUE(uuid) → UNIQUE(user_id, uuid)(F3:同机换账号 403 死结) +-- +-- device_id 是客户端按「安装」生成并持久的,跨账号复用;全局 UNIQUE(uuid) 使同一台 +-- 机器登第二个账号时 RegisterIfAbsent 永远 403 → ConnectNode 判 DEVICE_NOT_REGISTERED, +-- 且提示的「重新登录」无法自救。改为按用户隔离:同一物理设备在每个账号下各有一行。 +-- 顺手落地 migration 16 头注释里推迟的两件事:本唯一键改造 + platform CHECK 加 linux +-- (normalizePlatform 已接受 linux,但旧 CHECK 会拒 INSERT)。 +-- +-- SQLite 无法删除列级 UNIQUE,须重建表。sessions 对 devices 有 ON DELETE CASCADE 外键 +-- 且连接开启 foreign_keys=ON:直接 DROP devices 会级联清空全部会话(=全员被登出)。 +-- 安全次序(单事务内成立,不依赖 PRAGMA foreign_keys=OFF): +-- ① RENAME devices → devices_old(sessions 的 FK 定义随 rename 跟走到 devices_old); +-- ② 建新 devices(复合唯一键),按原 id 复制数据; +-- ③ RENAME sessions → sessions_old,建新 sessions(FK 指向新 devices),复制数据; +-- ④ 先 DROP 子表 sessions_old,再 DROP 已无引用的 devices_old;重建全部索引。 + +ALTER TABLE devices RENAME TO devices_old; + +CREATE TABLE devices ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + uuid TEXT NOT NULL, + user_id INTEGER NOT NULL, + name TEXT NOT NULL, + platform TEXT NOT NULL CHECK (platform IN ('ios','android','windows','macos','linux')), + last_seen DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + client_version TEXT NULL, + totp_trusted_until DATETIME NULL, + dp_uuid TEXT NULL, + UNIQUE (user_id, uuid), + FOREIGN KEY (user_id) REFERENCES users(id) +); + +INSERT INTO devices (id, uuid, user_id, name, platform, last_seen, created_at, + client_version, totp_trusted_until, dp_uuid) +SELECT id, uuid, user_id, name, platform, last_seen, created_at, + client_version, totp_trusted_until, dp_uuid +FROM devices_old; + +ALTER TABLE sessions RENAME TO sessions_old; + +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) ON DELETE CASCADE, + FOREIGN KEY (device_id) REFERENCES devices(id) ON DELETE CASCADE +); + +INSERT INTO sessions (id, user_id, device_id, refresh_jti, client_ip, client_version, + created_at, last_active, revoked_at) +SELECT id, user_id, device_id, refresh_jti, client_ip, client_version, + created_at, last_active, revoked_at +FROM sessions_old; + +DROP TABLE sessions_old; +DROP TABLE devices_old; + +CREATE INDEX idx_devices_user ON devices (user_id); +CREATE UNIQUE INDEX idx_devices_dp_uuid ON devices (dp_uuid); +CREATE INDEX idx_sessions_user ON sessions (user_id); +CREATE INDEX idx_sessions_device ON sessions (device_id);