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