diff --git a/client/lib/services/account_api.dart b/client/lib/services/account_api.dart index a064faf..b821a69 100644 --- a/client/lib/services/account_api.dart +++ b/client/lib/services/account_api.dart @@ -56,9 +56,14 @@ class AccountApi { return raw.map((e) => Device.fromJson(e as Map)).toList(); } - /// DELETE /v1/me/devices/{uuid} — 移除设备。 + /// DELETE /v1/me/devices/{uuid} — 清除登录信息(移除设备 + 吊销会话/凭证)。 Future removeDevice(String uuid) => _c.delete('/v1/me/devices/$uuid'); + /// POST /v1/me/devices/{uuid}/logout — 强制退出(吊销该设备会话,设备留列表)。 + Future forceLogout(String uuid) async { + await _c.postJson('/v1/me/devices/$uuid/logout'); + } + /// GET /v1/usage?days=N — 最近 N 天用量(默认 7,后端范围 [1,90])。 Future> usage({int days = 7}) async { final body = await _c.getJson('/v1/usage?days=$days'); diff --git a/client/lib/state/account_providers.dart b/client/lib/state/account_providers.dart index 2c92515..c88229b 100644 --- a/client/lib/state/account_providers.dart +++ b/client/lib/state/account_providers.dart @@ -79,12 +79,19 @@ class DevicesNotifier extends AsyncNotifier> { return ref.read(accountApiProvider).devices(); } - /// 移除设备(DELETE)后刷新列表。 + /// 清除登录信息(DELETE:移除设备 + 吊销会话/凭证)后刷新列表。 Future remove(String uuid) async { await ref.read(accountApiProvider).removeDevice(uuid); ref.invalidateSelf(); await future; } + + /// 强制退出(吊销该设备会话,设备保留)后刷新列表。 + Future forceLogout(String uuid) async { + await ref.read(accountApiProvider).forceLogout(uuid); + ref.invalidateSelf(); + await future; + } } final devicesProvider = diff --git a/docs/superpowers/plans/2026-06-29-device-session-management.md b/docs/superpowers/plans/2026-06-29-device-session-management.md index 0fc1182..73e17fe 100644 --- a/docs/superpowers/plans/2026-06-29-device-session-management.md +++ b/docs/superpowers/plans/2026-06-29-device-session-management.md @@ -34,11 +34,11 @@ - [ ] 验收:列表显示在线/版本/最后登录(待 P6 UI + 端到端);停 agent ~3min 转离线 ## P3 · 两个操作(强制退出 + 清除增强) -- [ ] 新端点 `POST /v1/me/devices/{uuid}/logout`(handler + `Service.ForceLogout`):`sessions.RevokeByDevice` + 逐 jti `TokenManager.Revoke` -- [ ] `DeleteDevice` 增强:加 `sessions.RevokeByDevice`;`CredentialRevoker` 改 per-device -- [ ] `CredentialRevoker` 接口改 `RevokeDevice(ctx, dpUUID)`;`nodes.Hub` 实现;`main.go` 注入 hub 替 NoopRevoker -- [ ] 客户端 `account_api.forceLogout` + `devicesProvider.forceLogout` -- [ ] 测试:ForceLogout 删 jti+标 revoked_at;DeleteDevice per-device revoke +- [x] 新端点 `POST /v1/me/devices/{uuid}/logout`(handler + `Service.ForceLogout`):`sessions.RevokeByDevice` + 逐 jti `TokenManager.Revoke` +- [x] `DeleteDevice` 增强:加 `sessions.RevokeByDevice`;`CredentialRevoker` 改 per-device +- [x] `CredentialRevoker` 接口改 `RevokeDevice(ctx, dpUUID)`;`nodes.Hub` 实现;`main.go` 注入 hub 替 NoopRevoker +- [x] 客户端 `account_api.forceLogout` + `devicesProvider.forceLogout` +- [x] 测试:ForceLogout 删 jti+标 revoked_at;DeleteDevice per-device revoke - [ ] 验收:强制退出→refresh 失败;清除→设备消失+凭证作废 ## P4 · 每设备流量验证 diff --git a/server/cmd/server/main.go b/server/cmd/server/main.go index 066e0ac..056b754 100644 --- a/server/cmd/server/main.go +++ b/server/cmd/server/main.go @@ -244,10 +244,18 @@ func mountV1(r chi.Router, sqlDB *sql.DB, rdb *redis.Client, nodeSvc *nodes.Serv devicesSvc := devices.NewService(devicesStore, nil) // NoopRevoker for MVP devicesHandler := devices.NewHandler(devicesSvc) - // Sessions: login sessions bound to (device, refresh JTI) — P2. Shared by - // auth (create/rotate/revoke) and devices (last-login display). + // Sessions: login sessions bound to (device, refresh JTI) — P2/P3. Shared by + // auth (create/rotate/revoke) and devices (last-login display + force-logout). sessionStore := sessions.NewStore(sqlDB) - devicesSvc.SetLastLoginSource(sessionStore) + devicesSvc.SetSessionPort(sessionStore) + if tm != nil { + devicesSvc.SetJTIRevoker(tm) // force-logout drops refresh JTIs from Redis + } + // Per-device credential revoke on clear-login: nodes.Service pushes Revoke to + // the holding node(s). Wired when a DB-backed nodeSvc exists. + if nodeSvc != nil { + devicesSvc.SetCredentialRevoker(nodeSvc) + } // ── Auth ────────────────────────────────────────────────────────────────── var authHandler *auth.Handler diff --git a/server/internal/devices/devices_integration_test.go b/server/internal/devices/devices_integration_test.go index da671a3..22e18eb 100644 --- a/server/internal/devices/devices_integration_test.go +++ b/server/internal/devices/devices_integration_test.go @@ -207,7 +207,13 @@ func TestFullChain(t *testing.T) { t.Fatalf("expected 1 device after re-register, got %d", len(list)) } - // Delete → list drops to 0, audit row exists, revoker called. + // Give the device a data-plane credential (normally minted at connect) so + // clear-login revokes it per-device. + if _, err := db.Exec(`UPDATE devices SET dp_uuid=? WHERE uuid=?`, "dp-test-uuid", devUUID); err != nil { + t.Fatalf("set dp_uuid: %v", err) + } + + // Delete → list drops to 0, audit row exists, revoker called with dp_uuid. if apiErr := svc.DeleteDevice(ctx, userID, devUUID); apiErr != nil { t.Fatalf("DeleteDevice: %v", apiErr) } @@ -226,8 +232,8 @@ func TestFullChain(t *testing.T) { if len(revoker.Calls) != 1 { t.Fatalf("expected 1 revoke call, got %d", len(revoker.Calls)) } - if revoker.Calls[0].UserID != userID || revoker.Calls[0].Reason != "device_deleted" { - t.Errorf("unexpected revoke call: %+v", revoker.Calls[0]) + if revoker.Calls[0] != "dp-test-uuid" { + t.Errorf("unexpected revoke call: %q", revoker.Calls[0]) } } diff --git a/server/internal/devices/handler.go b/server/internal/devices/handler.go index be92e64..e36f357 100644 --- a/server/internal/devices/handler.go +++ b/server/internal/devices/handler.go @@ -22,9 +22,11 @@ func NewHandler(svc *Service) *Handler { return &Handler{svc: svc} } // // GET /devices // DELETE /devices/{id} +// POST /devices/{id}/logout func (h *Handler) RegisterRoutes(r chi.Router) { r.Get("/devices", h.ListDevices) r.Delete("/devices/{id}", h.DeleteDevice) + r.Post("/devices/{id}/logout", h.ForceLogout) } type listDevicesResponse struct { @@ -66,3 +68,21 @@ func (h *Handler) DeleteDevice(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } + +// ForceLogout handles POST /v1/me/devices/{id}/logout — revoke the device's +// sessions (kick it offline); the device stays in the list. +func (h *Handler) ForceLogout(w http.ResponseWriter, r *http.Request) { + userID, ok := UserIDFromContext(r.Context()) + if !ok { + apierr.WriteJSON(w, http.StatusUnauthorized, apierr.ErrUnauthorized) + return + } + + deviceUUID := chi.URLParam(r, "id") + if apiErr := h.svc.ForceLogout(r.Context(), userID, deviceUUID); apiErr != nil { + apierr.WriteJSON(w, StatusForError(apiErr), apiErr) + return + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/server/internal/devices/service.go b/server/internal/devices/service.go index 91ceb6a..3dff91b 100644 --- a/server/internal/devices/service.go +++ b/server/internal/devices/service.go @@ -10,54 +10,50 @@ import ( "github.com/wangjia/pangolin/server/internal/apierr" ) -// CredentialRevoker abstracts the node-side credential recall triggered when a -// device is removed. -// -// The data-plane credential model is per-user (users.dp_uuid, doc/02 §3.2): the -// node side has no device dimension, so "revoke a device" is realised as -// "recall/re-issue the owning user's credential". The interface is defined here -// (consumer side) to avoid an import cycle with the nodes package; module #5's -// nodes.Hub will satisfy it, with RevokeForUser fronting Hub.Push(RevokeCredential). +// CredentialRevoker recalls a device's data-plane credential (its dp_uuid) on the +// node side when the device is cleared. Per migration 000015 the credential is +// per-device, so revocation targets the dp_uuid. Defined consumer-side to avoid an +// import cycle; nodes.Service satisfies it (push Revoke to the holding node(s) + +// drop the connect_credentials row). Best-effort: node resync reconciles misses. type CredentialRevoker interface { - // RevokeForUser instructs node agents to recall (and lazily re-issue) the - // data-plane credential for userID. reason is a short machine tag, e.g. - // "device_deleted", used for audit/telemetry on the node side. - RevokeForUser(ctx context.Context, userID int64, reason string) error + RevokeDevice(ctx context.Context, dpUUID string) error } -// NoopRevoker is the default CredentialRevoker used until module #5 lands. -// It records the last call so callers/tests can assert on it. +// NoopRevoker is the default CredentialRevoker (tests / before nodes wiring). +// It records the dp_uuids passed so callers/tests can assert on it. type NoopRevoker struct { - Calls []RevokeCall + Calls []string } -// RevokeCall captures the arguments of a RevokeForUser invocation. -type RevokeCall struct { - UserID int64 - Reason string -} - -// RevokeForUser implements CredentialRevoker by recording the call. -func (n *NoopRevoker) RevokeForUser(_ context.Context, userID int64, reason string) error { - n.Calls = append(n.Calls, RevokeCall{UserID: userID, Reason: reason}) +// RevokeDevice implements CredentialRevoker by recording the dp_uuid. +func (n *NoopRevoker) RevokeDevice(_ context.Context, dpUUID string) error { + n.Calls = append(n.Calls, dpUUID) return nil } -// LastLoginSource provides per-device last-login times (most recent session -// created_at). Satisfied by sessions.Store; injected to keep packages decoupled. -type LastLoginSource interface { +// SessionPort provides per-device session reads/revocations. Satisfied by +// sessions.Store; injected to keep packages decoupled. +type SessionPort interface { LastLoginByDevice(ctx context.Context, userID int64) (map[int64]time.Time, error) + RevokeByDevice(ctx context.Context, userID, deviceID int64) ([]string, error) +} + +// JTIRevoker drops a refresh JTI from the Redis whitelist. Satisfied by +// auth.TokenManager (so a force-logged-out device's tokens stop refreshing). +type JTIRevoker interface { + Revoke(ctx context.Context, jti string) error } // Service implements the devices business logic. type Service struct { - store *Store - revoker CredentialRevoker - lastLogin LastLoginSource // nil until wired (P2) + store *Store + revoker CredentialRevoker + sessions SessionPort // nil until wired (P2/P3) + jtiRevoker JTIRevoker // nil until wired (P3) } // NewService creates a Service. If revoker is nil a NoopRevoker is used so the -// module can be wired and tested before module #5 (nodes.Hub) is available. +// module can be wired and tested before nodes is available. func NewService(store *Store, revoker CredentialRevoker) *Service { if revoker == nil { revoker = &NoopRevoker{} @@ -65,8 +61,15 @@ func NewService(store *Store, revoker CredentialRevoker) *Service { return &Service{store: store, revoker: revoker} } -// SetLastLoginSource wires the per-device last-login source (sessions.Store). -func (svc *Service) SetLastLoginSource(s LastLoginSource) { svc.lastLogin = s } +// SetSessionPort / SetJTIRevoker / SetCredentialRevoker wire collaborators after +// construction (main keeps devices, sessions, auth and nodes decoupled). +func (svc *Service) SetSessionPort(s SessionPort) { svc.sessions = s } +func (svc *Service) SetJTIRevoker(r JTIRevoker) { svc.jtiRevoker = r } +func (svc *Service) SetCredentialRevoker(r CredentialRevoker) { + if r != nil { + svc.revoker = r + } +} // Device is the API representation of a registered device. type Device struct { @@ -104,8 +107,8 @@ func (svc *Service) ListDevices(ctx context.Context, userID int64) ([]Device, *a return nil, apierr.ErrInternal } var lastLogin map[int64]time.Time - if svc.lastLogin != nil { - if m, e := svc.lastLogin.LastLoginByDevice(ctx, userID); e == nil { + if svc.sessions != nil { + if m, e := svc.sessions.LastLoginByDevice(ctx, userID); e == nil { lastLogin = m } } @@ -230,6 +233,24 @@ func (svc *Service) DeleteDevice(ctx context.Context, userID int64, deviceUUID s 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) + 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. + svc.revokeDeviceSessions(ctx, userID, dev.ID) + tx, err := svc.store.BeginTx(ctx) if err != nil { return apierr.ErrInternal @@ -241,17 +262,6 @@ func (svc *Service) DeleteDevice(ctx context.Context, userID int64, deviceUUID s } }() - dev, err := svc.store.findDeviceByUUIDTx(ctx, tx, uuid) - if err != nil { - return apierr.ErrInternal - } - if dev == nil { - return apierr.ErrNotFound - } - if dev.UserID != userID { - return apierr.ErrForbidden - } - if err := svc.store.deleteDeviceTx(ctx, tx, dev.ID); err != nil { return apierr.ErrInternal } @@ -265,16 +275,60 @@ func (svc *Service) DeleteDevice(ctx context.Context, userID int64, deviceUUID s } committed = true - // Recall the user's data-plane credential. Per the dp_uuid model this is a - // per-user operation (no device dimension on the node side). Best-effort: - // the device is already removed; a transient revoke failure is reconciled - // by the node agent's periodic resync, so we do not fail the request. - if err := svc.revoker.RevokeForUser(ctx, userID, "device_deleted"); err != nil { - _ = err + // Recall the device's data-plane credential so a live connection is dropped + // too. Best-effort: the device row is already gone; a transient failure is + // reconciled by node resync, so we never fail the request. (Sessions were + // already revoked above, before the cascade removed their rows.) + if dev.DpUUID.Valid && dev.DpUUID.String != "" { + if err := svc.revoker.RevokeDevice(ctx, dev.DpUUID.String); err != nil { + _ = err + } } return nil } +// ForceLogout revokes a device's login sessions (kicks it offline; the device +// stays in the list). The device's data-plane credential is left intact so the +// user can simply log in again. +// +// - device not found → 404 NOT_FOUND +// - device owned by another user → 403 FORBIDDEN +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) + 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 +} + +// revokeDeviceSessions marks all of a device's sessions revoked and drops their +// refresh JTIs from the Redis whitelist. Best-effort. +func (svc *Service) revokeDeviceSessions(ctx context.Context, userID, deviceID int64) { + if svc.sessions == nil { + return + } + jtis, err := svc.sessions.RevokeByDevice(ctx, userID, deviceID) + if err != nil { + return + } + if svc.jtiRevoker != nil { + for _, jti := range jtis { + _ = svc.jtiRevoker.Revoke(ctx, jti) + } + } +} + // SubscriptionInfo is the /v1/me subscription summary provided by this module. type SubscriptionInfo struct { PlanCode string `json:"plan_code"` diff --git a/server/internal/devices/service_test.go b/server/internal/devices/service_test.go index 6f993f1..ecbede0 100644 --- a/server/internal/devices/service_test.go +++ b/server/internal/devices/service_test.go @@ -249,8 +249,8 @@ func TestUserIDContextRoundTrip(t *testing.T) { func TestNoopRevokerRecordsCalls(t *testing.T) { n := &NoopRevoker{} - _ = n.RevokeForUser(context.Background(), 7, "device_deleted") - if len(n.Calls) != 1 || n.Calls[0].UserID != 7 || n.Calls[0].Reason != "device_deleted" { + _ = n.RevokeDevice(context.Background(), "dp-uuid-123") + if len(n.Calls) != 1 || n.Calls[0] != "dp-uuid-123" { t.Fatalf("unexpected calls: %+v", n.Calls) } } diff --git a/server/internal/devices/store.go b/server/internal/devices/store.go index 94f8c5c..4867090 100644 --- a/server/internal/devices/store.go +++ b/server/internal/devices/store.go @@ -19,6 +19,7 @@ type DeviceRow struct { LastSeen sql.NullTime CreatedAt time.Time ClientVersion sql.NullString // 000016: latest reported app version + DpUUID sql.NullString // per-device data-plane credential (000015) } // effSub is an active-or-expired subscription joined with its plan, used by the @@ -80,13 +81,31 @@ func (s *Store) ListByUser(ctx context.Context, userID int64) ([]DeviceRow, erro // Returns (nil, nil) when the device does not exist. func (s *Store) findDeviceByUUIDTx(ctx context.Context, tx *sql.Tx, uuid string) (*DeviceRow, error) { row := tx.QueryRowContext(ctx, - `SELECT id, uuid, user_id, name, platform, last_seen, created_at + `SELECT id, uuid, user_id, name, platform, last_seen, created_at, client_version, dp_uuid FROM devices WHERE uuid=? `+s.dialect.LockForUpdate(), 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) { + 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) + return scanDeviceRow(row) +} + +type rowScanner interface { + Scan(dest ...any) error +} + +func scanDeviceRow(row rowScanner) (*DeviceRow, error) { var d DeviceRow - if err := row.Scan(&d.ID, &d.UUID, &d.UserID, &d.Name, &d.Platform, &d.LastSeen, &d.CreatedAt); err == sql.ErrNoRows { + if err := row.Scan(&d.ID, &d.UUID, &d.UserID, &d.Name, &d.Platform, + &d.LastSeen, &d.CreatedAt, &d.ClientVersion, &d.DpUUID); err == sql.ErrNoRows { return nil, nil } else if err != nil { - return nil, fmt.Errorf("store.findDeviceByUUIDTx: %w", err) + return nil, fmt.Errorf("store.scanDeviceRow: %w", err) } return &d, nil } diff --git a/server/internal/nodes/grpc_test.go b/server/internal/nodes/grpc_test.go index 4eab717..6b6cd8a 100644 --- a/server/internal/nodes/grpc_test.go +++ b/server/internal/nodes/grpc_test.go @@ -109,6 +109,10 @@ func (m *mockNodeStore) DeleteCredential(_ context.Context, _ int64, _ string) e return nil } +func (m *mockNodeStore) NodesHoldingCredential(_ context.Context, _ string) ([]nodes.CredentialLocation, error) { + return nil, nil +} + func (m *mockNodeStore) AccumulateUsage(_ context.Context, userID int64, date time.Time, bytesUp, bytesDown, minutes int64, ) error { diff --git a/server/internal/nodes/service.go b/server/internal/nodes/service.go index 4da557f..aec5183 100644 --- a/server/internal/nodes/service.go +++ b/server/internal/nodes/service.go @@ -1,6 +1,8 @@ package nodes import ( + "context" + "github.com/redis/go-redis/v9" "github.com/wangjia/pangolin/server/internal/mtls" @@ -54,6 +56,28 @@ func (s *Service) Store() NodeStore { return s.store } +// RevokeDevice recalls a per-device data-plane credential: it pushes a Revoke +// command to every node currently holding the dp_uuid (so sing-box drops that +// user) and removes the connect_credentials rows. Satisfies devices.CredentialRevoker. +// Best-effort: a queued Push is replayed when an offline node reconnects. +func (s *Service) RevokeDevice(ctx context.Context, dpUUID string) error { + if dpUUID == "" { + return nil + } + locs, err := s.store.NodesHoldingCredential(ctx, dpUUID) + if err != nil { + return err + } + for _, loc := range locs { + _ = s.hub.Push(ctx, loc.NodeUUID, &agentv1.Command{ + Type: agentv1.CommandTypeRevoke, + Revoke: &agentv1.RevokePayload{DpUUID: dpUUID}, + }) + _ = s.store.DeleteCredential(ctx, loc.NodeID, dpUUID) + } + return nil +} + // Load exposes the LoadCache for callers that display per-node load metrics. func (s *Service) Load() *LoadCache { return s.load diff --git a/server/internal/nodes/store.go b/server/internal/nodes/store.go index 69e468d..1213c77 100644 --- a/server/internal/nodes/store.go +++ b/server/internal/nodes/store.go @@ -69,6 +69,10 @@ type NodeStore interface { // DeleteCredential removes the credential for (nodeID, dpUUID). DeleteCredential(ctx context.Context, nodeID int64, dpUUID string) error + // NodesHoldingCredential returns the nodes (id+uuid) that currently hold a + // connect_credentials row for dpUUID — the targets of a per-device revoke. + NodesHoldingCredential(ctx context.Context, dpUUID string) ([]CredentialLocation, error) + // UserIDByDpUUID maps a data-plane UUID to the owning user's internal ID. // Returns (0, false, nil) if the dp_uuid is unknown or the user is inactive. UserIDByDpUUID(ctx context.Context, dpUUID string) (int64, bool, error) @@ -290,6 +294,32 @@ func (s *SQLNodeStore) PersistCredential(ctx context.Context, nodeID int64, cred return nil } +// CredentialLocation identifies a node holding a given dp_uuid credential. +type CredentialLocation struct { + NodeID int64 + NodeUUID string +} + +// NodesHoldingCredential lists the nodes that currently hold dpUUID. +func (s *SQLNodeStore) NodesHoldingCredential(ctx context.Context, dpUUID string) ([]CredentialLocation, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT cc.node_id, n.uuid FROM connect_credentials cc + JOIN nodes n ON n.id = cc.node_id WHERE cc.dp_uuid = ?`, dpUUID) + if err != nil { + return nil, fmt.Errorf("nodes.SQLNodeStore.NodesHoldingCredential: %w", err) + } + defer rows.Close() + var out []CredentialLocation + for rows.Next() { + var loc CredentialLocation + if err := rows.Scan(&loc.NodeID, &loc.NodeUUID); err != nil { + return nil, fmt.Errorf("nodes.SQLNodeStore.NodesHoldingCredential scan: %w", err) + } + out = append(out, loc) + } + return out, rows.Err() +} + // DeleteCredential removes the credential for (nodeID, dpUUID). func (s *SQLNodeStore) DeleteCredential(ctx context.Context, nodeID int64, dpUUID string) error { if _, err := s.db.ExecContext(ctx, diff --git a/server/internal/store/devices_list_test.go b/server/internal/store/devices_list_test.go index dee57cc..05422fd 100644 --- a/server/internal/store/devices_list_test.go +++ b/server/internal/store/devices_list_test.go @@ -27,7 +27,7 @@ func TestSQLite_ListDevices_OnlineAndLastLogin(t *testing.T) { db.Exec(`INSERT INTO sessions (user_id, device_id, refresh_jti, created_at) VALUES (1,10,'j',?)`, now) svc := devices.NewService(devices.NewStore(db), nil) - svc.SetLastLoginSource(sessions.NewStore(db)) + svc.SetSessionPort(sessions.NewStore(db)) list, apiErr := svc.ListDevices(ctx, 1) if apiErr != nil { diff --git a/server/internal/store/devices_logout_test.go b/server/internal/store/devices_logout_test.go new file mode 100644 index 0000000..7b5799a --- /dev/null +++ b/server/internal/store/devices_logout_test.go @@ -0,0 +1,72 @@ +package store_test + +import ( + "context" + "testing" + + "github.com/wangjia/pangolin/server/internal/devices" + "github.com/wangjia/pangolin/server/internal/sessions" +) + +type fakeJTIRevoker struct{ revoked []string } + +func (f *fakeJTIRevoker) Revoke(_ context.Context, jti string) error { + f.revoked = append(f.revoked, jti) + return nil +} + +func TestSQLite_ForceLogoutAndDelete(t *testing.T) { + ctx := context.Background() + db := openSQLite(t) + if _, err := db.Exec(`INSERT INTO users (id, uuid, email, pw_hash, dp_uuid, status) VALUES (1,'u','u@e.com','h','dp','active')`); err != nil { + t.Fatalf("seed user: %v", err) + } + if _, err := db.Exec(`INSERT INTO devices (id, uuid, user_id, name, platform, dp_uuid) VALUES (10,'dev-uuid',1,'Mac','macos','dp-dev')`); err != nil { + t.Fatalf("seed device: %v", err) + } + + ss := sessions.NewStore(db) + ss.Create(ctx, 1, 10, "jti-a", "", "") + ss.Create(ctx, 1, 10, "jti-b", "", "") + + jr := &fakeJTIRevoker{} + cr := &devices.NoopRevoker{} + svc := devices.NewService(devices.NewStore(db), cr) + svc.SetSessionPort(ss) + svc.SetJTIRevoker(jr) + + // Force-logout: sessions revoked, JTIs dropped, device kept. + if e := svc.ForceLogout(ctx, 1, "dev-uuid"); e != nil { + t.Fatalf("ForceLogout: %v", e) + } + var active int + db.QueryRow(`SELECT COUNT(*) FROM sessions WHERE device_id=10 AND revoked_at IS NULL`).Scan(&active) + if active != 0 { + t.Fatalf("force-logout left %d active sessions", active) + } + if len(jr.revoked) != 2 { + t.Fatalf("expected 2 JTIs revoked, got %v", jr.revoked) + } + if list, _ := svc.ListDevices(ctx, 1); len(list) != 1 { + t.Fatalf("device should remain after force-logout, got %d", len(list)) + } + + // Ownership / existence guards. + if e := svc.ForceLogout(ctx, 2, "dev-uuid"); e == nil { + t.Fatalf("expected forbidden for other user") + } + if e := svc.ForceLogout(ctx, 1, "nope"); e == nil { + t.Fatalf("expected not found for unknown device") + } + + // Clear-login (delete): device gone + per-device credential revoked by dp_uuid. + if e := svc.DeleteDevice(ctx, 1, "dev-uuid"); e != nil { + t.Fatalf("DeleteDevice: %v", e) + } + if list, _ := svc.ListDevices(ctx, 1); len(list) != 0 { + t.Fatalf("device should be gone, got %d", len(list)) + } + if len(cr.Calls) != 1 || cr.Calls[0] != "dp-dev" { + t.Fatalf("expected dp-dev credential revoke, got %v", cr.Calls) + } +} diff --git a/server/migrations/mysql/000016_sessions_and_device_meta.up.sql b/server/migrations/mysql/000016_sessions_and_device_meta.up.sql index 64c3c90..2c60b14 100644 --- a/server/migrations/mysql/000016_sessions_and_device_meta.up.sql +++ b/server/migrations/mysql/000016_sessions_and_device_meta.up.sql @@ -11,7 +11,9 @@ CREATE TABLE sessions ( revoked_at DATETIME(6) NULL, UNIQUE KEY uk_sessions_jti (refresh_jti), INDEX idx_sessions_user (user_id), - INDEX idx_sessions_device (device_id) + INDEX idx_sessions_device (device_id), + CONSTRAINT fk_sessions_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + CONSTRAINT fk_sessions_device FOREIGN KEY (device_id) REFERENCES devices(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ALTER TABLE devices ADD COLUMN client_version VARCHAR(64) NULL; diff --git a/server/migrations/sqlite/000016_sessions_and_device_meta.up.sql b/server/migrations/sqlite/000016_sessions_and_device_meta.up.sql index cd01dad..bb7e6f9 100644 --- a/server/migrations/sqlite/000016_sessions_and_device_meta.up.sql +++ b/server/migrations/sqlite/000016_sessions_and_device_meta.up.sql @@ -19,8 +19,8 @@ CREATE TABLE sessions ( created_at DATETIME NOT NULL, last_active DATETIME NULL, revoked_at DATETIME NULL, - FOREIGN KEY (user_id) REFERENCES users(id), - FOREIGN KEY (device_id) REFERENCES devices(id) + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (device_id) REFERENCES devices(id) ON DELETE CASCADE ); CREATE INDEX idx_sessions_user ON sessions (user_id); CREATE INDEX idx_sessions_device ON sessions (device_id);