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
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:
@@ -56,9 +56,14 @@ class AccountApi {
|
||||
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');
|
||||
|
||||
/// 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])。
|
||||
Future<List<UsagePoint>> usage({int days = 7}) async {
|
||||
final body = await _c.getJson('/v1/usage?days=$days');
|
||||
|
||||
@@ -79,12 +79,19 @@ class DevicesNotifier extends AsyncNotifier<List<Device>> {
|
||||
return ref.read(accountApiProvider).devices();
|
||||
}
|
||||
|
||||
/// 移除设备(DELETE)后刷新列表。
|
||||
/// 清除登录信息(DELETE:移除设备 + 吊销会话/凭证)后刷新列表。
|
||||
Future<void> remove(String uuid) async {
|
||||
await ref.read(accountApiProvider).removeDevice(uuid);
|
||||
ref.invalidateSelf();
|
||||
await future;
|
||||
}
|
||||
|
||||
/// 强制退出(吊销该设备会话,设备保留)后刷新列表。
|
||||
Future<void> forceLogout(String uuid) async {
|
||||
await ref.read(accountApiProvider).forceLogout(uuid);
|
||||
ref.invalidateSelf();
|
||||
await future;
|
||||
}
|
||||
}
|
||||
|
||||
final devicesProvider =
|
||||
|
||||
@@ -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 · 每设备流量验证
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user