stable-deviceid 1.0.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +89 -84
- package/README.zh-CN.md +197 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -1,34 +1,32 @@
|
|
|
1
1
|
# stable-deviceid
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
结构化稳定设备 ID(生成 / 校验 / 自愈)、canvas + WebGL 设备指纹(默认关闭)、
|
|
5
|
-
HTTP 响应头身份同步、SSO 跨 origin 身份归一、axios 一站式接入。
|
|
3
|
+
[English](./README.md) | [简体中文](./README.zh-CN.md)
|
|
6
4
|
|
|
7
|
-
|
|
8
|
-
> 时钟偏差不引起身份漂移(±5 分钟容差)、多标签页实时同步、同设备跨应用(SSO iframe)归一为同一身份。
|
|
5
|
+
Stable, self-healing device identity for modern web applications.
|
|
9
6
|
|
|
10
|
-
|
|
7
|
+
`stable-deviceid` gives your web app a **durable device identifier** that survives the things that normally break it: storage cleared by the user or by Safari ITP, clock skew between client and server, multi-tab races, and per-origin identity fragmentation in iframe-based SSO. It ships with an optional, privacy-first device fingerprint and a one-line axios integration.
|
|
11
8
|
|
|
12
|
-
|
|
13
|
-
时间戳位混淆 + Base62 编码 + 高熵随机后缀(`crypto.getRandomValues` 拒绝采样)
|
|
14
|
-
- 🔁 **自愈能力**:损坏 / 老格式 / 过期 / 未来时间的存量 ID 自动重生;服务端校验失败自动收敛(一轮往返)
|
|
15
|
-
- 🔄 **响应头同步**:服务端可下发权威 ID(`X-Device-Id`),客户端写回后下一请求立即生效
|
|
16
|
-
- 🧩 **SSO 跨 origin 归一**:oauth21 式 iframe 登录场景,子应用采纳权威域 ID,同物理设备全端同身份
|
|
17
|
-
- 🛡 **安全内建**:与后端校验规则逐条对齐、脏值拒绝、超长头忽略、日志截断、SSO 消息双重校验
|
|
18
|
-
- 🤫 **隐私友好**:设备指纹默认关闭,需显式启用;存储层静默降级,不抛异常
|
|
19
|
-
- 📦 **零依赖**:ESM-only、tree-shaking 友好(`sideEffects: false`)、TypeScript 类型完备
|
|
9
|
+
## Features
|
|
20
10
|
|
|
21
|
-
|
|
11
|
+
- **Structured device ID** — `{PLATFORM}-{ENCODED_TS}-{RANDOM}` (e.g. `WEB-DaBOSbNdSuc-8s4T`): obfuscated millisecond timestamp (64-bit magic XOR → Base62), 6-char high-entropy random suffix via `crypto.getRandomValues` with rejection sampling
|
|
12
|
+
- **Self-healing** — corrupted / legacy-format / expired / future-dated stored IDs are regenerated locally; server-side rejection converges within one round trip
|
|
13
|
+
- **Response-header sync** — the backend can push an authoritative ID via `X-Device-Id`; the client persists it and the very next request uses it
|
|
14
|
+
- **SSO identity unification** — in iframe-based SSO, child apps adopt the authoritative ID from the identity domain so one physical device maps to one identity everywhere
|
|
15
|
+
- **Security built in** — local validation mirrors the backend rule-by-rule (platform enum, lengths, Base62 charset, future-time rejection with ±5 min clock-skew tolerance, 365-day max age), dirty-value rejection, oversized-header filtering, log truncation
|
|
16
|
+
- **Privacy-friendly** — canvas + WebGL fingerprinting is **off by default**; storage access degrades silently to an in-memory fallback
|
|
17
|
+
- **Zero dependencies** — ESM-only, tree-shakable (`sideEffects: false`), full TypeScript types, 93 unit tests
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
22
20
|
|
|
23
21
|
```bash
|
|
24
22
|
npm install stable-deviceid
|
|
25
23
|
```
|
|
26
24
|
|
|
27
|
-
|
|
25
|
+
Requires Node.js >= 18 (build tooling only; runtime targets modern browsers).
|
|
28
26
|
|
|
29
|
-
##
|
|
27
|
+
## Quick Start
|
|
30
28
|
|
|
31
|
-
### axios
|
|
29
|
+
### One-line axios integration (recommended)
|
|
32
30
|
|
|
33
31
|
```ts
|
|
34
32
|
import axios from 'axios';
|
|
@@ -38,26 +36,26 @@ const http = axios.create({ baseURL: '/api', withCredentials: true });
|
|
|
38
36
|
setupDeviceSync(http);
|
|
39
37
|
```
|
|
40
38
|
|
|
41
|
-
`setupDeviceSync`
|
|
39
|
+
`setupDeviceSync` registers everything for you:
|
|
42
40
|
|
|
43
|
-
1.
|
|
44
|
-
2.
|
|
45
|
-
3.
|
|
46
|
-
4.
|
|
41
|
+
1. A request interceptor injecting the `x-device-id` header (served from an in-memory cache — effectively zero overhead)
|
|
42
|
+
2. The device-fingerprint header `x-device-fp` when enabled (see [Device Fingerprinting](#device-fingerprinting))
|
|
43
|
+
3. A response interceptor syncing the device ID pushed via response headers
|
|
44
|
+
4. Cross-tab synchronization via the `storage` event (idempotent)
|
|
47
45
|
|
|
48
46
|
```ts
|
|
49
|
-
//
|
|
47
|
+
// Options
|
|
50
48
|
setupDeviceSync(http, {
|
|
51
|
-
fingerprint: true, //
|
|
52
|
-
onDeviceIdChange: (oldId, newId) => { /*
|
|
49
|
+
fingerprint: true, // force-enable fingerprinting (default: environment-based)
|
|
50
|
+
onDeviceIdChange: (oldId, newId) => { /* react to identity changes */ }
|
|
53
51
|
});
|
|
54
52
|
|
|
55
|
-
//
|
|
53
|
+
// Disposal for HMR / tests
|
|
56
54
|
const dispose = setupDeviceSync(http);
|
|
57
55
|
dispose();
|
|
58
56
|
```
|
|
59
57
|
|
|
60
|
-
###
|
|
58
|
+
### Native fetch / explicit headers
|
|
61
59
|
|
|
62
60
|
```ts
|
|
63
61
|
import { getDeviceHeaders } from 'stable-deviceid';
|
|
@@ -65,15 +63,15 @@ import { getDeviceHeaders } from 'stable-deviceid';
|
|
|
65
63
|
fetch('/api/profile', { headers: { ...getDeviceHeaders() } });
|
|
66
64
|
```
|
|
67
65
|
|
|
68
|
-
###
|
|
66
|
+
### Manual interceptors (fine-grained control)
|
|
69
67
|
|
|
70
68
|
```ts
|
|
71
69
|
import {
|
|
72
|
-
getStableDeviceId, //
|
|
73
|
-
getDeviceFingerprint, //
|
|
74
|
-
isDeviceFingerprintEnabled, //
|
|
75
|
-
handleDeviceSyncInResponse, //
|
|
76
|
-
initDeviceSync //
|
|
70
|
+
getStableDeviceId, // stable device ID (persisted + in-memory cache)
|
|
71
|
+
getDeviceFingerprint, // fingerprint (async, 32-char hex)
|
|
72
|
+
isDeviceFingerprintEnabled, // fingerprint toggle
|
|
73
|
+
handleDeviceSyncInResponse, // response-header sync (pass the axios response)
|
|
74
|
+
initDeviceSync // global init (cross-tab listener, idempotent)
|
|
77
75
|
} from 'stable-deviceid';
|
|
78
76
|
|
|
79
77
|
http.interceptors.request.use(async config => {
|
|
@@ -81,7 +79,7 @@ http.interceptors.request.use(async config => {
|
|
|
81
79
|
if (isDeviceFingerprintEnabled()) {
|
|
82
80
|
try {
|
|
83
81
|
config.headers['x-device-fp'] = await getDeviceFingerprint();
|
|
84
|
-
} catch { /*
|
|
82
|
+
} catch { /* fingerprinting must never break the request */ }
|
|
85
83
|
}
|
|
86
84
|
return config;
|
|
87
85
|
});
|
|
@@ -92,76 +90,83 @@ http.interceptors.response.use(response => {
|
|
|
92
90
|
});
|
|
93
91
|
```
|
|
94
92
|
|
|
95
|
-
## API
|
|
93
|
+
## API Reference
|
|
96
94
|
|
|
97
|
-
|
|
|
95
|
+
| Export | Description |
|
|
98
96
|
| --- | --- |
|
|
99
|
-
| `setupDeviceSync(instance, options?)` |
|
|
100
|
-
| `getDeviceHeaders()` |
|
|
101
|
-
| `getStableDeviceId()` |
|
|
102
|
-
| `getCurrentDeviceId()` / `setDeviceId(id)` / `clearDeviceId()` |
|
|
103
|
-
| `adoptDeviceId(id)` |
|
|
104
|
-
| `validateDeviceIdFormat(id)` |
|
|
105
|
-
| `parseDeviceId(id)` |
|
|
106
|
-
| `syncDeviceFromHeaders(headers, options?)` |
|
|
107
|
-
| `handleDeviceSyncInResponse(response, options?)` |
|
|
108
|
-
| `initDeviceSync(options?)` |
|
|
109
|
-
| `getDeviceFingerprint()` / `isDeviceFingerprintEnabled()` |
|
|
110
|
-
| `sha256(message)` | SHA-256
|
|
111
|
-
| `getDeviceIdStats()` |
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
iframe 嵌入 SSO 登录页(如 oauth21)时,登录成功消息中携带权威域设备 ID,
|
|
119
|
-
子应用在 bindSession 之前采纳,同物理设备即归一为同一身份:
|
|
97
|
+
| `setupDeviceSync(instance, options?)` | One-line axios integration; returns a dispose function |
|
|
98
|
+
| `getDeviceHeaders()` | Synchronous `{ 'x-device-id': id }` for fetch / explicit headers |
|
|
99
|
+
| `getStableDeviceId()` | Stable device ID (generated once, then reused) |
|
|
100
|
+
| `getCurrentDeviceId()` / `setDeviceId(id)` / `clearDeviceId()` | Read / validated write / clear |
|
|
101
|
+
| `adoptDeviceId(id)` | Adopt an SSO-pushed authoritative ID (format + platform checks, cross-origin unification) |
|
|
102
|
+
| `validateDeviceIdFormat(id)` | Local format validation → `{ valid, reason? }` |
|
|
103
|
+
| `parseDeviceId(id)` | Parse ID metadata (platform / timestamp / age); `null` when invalid |
|
|
104
|
+
| `syncDeviceFromHeaders(headers, options?)` | Sync from response headers (Headers / AxiosHeaders / plain objects) |
|
|
105
|
+
| `handleDeviceSyncInResponse(response, options?)` | Response-interceptor helper (axios and fetch Response) |
|
|
106
|
+
| `initDeviceSync(options?)` | Global init: cross-tab listener + change callback (idempotent) |
|
|
107
|
+
| `getDeviceFingerprint()` / `isDeviceFingerprintEnabled()` | Fingerprint collection (promise-cached, concurrent-safe) and toggle |
|
|
108
|
+
| `sha256(message)` | SHA-256 (Web Crypto first, pure-JS fallback on insecure contexts) |
|
|
109
|
+
| `getDeviceIdStats()` | Debug stats (current ID / parsed info / source) |
|
|
110
|
+
|
|
111
|
+
Constants: `STORAGE_KEY`, `MAX_AGE_DAYS` (365), `CLOCK_SKEW_TOLERANCE_MS` (±5 min), `DEVICE_PLATFORMS`, `RANDOM_SUFFIX_LENGTH`.
|
|
112
|
+
|
|
113
|
+
## SSO Identity Unification
|
|
114
|
+
|
|
115
|
+
When your app embeds an SSO login page (e.g. an OAuth authorization iframe) in a different origin, each origin would normally hold its own device ID. On login success, have the identity domain push its authoritative ID inside the `LOGIN_SUCCESS` message and adopt it **before** binding the session, so the bind request already carries the unified identity:
|
|
120
116
|
|
|
121
117
|
```ts
|
|
122
118
|
window.addEventListener('message', event => {
|
|
123
|
-
//
|
|
119
|
+
// Always verify event.origin and event.source in production (omitted here)
|
|
124
120
|
if (event.data?.type === 'LOGIN_SUCCESS' && event.data.deviceId) {
|
|
125
|
-
adoptDeviceId(event.data.deviceId); //
|
|
121
|
+
adoptDeviceId(event.data.deviceId); // format + platform validation built in
|
|
126
122
|
}
|
|
127
123
|
});
|
|
128
124
|
```
|
|
129
125
|
|
|
130
|
-
##
|
|
126
|
+
## Device Fingerprinting (off by default)
|
|
131
127
|
|
|
132
128
|
```html
|
|
133
|
-
<!--
|
|
129
|
+
<!-- Option 1: server/template-injected meta toggle -->
|
|
134
130
|
<meta name="device-fp" content="true" />
|
|
135
131
|
```
|
|
136
132
|
|
|
137
133
|
```bash
|
|
138
|
-
#
|
|
134
|
+
# Option 2: Vite environment variable
|
|
139
135
|
VITE_DEVICE_FINGERPRINT=true
|
|
140
136
|
```
|
|
141
137
|
|
|
142
138
|
```ts
|
|
143
|
-
//
|
|
139
|
+
// Option 3: explicit option (recommended for non-Vite bundlers)
|
|
144
140
|
setupDeviceSync(http, { fingerprint: true });
|
|
145
141
|
```
|
|
146
142
|
|
|
147
|
-
|
|
148
|
-
|
|
143
|
+
Collection combines canvas rendering differences and WebGL renderer/vendor strings, hashed with SHA-256 (first 32 hex chars). The result is promise-cached: concurrent calls are deduplicated and a failed collection (headless / anti-fingerprint browsers) returns an empty string instead of a constant hash, preventing false matches.
|
|
144
|
+
|
|
145
|
+
## Security Design
|
|
146
|
+
|
|
147
|
+
- **Backend-aligned validation** — local rules mirror the backend `validateDeviceId` one-to-one; illegitimate stored IDs are regenerated locally, avoiding per-request regeneration loops
|
|
148
|
+
- **Dirty-input defense** — response-header values over 128 chars are ignored, external input in logs is truncated, `setDeviceId` rejects malformed writes
|
|
149
|
+
- **Cache coherence** — any identity write invalidates the in-memory cache immediately; the next request carries the new ID
|
|
150
|
+
- **Graceful degradation** — private browsing / storage failures fall back to an in-memory layer without throwing, never breaking the request chain
|
|
149
151
|
|
|
150
|
-
##
|
|
152
|
+
## Device ID Format
|
|
153
|
+
|
|
154
|
+
```
|
|
155
|
+
{PLATFORM}-{ENCODED_TIMESTAMP}-{RANDOM_SUFFIX}
|
|
156
|
+
Example: WEB-DaBOSbNdSuc-8s4T
|
|
157
|
+
```
|
|
151
158
|
|
|
152
|
-
-
|
|
153
|
-
|
|
154
|
-
-
|
|
155
|
-
- **缓存一致性**:任何来源的 ID 写入后立即失效内存缓存,下一请求即生效
|
|
156
|
-
- **隐私模式降级**:存储不可用时静默降级内存层(会话内稳定),不抛异常不中断请求链路
|
|
159
|
+
- `PLATFORM` — `WEB` / `IOS` / `ANDROID` (detected from the user agent)
|
|
160
|
+
- `ENCODED_TIMESTAMP` — (ms timestamp − 2024-01-01 offset) XOR 64-bit golden-ratio magic → Base62, fixed 11 chars
|
|
161
|
+
- `RANDOM_SUFFIX` — 6-char Base62 (≈35.7 bits of entropy), rejection-sampled
|
|
157
162
|
|
|
158
|
-
##
|
|
163
|
+
## Using Inside the nodeServers Monorepo
|
|
159
164
|
|
|
160
|
-
workspace
|
|
165
|
+
Frontends in this workspace consume the source directly through a Vite alias (development stays bundler-native):
|
|
161
166
|
|
|
162
167
|
```ts
|
|
163
168
|
// vite.config.ts
|
|
164
|
-
resolve: { alias: { stable-deviceid: fileURLToPath(new URL('../packages/shared-device/src/index.ts', import.meta.url)) } }
|
|
169
|
+
resolve: { alias: { 'stable-deviceid': fileURLToPath(new URL('../packages/shared-device/src/index.ts', import.meta.url)) } }
|
|
165
170
|
```
|
|
166
171
|
|
|
167
172
|
```json
|
|
@@ -170,22 +175,22 @@ resolve: { alias: { stable-deviceid: fileURLToPath(new URL('../packages/shared-d
|
|
|
170
175
|
"include": ["src/**/*.ts", "../packages/shared-device/src/**/*.ts"]
|
|
171
176
|
```
|
|
172
177
|
|
|
173
|
-
`
|
|
178
|
+
`"stable-deviceid": "*"` in each app's `package.json` links the workspace package.
|
|
174
179
|
|
|
175
|
-
##
|
|
180
|
+
## Build & Publish (maintainers)
|
|
176
181
|
|
|
177
182
|
```bash
|
|
178
183
|
cd packages/shared-device
|
|
179
|
-
npm run build # esbuild
|
|
180
|
-
npm publish # prepack
|
|
184
|
+
npm run build # esbuild per-module transform → dist/ + tsc emits dist/index.d.ts
|
|
185
|
+
npm publish # prepack rebuilds automatically; ESM-only, access: public
|
|
181
186
|
```
|
|
182
187
|
|
|
183
|
-
##
|
|
188
|
+
## Limitations
|
|
184
189
|
|
|
185
|
-
-
|
|
186
|
-
- `isDeviceFingerprintEnabled`
|
|
187
|
-
- `crypto.getRandomValues
|
|
188
|
-
- Safari ITP
|
|
190
|
+
- In private browsing, storage falls back to a per-session in-memory ID that changes on reload (a `console.warn` is emitted)
|
|
191
|
+
- `isDeviceFingerprintEnabled` relies on `import.meta.env` (Vite) and the DOM; non-Vite consumers should use the meta tag or the `fingerprint` option
|
|
192
|
+
- On very old browsers without `crypto.getRandomValues`, the random suffix falls back to `Math.random` (warned; affects uniqueness only, not security)
|
|
193
|
+
- Safari ITP clears script-writable storage; pair the library with a server-side httpOnly-cookie recovery mechanism for full continuity
|
|
189
194
|
|
|
190
195
|
## License
|
|
191
196
|
|
package/README.zh-CN.md
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# stable-deviceid
|
|
2
|
+
|
|
3
|
+
[English](./README.md) | 简体中文
|
|
4
|
+
|
|
5
|
+
为现代 Web 应用提供**稳定、可自愈**的设备身份标识。
|
|
6
|
+
|
|
7
|
+
`stable-deviceid` 解决浏览器设备标识在实际生产中的全部痛点:用户或 Safari ITP 清除存储、客户端与服务端时钟偏差、多标签页竞态、iframe SSO 下各 origin 身份分裂。内置隐私友好的设备指纹采集(默认关闭)与 axios 一站式接入,一行代码完成全部配置。
|
|
8
|
+
|
|
9
|
+
## 特性
|
|
10
|
+
|
|
11
|
+
- **结构化设备 ID** — `{PLATFORM}-{ENCODED_TS}-{RANDOM}`(如 `WEB-DaBOSbNdSuc-8s4T`):毫秒时间戳位混淆(64 位魔数 XOR → Base62)+ 6 字符高熵随机后缀(`crypto.getRandomValues` 拒绝采样)
|
|
12
|
+
- **自愈能力** — 损坏 / 老格式 / 过期 / 未来时间的存量 ID 本地自动重生;服务端校验失败一轮往返即收敛
|
|
13
|
+
- **响应头同步** — 服务端可通过 `X-Device-Id` 下发权威 ID,客户端持久化后下一个请求立即生效
|
|
14
|
+
- **SSO 身份归一** — iframe SSO 场景下子应用采纳身份域的权威 ID,同一物理设备全端同一身份
|
|
15
|
+
- **安全内建** — 本地校验与后端逐条对齐(平台枚举、长度、Base62 字符集、含 ±5 分钟时钟偏差容差的未来时间拒绝、365 天有效期)、脏值拒绝、超长头过滤、日志截断
|
|
16
|
+
- **隐私友好** — canvas + WebGL 指纹**默认关闭**;存储访问静默降级内存层,不抛异常
|
|
17
|
+
- **零依赖** — ESM-only、tree-shaking 友好(`sideEffects: false`)、TypeScript 类型完备、93 个单元测试
|
|
18
|
+
|
|
19
|
+
## 安装
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npm install stable-deviceid
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
要求 Node.js >= 18(仅构建环境;运行时面向现代浏览器)。
|
|
26
|
+
|
|
27
|
+
## 快速开始
|
|
28
|
+
|
|
29
|
+
### axios 一站式接入(推荐)
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
import axios from 'axios';
|
|
33
|
+
import { setupDeviceSync } from 'stable-deviceid';
|
|
34
|
+
|
|
35
|
+
const http = axios.create({ baseURL: '/api', withCredentials: true });
|
|
36
|
+
setupDeviceSync(http);
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
`setupDeviceSync` 自动完成:
|
|
40
|
+
|
|
41
|
+
1. 请求拦截器注入 `x-device-id` 头(内存缓存直读,近乎零开销)
|
|
42
|
+
2. 按需注入设备指纹头 `x-device-fp`(见[设备指纹](#设备指纹默认关闭隐私友好))
|
|
43
|
+
3. 响应拦截器同步响应头下发的设备 ID
|
|
44
|
+
4. 跨标签页 storage 监听(幂等)
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
// 可选配置
|
|
48
|
+
setupDeviceSync(http, {
|
|
49
|
+
fingerprint: true, // 强制开启指纹(默认按环境判定)
|
|
50
|
+
onDeviceIdChange: (oldId, newId) => { /* 设备身份变更回调 */ }
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// 卸载拦截器(HMR / 单测场景)
|
|
54
|
+
const dispose = setupDeviceSync(http);
|
|
55
|
+
dispose();
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### 原生 fetch / 显式带头
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
import { getDeviceHeaders } from 'stable-deviceid';
|
|
62
|
+
|
|
63
|
+
fetch('/api/profile', { headers: { ...getDeviceHeaders() } });
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### 手动拦截器(细粒度控制)
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
import {
|
|
70
|
+
getStableDeviceId, // 稳定设备 ID(持久化 + 内存缓存)
|
|
71
|
+
getDeviceFingerprint, // 指纹(异步,32 位 hex)
|
|
72
|
+
isDeviceFingerprintEnabled, // 指纹开关判定
|
|
73
|
+
handleDeviceSyncInResponse, // 响应头同步(传 axios response)
|
|
74
|
+
initDeviceSync // 全局初始化(跨标签页监听,幂等)
|
|
75
|
+
} from 'stable-deviceid';
|
|
76
|
+
|
|
77
|
+
http.interceptors.request.use(async config => {
|
|
78
|
+
config.headers['x-device-id'] = getStableDeviceId();
|
|
79
|
+
if (isDeviceFingerprintEnabled()) {
|
|
80
|
+
try {
|
|
81
|
+
config.headers['x-device-fp'] = await getDeviceFingerprint();
|
|
82
|
+
} catch { /* 指纹采集绝不阻断请求 */ }
|
|
83
|
+
}
|
|
84
|
+
return config;
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
http.interceptors.response.use(response => {
|
|
88
|
+
handleDeviceSyncInResponse(response);
|
|
89
|
+
return response;
|
|
90
|
+
});
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## API 参考
|
|
94
|
+
|
|
95
|
+
| 导出 | 说明 |
|
|
96
|
+
| --- | --- |
|
|
97
|
+
| `setupDeviceSync(instance, options?)` | axios 一站式接入,返回 dispose 函数 |
|
|
98
|
+
| `getDeviceHeaders()` | 同步返回 `{ 'x-device-id': id }`,供 fetch / 显式带头 |
|
|
99
|
+
| `getStableDeviceId()` | 稳定设备 ID(首次生成,终身复用) |
|
|
100
|
+
| `getCurrentDeviceId()` / `setDeviceId(id)` / `clearDeviceId()` | 读取 / 校验写入 / 清除 |
|
|
101
|
+
| `adoptDeviceId(id)` | 采纳 SSO 权威域 ID(格式 + 平台段双校验,跨 origin 归一) |
|
|
102
|
+
| `validateDeviceIdFormat(id)` | 本地格式校验 → `{ valid, reason? }` |
|
|
103
|
+
| `parseDeviceId(id)` | 解析 ID 元信息(platform / timestamp / age),非法返回 null |
|
|
104
|
+
| `syncDeviceFromHeaders(headers, options?)` | 响应头同步(兼容 Headers / AxiosHeaders / 普通对象) |
|
|
105
|
+
| `handleDeviceSyncInResponse(response, options?)` | 响应拦截器集成(axios / fetch Response 均可) |
|
|
106
|
+
| `initDeviceSync(options?)` | 全局初始化:跨标签页监听 + 变更回调(幂等) |
|
|
107
|
+
| `getDeviceFingerprint()` / `isDeviceFingerprintEnabled()` | 指纹采集(Promise 缓存、并发去重)与开关判定 |
|
|
108
|
+
| `sha256(message)` | SHA-256(Web Crypto 优先,非安全上下文自动降级纯 JS) |
|
|
109
|
+
| `getDeviceIdStats()` | 调试统计(当前 ID / 解析信息 / 来源) |
|
|
110
|
+
|
|
111
|
+
常量:`STORAGE_KEY`、`MAX_AGE_DAYS`(365)、`CLOCK_SKEW_TOLERANCE_MS`(±5 分钟)、`DEVICE_PLATFORMS`、`RANDOM_SUFFIX_LENGTH`。
|
|
112
|
+
|
|
113
|
+
## SSO 跨 origin 身份归一
|
|
114
|
+
|
|
115
|
+
当应用以 iframe 嵌入不同 origin 的 SSO 登录页(如 OAuth 授权页)时,各 origin 本会各持一份设备 ID。在登录成功消息中由身份域下发权威 ID,子应用在 **bindSession 之前**采纳,绑定请求即携带统一身份:
|
|
116
|
+
|
|
117
|
+
```ts
|
|
118
|
+
window.addEventListener('message', event => {
|
|
119
|
+
// 生产环境务必校验 event.origin 与 event.source(此处省略)
|
|
120
|
+
if (event.data?.type === 'LOGIN_SUCCESS' && event.data.deviceId) {
|
|
121
|
+
adoptDeviceId(event.data.deviceId); // 包内做格式 + 平台段双校验
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## 设备指纹(默认关闭,隐私友好)
|
|
127
|
+
|
|
128
|
+
```html
|
|
129
|
+
<!-- 方式一:服务端/模板注入 meta 开关 -->
|
|
130
|
+
<meta name="device-fp" content="true" />
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
# 方式二:Vite 环境变量
|
|
135
|
+
VITE_DEVICE_FINGERPRINT=true
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
```ts
|
|
139
|
+
// 方式三:显式配置(非 Vite 构建器推荐)
|
|
140
|
+
setupDeviceSync(http, { fingerprint: true });
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
采集维度:canvas 渲染差异 + WebGL renderer/vendor,SHA-256 取前 32 位 hex。结果以 Promise 缓存:并发调用去重,采集失败(headless / 反指纹浏览器)返回空串而非常量哈希,防止误匹配。
|
|
144
|
+
|
|
145
|
+
## 安全设计
|
|
146
|
+
|
|
147
|
+
- **前后端校验对齐** — 本地规则与后端 `validateDeviceId` 逐条一致,非法存量 ID 本地即重生,避免每请求重生循环
|
|
148
|
+
- **脏输入防御** — 响应头超 128 字符忽略、日志截断外部输入、`setDeviceId` 拒绝非法写入
|
|
149
|
+
- **缓存一致性** — 任何身份写入立即失效内存缓存,下一请求即生效
|
|
150
|
+
- **优雅降级** — 隐私模式 / 存储故障静默降级内存层,不抛异常、不中断请求链路
|
|
151
|
+
|
|
152
|
+
## 设备 ID 格式
|
|
153
|
+
|
|
154
|
+
```
|
|
155
|
+
{PLATFORM}-{ENCODED_TIMESTAMP}-{RANDOM_SUFFIX}
|
|
156
|
+
示例:WEB-DaBOSbNdSuc-8s4T
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
- `PLATFORM` — `WEB` / `IOS` / `ANDROID`(UA 检测)
|
|
160
|
+
- `ENCODED_TIMESTAMP` —(毫秒时间戳 − 2024-01-01 偏移)XOR 64 位黄金比例魔数 → Base62,固定 11 字符
|
|
161
|
+
- `RANDOM_SUFFIX` — 6 字符 Base62(约 35.7 bit 熵),拒绝采样无模偏差
|
|
162
|
+
|
|
163
|
+
## 在 nodeServers monorepo 内开发
|
|
164
|
+
|
|
165
|
+
workspace 内前端通过 Vite alias 直连源码消费(开发体验保持 bundler 原生):
|
|
166
|
+
|
|
167
|
+
```ts
|
|
168
|
+
// vite.config.ts
|
|
169
|
+
resolve: { alias: { 'stable-deviceid': fileURLToPath(new URL('../packages/shared-device/src/index.ts', import.meta.url)) } }
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
```json
|
|
173
|
+
// tsconfig.json
|
|
174
|
+
"paths": { "stable-deviceid": ["../packages/shared-device/src/index.ts"], "stable-deviceid/*": ["../packages/shared-device/src/*"] },
|
|
175
|
+
"include": ["src/**/*.ts", "../packages/shared-device/src/**/*.ts"]
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
各应用 `package.json` 中 `"stable-deviceid": "*"` 供 workspace 链接。
|
|
179
|
+
|
|
180
|
+
## 构建与发布(维护者)
|
|
181
|
+
|
|
182
|
+
```bash
|
|
183
|
+
cd packages/shared-device
|
|
184
|
+
npm run build # esbuild 逐模块转换 → dist/ + tsc 产出 dist/index.d.ts
|
|
185
|
+
npm publish # prepack 自动构建;ESM-only,access: public
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
## 已知限制
|
|
189
|
+
|
|
190
|
+
- 隐私模式下存储降级为会话内内存 ID,刷新即变(有 `console.warn` 告警)
|
|
191
|
+
- `isDeviceFingerprintEnabled` 依赖 `import.meta.env`(Vite)与 DOM;非 Vite 消费方请用 meta 标签或 `fingerprint` 选项
|
|
192
|
+
- 极旧浏览器无 `crypto.getRandomValues` 时随机后缀降级 `Math.random`(有告警;仅影响唯一性不影响安全性)
|
|
193
|
+
- Safari ITP 会清除脚本可写存储,需配合服务端 httpOnly cookie 兜底恢复机制实现完全连续性
|
|
194
|
+
|
|
195
|
+
## 许可证
|
|
196
|
+
|
|
197
|
+
[MIT](./LICENSE) © 2026 qirly
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "stable-deviceid",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.1",
|
|
4
4
|
"description": "跨端一致的现代浏览器设备身份库:结构化稳定设备 ID(生成/校验/自愈)、canvas+WebGL 指纹(默认关闭)、响应头身份同步、SSO 跨 origin 归一、axios 一站式接入",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
"files": [
|
|
16
16
|
"dist",
|
|
17
17
|
"README.md",
|
|
18
|
+
"README.zh-CN.md",
|
|
18
19
|
"LICENSE"
|
|
19
20
|
],
|
|
20
21
|
"sideEffects": false,
|