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
@@ -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])
}
}
+20
View File
@@ -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)
}
+106 -52
View File
@@ -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"`
+2 -2
View File
@@ -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)
}
}
+22 -3
View File
@@ -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
}