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
+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