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();
}
/// 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');
+8 -1
View File
@@ -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_atDeleteDevice 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_atDeleteDevice per-device revoke
- [ ] 验收:强制退出→refresh 失败;清除→设备消失+凭证作废
## 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
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])
}
}
+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
}
+4
View File
@@ -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 {
+24
View File
@@ -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
+30
View File
@@ -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,
+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)
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);