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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user