feat(devices): P3 强制退出 + 清除增强(per-device 凭证吊销)
ci-pangolin / Lint — shellcheck (push) Successful in 9s
ci-pangolin / OpenAPI Sync Check (push) Successful in 17s
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (push) Successful in 5s
ci-pangolin / Flutter — analyze + test (push) Successful in 26s
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (push) Successful in 5s
ci-pangolin / Codegen Drift — token 生成物未漂移 (push) Successful in 5s
ci-pangolin / Go — build + test (push) Successful in 12s
ci-pangolin / E2E Smoke — L4 进程级端到端 (push) Successful in 15s
ci-pangolin / Go — integration (mysql/redis testcontainers) (push) Failing after 4m4s
ci-pangolin / Golden — 视觉回归 (components + auth) (push) Successful in 15s

后端:新端点 POST /v1/me/devices/{uuid}/logout(ForceLogout:吊销该设备会话+
丢 Redis JTI,设备留列表)。DeleteDevice 增强:先吊销会话再删设备(FK ON DELETE
CASCADE 清理会话行)+ 按 dp_uuid 吊销数据面凭证。CredentialRevoker 接口改
per-device RevokeDevice(dpUUID),由 nodes.Service 实现(查 connect_credentials
持有节点→推 CommandTypeRevoke + 删凭证行),main 注入替 NoopRevoker;devices 注入
SessionPort/JTIRevoker。修 SQLite 跨连接死锁(会话吊销移到 delete tx 之前)。
migration 000016 sessions FK 加 ON DELETE CASCADE。
客户端:account_api.forceLogout + devicesProvider.forceLogout(UI 留 P6)。
测试:ForceLogout(吊销会话+JTI+设备保留+403/404)+ DeleteDevice(级联+按 dp_uuid
吊销);NoopRevoker 改 dp_uuid;全量 server/flutter 测试绿。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-29 06:01:41 +08:00
parent 2f298f0a0a
commit bcc114088c
16 changed files with 325 additions and 74 deletions
+6 -1
View File
@@ -56,9 +56,14 @@ class AccountApi {
return raw.map((e) => Device.fromJson(e as Map<String, dynamic>)).toList(); return raw.map((e) => Device.fromJson(e as Map<String, dynamic>)).toList();
} }
/// DELETE /v1/me/devices/{uuid} — 移除设备 /// DELETE /v1/me/devices/{uuid} — 清除登录信息(移除设备 + 吊销会话/凭证)
Future<void> removeDevice(String uuid) => _c.delete('/v1/me/devices/$uuid'); Future<void> removeDevice(String uuid) => _c.delete('/v1/me/devices/$uuid');
/// POST /v1/me/devices/{uuid}/logout — 强制退出(吊销该设备会话,设备留列表)。
Future<void> forceLogout(String uuid) async {
await _c.postJson('/v1/me/devices/$uuid/logout');
}
/// GET /v1/usage?days=N — 最近 N 天用量(默认 7,后端范围 [1,90])。 /// GET /v1/usage?days=N — 最近 N 天用量(默认 7,后端范围 [1,90])。
Future<List<UsagePoint>> usage({int days = 7}) async { Future<List<UsagePoint>> usage({int days = 7}) async {
final body = await _c.getJson('/v1/usage?days=$days'); final body = await _c.getJson('/v1/usage?days=$days');
+8 -1
View File
@@ -79,12 +79,19 @@ class DevicesNotifier extends AsyncNotifier<List<Device>> {
return ref.read(accountApiProvider).devices(); return ref.read(accountApiProvider).devices();
} }
/// 移除设备(DELETE)后刷新列表。 /// 清除登录信息(DELETE:移除设备 + 吊销会话/凭证)后刷新列表。
Future<void> remove(String uuid) async { Future<void> remove(String uuid) async {
await ref.read(accountApiProvider).removeDevice(uuid); await ref.read(accountApiProvider).removeDevice(uuid);
ref.invalidateSelf(); ref.invalidateSelf();
await future; await future;
} }
/// 强制退出(吊销该设备会话,设备保留)后刷新列表。
Future<void> forceLogout(String uuid) async {
await ref.read(accountApiProvider).forceLogout(uuid);
ref.invalidateSelf();
await future;
}
} }
final devicesProvider = final devicesProvider =
@@ -34,11 +34,11 @@
- [ ] 验收:列表显示在线/版本/最后登录(待 P6 UI + 端到端);停 agent ~3min 转离线 - [ ] 验收:列表显示在线/版本/最后登录(待 P6 UI + 端到端);停 agent ~3min 转离线
## P3 · 两个操作(强制退出 + 清除增强) ## P3 · 两个操作(强制退出 + 清除增强)
- [ ] 新端点 `POST /v1/me/devices/{uuid}/logout`handler + `Service.ForceLogout`):`sessions.RevokeByDevice` + 逐 jti `TokenManager.Revoke` - [x] 新端点 `POST /v1/me/devices/{uuid}/logout`handler + `Service.ForceLogout`):`sessions.RevokeByDevice` + 逐 jti `TokenManager.Revoke`
- [ ] `DeleteDevice` 增强:加 `sessions.RevokeByDevice``CredentialRevoker` 改 per-device - [x] `DeleteDevice` 增强:加 `sessions.RevokeByDevice``CredentialRevoker` 改 per-device
- [ ] `CredentialRevoker` 接口改 `RevokeDevice(ctx, dpUUID)``nodes.Hub` 实现;`main.go` 注入 hub 替 NoopRevoker - [x] `CredentialRevoker` 接口改 `RevokeDevice(ctx, dpUUID)``nodes.Hub` 实现;`main.go` 注入 hub 替 NoopRevoker
- [ ] 客户端 `account_api.forceLogout` + `devicesProvider.forceLogout` - [x] 客户端 `account_api.forceLogout` + `devicesProvider.forceLogout`
- [ ] 测试:ForceLogout 删 jti+标 revoked_atDeleteDevice per-device revoke - [x] 测试:ForceLogout 删 jti+标 revoked_atDeleteDevice per-device revoke
- [ ] 验收:强制退出→refresh 失败;清除→设备消失+凭证作废 - [ ] 验收:强制退出→refresh 失败;清除→设备消失+凭证作废
## P4 · 每设备流量验证 ## P4 · 每设备流量验证
+11 -3
View File
@@ -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 devicesSvc := devices.NewService(devicesStore, nil) // NoopRevoker for MVP
devicesHandler := devices.NewHandler(devicesSvc) devicesHandler := devices.NewHandler(devicesSvc)
// Sessions: login sessions bound to (device, refresh JTI) — P2. Shared by // Sessions: login sessions bound to (device, refresh JTI) — P2/P3. Shared by
// auth (create/rotate/revoke) and devices (last-login display). // auth (create/rotate/revoke) and devices (last-login display + force-logout).
sessionStore := sessions.NewStore(sqlDB) 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 ────────────────────────────────────────────────────────────────── // ── Auth ──────────────────────────────────────────────────────────────────
var authHandler *auth.Handler var authHandler *auth.Handler
@@ -207,7 +207,13 @@ func TestFullChain(t *testing.T) {
t.Fatalf("expected 1 device after re-register, got %d", len(list)) 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 { if apiErr := svc.DeleteDevice(ctx, userID, devUUID); apiErr != nil {
t.Fatalf("DeleteDevice: %v", apiErr) t.Fatalf("DeleteDevice: %v", apiErr)
} }
@@ -226,8 +232,8 @@ func TestFullChain(t *testing.T) {
if len(revoker.Calls) != 1 { if len(revoker.Calls) != 1 {
t.Fatalf("expected 1 revoke call, got %d", len(revoker.Calls)) t.Fatalf("expected 1 revoke call, got %d", len(revoker.Calls))
} }
if revoker.Calls[0].UserID != userID || revoker.Calls[0].Reason != "device_deleted" { if revoker.Calls[0] != "dp-test-uuid" {
t.Errorf("unexpected revoke call: %+v", revoker.Calls[0]) t.Errorf("unexpected revoke call: %q", revoker.Calls[0])
} }
} }
+20
View File
@@ -22,9 +22,11 @@ func NewHandler(svc *Service) *Handler { return &Handler{svc: svc} }
// //
// GET /devices // GET /devices
// DELETE /devices/{id} // DELETE /devices/{id}
// POST /devices/{id}/logout
func (h *Handler) RegisterRoutes(r chi.Router) { func (h *Handler) RegisterRoutes(r chi.Router) {
r.Get("/devices", h.ListDevices) r.Get("/devices", h.ListDevices)
r.Delete("/devices/{id}", h.DeleteDevice) r.Delete("/devices/{id}", h.DeleteDevice)
r.Post("/devices/{id}/logout", h.ForceLogout)
} }
type listDevicesResponse struct { type listDevicesResponse struct {
@@ -66,3 +68,21 @@ func (h *Handler) DeleteDevice(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent) 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)
}
+106 -52
View File
@@ -10,54 +10,50 @@ import (
"github.com/wangjia/pangolin/server/internal/apierr" "github.com/wangjia/pangolin/server/internal/apierr"
) )
// CredentialRevoker abstracts the node-side credential recall triggered when a // CredentialRevoker recalls a device's data-plane credential (its dp_uuid) on the
// device is removed. // 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
// The data-plane credential model is per-user (users.dp_uuid, doc/02 §3.2): the // import cycle; nodes.Service satisfies it (push Revoke to the holding node(s) +
// node side has no device dimension, so "revoke a device" is realised as // drop the connect_credentials row). Best-effort: node resync reconciles misses.
// "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).
type CredentialRevoker interface { type CredentialRevoker interface {
// RevokeForUser instructs node agents to recall (and lazily re-issue) the RevokeDevice(ctx context.Context, dpUUID string) error
// 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
} }
// NoopRevoker is the default CredentialRevoker used until module #5 lands. // NoopRevoker is the default CredentialRevoker (tests / before nodes wiring).
// It records the last call so callers/tests can assert on it. // It records the dp_uuids passed so callers/tests can assert on it.
type NoopRevoker struct { type NoopRevoker struct {
Calls []RevokeCall Calls []string
} }
// RevokeCall captures the arguments of a RevokeForUser invocation. // RevokeDevice implements CredentialRevoker by recording the dp_uuid.
type RevokeCall struct { func (n *NoopRevoker) RevokeDevice(_ context.Context, dpUUID string) error {
UserID int64 n.Calls = append(n.Calls, dpUUID)
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})
return nil return nil
} }
// LastLoginSource provides per-device last-login times (most recent session // SessionPort provides per-device session reads/revocations. Satisfied by
// created_at). Satisfied by sessions.Store; injected to keep packages decoupled. // sessions.Store; injected to keep packages decoupled.
type LastLoginSource interface { type SessionPort interface {
LastLoginByDevice(ctx context.Context, userID int64) (map[int64]time.Time, error) 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. // Service implements the devices business logic.
type Service struct { type Service struct {
store *Store store *Store
revoker CredentialRevoker revoker CredentialRevoker
lastLogin LastLoginSource // nil until wired (P2) 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 // 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 { func NewService(store *Store, revoker CredentialRevoker) *Service {
if revoker == nil { if revoker == nil {
revoker = &NoopRevoker{} revoker = &NoopRevoker{}
@@ -65,8 +61,15 @@ func NewService(store *Store, revoker CredentialRevoker) *Service {
return &Service{store: store, revoker: revoker} return &Service{store: store, revoker: revoker}
} }
// SetLastLoginSource wires the per-device last-login source (sessions.Store). // SetSessionPort / SetJTIRevoker / SetCredentialRevoker wire collaborators after
func (svc *Service) SetLastLoginSource(s LastLoginSource) { svc.lastLogin = s } // 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. // Device is the API representation of a registered device.
type Device struct { type Device struct {
@@ -104,8 +107,8 @@ func (svc *Service) ListDevices(ctx context.Context, userID int64) ([]Device, *a
return nil, apierr.ErrInternal return nil, apierr.ErrInternal
} }
var lastLogin map[int64]time.Time var lastLogin map[int64]time.Time
if svc.lastLogin != nil { if svc.sessions != nil {
if m, e := svc.lastLogin.LastLoginByDevice(ctx, userID); e == nil { if m, e := svc.sessions.LastLoginByDevice(ctx, userID); e == nil {
lastLogin = m lastLogin = m
} }
} }
@@ -230,6 +233,24 @@ func (svc *Service) DeleteDevice(ctx context.Context, userID int64, deviceUUID s
return apierr.ErrBadRequest 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) tx, err := svc.store.BeginTx(ctx)
if err != nil { if err != nil {
return apierr.ErrInternal 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 { if err := svc.store.deleteDeviceTx(ctx, tx, dev.ID); err != nil {
return apierr.ErrInternal return apierr.ErrInternal
} }
@@ -265,16 +275,60 @@ func (svc *Service) DeleteDevice(ctx context.Context, userID int64, deviceUUID s
} }
committed = true committed = true
// Recall the user's data-plane credential. Per the dp_uuid model this is a // Recall the device's data-plane credential so a live connection is dropped
// per-user operation (no device dimension on the node side). Best-effort: // too. Best-effort: the device row is already gone; a transient failure is
// the device is already removed; a transient revoke failure is reconciled // reconciled by node resync, so we never fail the request. (Sessions were
// by the node agent's periodic resync, so we do not fail the request. // already revoked above, before the cascade removed their rows.)
if err := svc.revoker.RevokeForUser(ctx, userID, "device_deleted"); err != nil { if dev.DpUUID.Valid && dev.DpUUID.String != "" {
_ = err if err := svc.revoker.RevokeDevice(ctx, dev.DpUUID.String); err != nil {
_ = err
}
} }
return nil 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. // SubscriptionInfo is the /v1/me subscription summary provided by this module.
type SubscriptionInfo struct { type SubscriptionInfo struct {
PlanCode string `json:"plan_code"` PlanCode string `json:"plan_code"`
+2 -2
View File
@@ -249,8 +249,8 @@ func TestUserIDContextRoundTrip(t *testing.T) {
func TestNoopRevokerRecordsCalls(t *testing.T) { func TestNoopRevokerRecordsCalls(t *testing.T) {
n := &NoopRevoker{} n := &NoopRevoker{}
_ = n.RevokeForUser(context.Background(), 7, "device_deleted") _ = n.RevokeDevice(context.Background(), "dp-uuid-123")
if len(n.Calls) != 1 || n.Calls[0].UserID != 7 || n.Calls[0].Reason != "device_deleted" { if len(n.Calls) != 1 || n.Calls[0] != "dp-uuid-123" {
t.Fatalf("unexpected calls: %+v", n.Calls) t.Fatalf("unexpected calls: %+v", n.Calls)
} }
} }
+22 -3
View File
@@ -19,6 +19,7 @@ type DeviceRow struct {
LastSeen sql.NullTime LastSeen sql.NullTime
CreatedAt time.Time CreatedAt time.Time
ClientVersion sql.NullString // 000016: latest reported app version 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 // 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. // Returns (nil, nil) when the device does not exist.
func (s *Store) findDeviceByUUIDTx(ctx context.Context, tx *sql.Tx, uuid string) (*DeviceRow, error) { func (s *Store) findDeviceByUUIDTx(ctx context.Context, tx *sql.Tx, uuid string) (*DeviceRow, error) {
row := tx.QueryRowContext(ctx, 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) 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 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 return nil, nil
} else if err != nil { } else if err != nil {
return nil, fmt.Errorf("store.findDeviceByUUIDTx: %w", err) return nil, fmt.Errorf("store.scanDeviceRow: %w", err)
} }
return &d, nil return &d, nil
} }
+4
View File
@@ -109,6 +109,10 @@ func (m *mockNodeStore) DeleteCredential(_ context.Context, _ int64, _ string) e
return nil 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, func (m *mockNodeStore) AccumulateUsage(_ context.Context, userID int64, date time.Time,
bytesUp, bytesDown, minutes int64, bytesUp, bytesDown, minutes int64,
) error { ) error {
+24
View File
@@ -1,6 +1,8 @@
package nodes package nodes
import ( import (
"context"
"github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9"
"github.com/wangjia/pangolin/server/internal/mtls" "github.com/wangjia/pangolin/server/internal/mtls"
@@ -54,6 +56,28 @@ func (s *Service) Store() NodeStore {
return s.store 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. // Load exposes the LoadCache for callers that display per-node load metrics.
func (s *Service) Load() *LoadCache { func (s *Service) Load() *LoadCache {
return s.load return s.load
+30
View File
@@ -69,6 +69,10 @@ type NodeStore interface {
// DeleteCredential removes the credential for (nodeID, dpUUID). // DeleteCredential removes the credential for (nodeID, dpUUID).
DeleteCredential(ctx context.Context, nodeID int64, dpUUID string) error 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. // 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. // Returns (0, false, nil) if the dp_uuid is unknown or the user is inactive.
UserIDByDpUUID(ctx context.Context, dpUUID string) (int64, bool, error) 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 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). // DeleteCredential removes the credential for (nodeID, dpUUID).
func (s *SQLNodeStore) DeleteCredential(ctx context.Context, nodeID int64, dpUUID string) error { func (s *SQLNodeStore) DeleteCredential(ctx context.Context, nodeID int64, dpUUID string) error {
if _, err := s.db.ExecContext(ctx, if _, err := s.db.ExecContext(ctx,
+1 -1
View File
@@ -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) 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 := devices.NewService(devices.NewStore(db), nil)
svc.SetLastLoginSource(sessions.NewStore(db)) svc.SetSessionPort(sessions.NewStore(db))
list, apiErr := svc.ListDevices(ctx, 1) list, apiErr := svc.ListDevices(ctx, 1)
if apiErr != nil { if apiErr != nil {
@@ -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)
}
}
@@ -11,7 +11,9 @@ CREATE TABLE sessions (
revoked_at DATETIME(6) NULL, revoked_at DATETIME(6) NULL,
UNIQUE KEY uk_sessions_jti (refresh_jti), UNIQUE KEY uk_sessions_jti (refresh_jti),
INDEX idx_sessions_user (user_id), 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; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
ALTER TABLE devices ADD COLUMN client_version VARCHAR(64) NULL; ALTER TABLE devices ADD COLUMN client_version VARCHAR(64) NULL;
@@ -19,8 +19,8 @@ CREATE TABLE sessions (
created_at DATETIME NOT NULL, created_at DATETIME NOT NULL,
last_active DATETIME NULL, last_active DATETIME NULL,
revoked_at DATETIME NULL, revoked_at DATETIME NULL,
FOREIGN KEY (user_id) REFERENCES users(id), FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (device_id) REFERENCES devices(id) FOREIGN KEY (device_id) REFERENCES devices(id) ON DELETE CASCADE
); );
CREATE INDEX idx_sessions_user ON sessions (user_id); CREATE INDEX idx_sessions_user ON sessions (user_id);
CREATE INDEX idx_sessions_device ON sessions (device_id); CREATE INDEX idx_sessions_device ON sessions (device_id);