fix(server): devices 唯一键改 (user_id,uuid) —— 同机换账号不再 403 死结(F3,#27)
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 <noreply@anthropic.com>
This commit is contained in:
@@ -70,13 +70,18 @@ func applySchema(db *sql.DB) error {
|
|||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
||||||
|
|
||||||
`CREATE TABLE IF NOT EXISTS devices (
|
`CREATE TABLE IF NOT EXISTS devices (
|
||||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
uuid CHAR(36) NOT NULL UNIQUE,
|
uuid CHAR(36) NOT NULL,
|
||||||
user_id BIGINT UNSIGNED NOT NULL,
|
user_id BIGINT UNSIGNED NOT NULL,
|
||||||
name VARCHAR(64) NOT NULL,
|
name VARCHAR(64) NOT NULL,
|
||||||
platform ENUM('ios','android','windows','macos') NOT NULL,
|
platform ENUM('ios','android','windows','macos','linux') NOT NULL,
|
||||||
last_seen DATETIME(6) NULL,
|
last_seen DATETIME(6) NULL,
|
||||||
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
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),
|
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||||
INDEX idx_user (user_id)
|
INDEX idx_user (user_id)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
||||||
@@ -330,9 +335,10 @@ func TestDeleteOthersDevice(t *testing.T) {
|
|||||||
t.Fatalf("register: %v", apiErr)
|
t.Fatalf("register: %v", apiErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Other user cannot delete it → 403 FORBIDDEN.
|
// Other user cannot delete it → 404 NOT_FOUND(查找按 (user,uuid) 作用域,
|
||||||
if apiErr := svc.DeleteDevice(ctx, other, devUUID); apiErr == nil || apiErr.Code != "FORBIDDEN" {
|
// 他人名下的行不可见,migration 21 起不再是 403)。
|
||||||
t.Errorf("want FORBIDDEN, got %v", apiErr)
|
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.
|
// Non-existent device → 404 NOT_FOUND.
|
||||||
if apiErr := svc.DeleteDevice(ctx, owner, newUUID(t, db)); apiErr == nil || apiErr.Code != "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.
|
// TestBannedUserRejected verifies the resolver/middleware path rejects banned users.
|
||||||
func TestBannedUserRejected(t *testing.T) {
|
func TestBannedUserRejected(t *testing.T) {
|
||||||
db := setupMySQL(t)
|
db := setupMySQL(t)
|
||||||
|
|||||||
@@ -201,16 +201,13 @@ func (svc *Service) RegisterIfAbsent(ctx context.Context, in RegisterInput) (int
|
|||||||
return 0, nil, apierr.ErrAccountBanned
|
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 {
|
if err != nil {
|
||||||
return 0, nil, apierr.ErrInternal
|
return 0, nil, apierr.ErrInternal
|
||||||
}
|
}
|
||||||
if existing != nil {
|
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 {
|
if err := svc.store.touchLastSeenTx(ctx, tx, existing.ID, in.ClientVersion); err != nil {
|
||||||
return 0, nil, apierr.ErrInternal
|
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.
|
// and then triggers per-user credential recall on the node side.
|
||||||
//
|
//
|
||||||
// - device not found → 404 NOT_FOUND
|
// - 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 {
|
func (svc *Service) DeleteDevice(ctx context.Context, userID int64, deviceUUID string) *apierr.Error {
|
||||||
uuid := strings.TrimSpace(deviceUUID)
|
uuid := strings.TrimSpace(deviceUUID)
|
||||||
if uuid == "" {
|
if uuid == "" {
|
||||||
return apierr.ErrBadRequest
|
return apierr.ErrBadRequest
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve + ownership check (non-tx) so sessions can be revoked BEFORE the
|
// Resolve (non-tx) so sessions can be revoked BEFORE the delete tx: SQLite
|
||||||
// delete tx: SQLite (_txlock=immediate) holds a write lock for the tx, so a
|
// (_txlock=immediate) holds a write lock for the tx, so a session write on
|
||||||
// session write on another pool connection would deadlock against it.
|
// another pool connection would deadlock against it. Lookup is user-scoped
|
||||||
dev, err := svc.store.FindByUUID(ctx, uuid)
|
// (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 {
|
if err != nil {
|
||||||
return apierr.ErrInternal
|
return apierr.ErrInternal
|
||||||
}
|
}
|
||||||
if dev == nil {
|
if dev == nil {
|
||||||
return apierr.ErrNotFound
|
return apierr.ErrNotFound
|
||||||
}
|
}
|
||||||
if dev.UserID != userID {
|
|
||||||
return apierr.ErrForbidden
|
|
||||||
}
|
|
||||||
|
|
||||||
// Revoke the device's sessions (drop their refresh JTIs from Redis) while the
|
// Revoke the device's sessions (drop their refresh JTIs from Redis) while the
|
||||||
// rows still exist; the device delete then cascades them away.
|
// 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.
|
// user can simply log in again.
|
||||||
//
|
//
|
||||||
// - device not found → 404 NOT_FOUND
|
// - 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 {
|
func (svc *Service) ForceLogout(ctx context.Context, userID int64, deviceUUID string) *apierr.Error {
|
||||||
uuid := strings.TrimSpace(deviceUUID)
|
uuid := strings.TrimSpace(deviceUUID)
|
||||||
if uuid == "" {
|
if uuid == "" {
|
||||||
return apierr.ErrBadRequest
|
return apierr.ErrBadRequest
|
||||||
}
|
}
|
||||||
dev, err := svc.store.FindByUUID(ctx, uuid)
|
dev, err := svc.store.FindByUserUUID(ctx, userID, uuid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return apierr.ErrInternal
|
return apierr.ErrInternal
|
||||||
}
|
}
|
||||||
if dev == nil {
|
if dev == nil {
|
||||||
return apierr.ErrNotFound
|
return apierr.ErrNotFound
|
||||||
}
|
}
|
||||||
if dev.UserID != userID {
|
|
||||||
return apierr.ErrForbidden
|
|
||||||
}
|
|
||||||
svc.revokeDeviceSessions(ctx, userID, dev.ID)
|
svc.revokeDeviceSessions(ctx, userID, dev.ID)
|
||||||
return nil
|
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.
|
// 400; name is trimmed + truncated to 64 runes.
|
||||||
//
|
//
|
||||||
// - device not found → 404
|
// - 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 {
|
func (svc *Service) RenameDevice(ctx context.Context, userID int64, deviceUUID, rawName string) *apierr.Error {
|
||||||
uuid := strings.TrimSpace(deviceUUID)
|
uuid := strings.TrimSpace(deviceUUID)
|
||||||
name := strings.TrimSpace(rawName)
|
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 {
|
if r := []rune(name); len(r) > 64 {
|
||||||
name = string(r[:64])
|
name = string(r[:64])
|
||||||
}
|
}
|
||||||
dev, err := svc.store.FindByUUID(ctx, uuid)
|
dev, err := svc.store.FindByUserUUID(ctx, userID, uuid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return apierr.ErrInternal
|
return apierr.ErrInternal
|
||||||
}
|
}
|
||||||
if dev == nil {
|
if dev == nil {
|
||||||
return apierr.ErrNotFound
|
return apierr.ErrNotFound
|
||||||
}
|
}
|
||||||
if dev.UserID != userID {
|
|
||||||
return apierr.ErrForbidden
|
|
||||||
}
|
|
||||||
if err := svc.store.UpdateName(ctx, dev.ID, name); err != nil {
|
if err := svc.store.UpdateName(ctx, dev.ID, name); err != nil {
|
||||||
return apierr.ErrInternal
|
return apierr.ErrInternal
|
||||||
}
|
}
|
||||||
@@ -378,19 +368,18 @@ func (svc *Service) SessionActive(ctx context.Context, userID int64, deviceUUID
|
|||||||
if svc.sessions == nil || strings.TrimSpace(deviceUUID) == "" {
|
if svc.sessions == nil || strings.TrimSpace(deviceUUID) == "" {
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
dev, err := svc.store.FindByUUID(ctx, deviceUUID)
|
dev, err := svc.store.FindByUserUUID(ctx, userID, deviceUUID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, apierr.ErrInternal
|
return false, apierr.ErrInternal
|
||||||
}
|
}
|
||||||
if dev == nil {
|
if dev == nil {
|
||||||
// 设备行已不存在 = 本设备被「移除」(DeleteDevice 删行)。已登录的客户端在登录时
|
// 本用户名下无此设备行 = 本设备被「移除」(DeleteDevice 删行)。已登录的客户端在
|
||||||
// 必然注册过自己的设备,轮询自身 device_id 却查无此行,只能是被移除 → 视为会话失效,
|
// 登录时必然注册过自己的设备,轮询自身 device_id 却查无此行,只能是被移除 → 视为
|
||||||
// 让其登出(否则被移除的设备永远收到 active=true,不退出,只表现为数据面被断→「节点异常」)。
|
// 会话失效,让其登出(否则被移除的设备永远收到 active=true,不退出,只表现为数据面
|
||||||
|
// 被断→「节点异常」)。查找按 (user,uuid) 作用域,他人账号下的同 uuid 行不可见,
|
||||||
|
// 不存在旧「非本人设备 fail-safe」分支。
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
if dev.UserID != userID {
|
|
||||||
return true, nil // 非本人设备 uuid:不据此登出(fail-safe)
|
|
||||||
}
|
|
||||||
active, err := svc.sessions.HasActiveSession(ctx, userID, dev.ID)
|
active, err := svc.sessions.HasActiveSession(ctx, userID, dev.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, apierr.ErrInternal
|
return false, apierr.ErrInternal
|
||||||
|
|||||||
@@ -77,21 +77,24 @@ func (s *Store) ListByUser(ctx context.Context, userID int64) ([]DeviceRow, erro
|
|||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// findDeviceByUUIDTx looks up a device by UUID with FOR UPDATE inside tx.
|
// findDeviceByUserUUIDTx looks up the user's device by UUID with FOR UPDATE
|
||||||
// Returns (nil, nil) when the device does not exist.
|
// inside tx. Returns (nil, nil) when the device does not exist for this user.
|
||||||
func (s *Store) findDeviceByUUIDTx(ctx context.Context, tx *sql.Tx, uuid string) (*DeviceRow, error) {
|
// 必须带 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,
|
row := tx.QueryRowContext(ctx,
|
||||||
`SELECT id, uuid, user_id, name, platform, last_seen, created_at, client_version, dp_uuid
|
`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)
|
return scanDeviceRow(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
// FindByUUID looks up a device by UUID (non-tx). Returns (nil, nil) if absent.
|
// FindByUserUUID looks up the user's device by UUID (non-tx). Returns (nil, nil)
|
||||||
// Used by force-logout/delete to resolve ownership + dp_uuid.
|
// if absent for this user. Used by force-logout/delete/rename/session-poll to
|
||||||
func (s *Store) FindByUUID(ctx context.Context, uuid string) (*DeviceRow, error) {
|
// 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,
|
row := s.db.QueryRowContext(ctx,
|
||||||
`SELECT id, uuid, user_id, name, platform, last_seen, created_at, client_version, dp_uuid
|
`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)
|
return scanDeviceRow(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ func TestSQLiteMigrateUpDown(t *testing.T) {
|
|||||||
if dirty {
|
if dirty {
|
||||||
t.Fatalf("schema dirty after MigrateUp")
|
t.Fatalf("schema dirty after MigrateUp")
|
||||||
}
|
}
|
||||||
if v != 20 {
|
if v != 21 {
|
||||||
t.Errorf("version = %d, want 20", v)
|
t.Errorf("version = %d, want 21", v)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Core tables exist.
|
// 2. Core tables exist.
|
||||||
|
|||||||
@@ -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) {
|
func TestSQLite_NodeAccumulateUsage(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
db := openSQLite(t)
|
db := openSQLite(t)
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
Reference in New Issue
Block a user