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:
wangjia
2026-07-02 13:16:40 +08:00
parent bbe149fd41
commit 63d1baeb01
9 changed files with 321 additions and 51 deletions
@@ -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)
+20 -31
View File
@@ -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
+11 -8
View File
@@ -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)
}
+2 -2
View File
@@ -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.
@@ -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)
@@ -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);