audit-device-tracker 0.1.0
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/LICENSE +20 -0
- package/README.md +280 -0
- package/dist/adapters/geo/maxmind.d.ts +31 -0
- package/dist/adapters/geo/maxmind.js +25 -0
- package/dist/adapters/geo/static.d.ts +7 -0
- package/dist/adapters/geo/static.js +10 -0
- package/dist/adapters/storage/memory.d.ts +14 -0
- package/dist/adapters/storage/memory.js +44 -0
- package/dist/adapters/storage/sqlite.d.ts +30 -0
- package/dist/adapters/storage/sqlite.js +120 -0
- package/dist/client/fingerprint.d.ts +36 -0
- package/dist/client/fingerprint.js +37 -0
- package/dist/constants.d.ts +2 -0
- package/dist/constants.js +2 -0
- package/dist/core/geo-distance.d.ts +5 -0
- package/dist/core/geo-distance.js +10 -0
- package/dist/core/parser.d.ts +7 -0
- package/dist/core/parser.js +78 -0
- package/dist/core/rules/impossible-travel.d.ts +12 -0
- package/dist/core/rules/impossible-travel.js +30 -0
- package/dist/core/rules/index.d.ts +11 -0
- package/dist/core/rules/index.js +25 -0
- package/dist/core/rules/new-device.d.ts +3 -0
- package/dist/core/rules/new-device.js +16 -0
- package/dist/core/rules/new-ip.d.ts +3 -0
- package/dist/core/rules/new-ip.js +9 -0
- package/dist/core/rules/shared-device.d.ts +6 -0
- package/dist/core/rules/shared-device.js +16 -0
- package/dist/core/rules/simultaneous-login.d.ts +7 -0
- package/dist/core/rules/simultaneous-login.js +22 -0
- package/dist/core/scoring.d.ts +10 -0
- package/dist/core/scoring.js +14 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +9 -0
- package/dist/middleware/express.d.ts +12 -0
- package/dist/middleware/express.js +11 -0
- package/dist/tracker.d.ts +23 -0
- package/dist/tracker.js +157 -0
- package/dist/types.d.ts +141 -0
- package/dist/types.js +1 -0
- package/package.json +42 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Licence MIT
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Adiko Elie
|
|
4
|
+
|
|
5
|
+
L'autorisation est accordée par la présente, à titre gratuit, à toute personne obtenant une copie
|
|
6
|
+
de ce logiciel et des fichiers de documentation associés (le « Logiciel »), d'utiliser
|
|
7
|
+
le Logiciel sans restriction, y compris, sans s'y limiter, les droits
|
|
8
|
+
d'utiliser, de copier, de modifier, de fusionner, de publier, de distribuer, de concéder des sous-licences et/ou de vendre
|
|
9
|
+
des copies du Logiciel, et d'autoriser les personnes auxquelles le Logiciel est
|
|
10
|
+
fourni à le faire, sous réserve des conditions suivantes :
|
|
11
|
+
|
|
12
|
+
La mention de copyright ci-dessus et la présente autorisation doivent être incluses dans toutes
|
|
13
|
+
les copies ou parties substantielles du Logiciel.
|
|
14
|
+
|
|
15
|
+
LE LOGICIEL EST FOURNI « EN L'ÉTAT », SANS GARANTIE D'AUCUNE SORTE, EXPRESSE OU
|
|
16
|
+
IMPLICITE, Y COMPRIS, SANS S'Y LIMITER, LES GARANTIES DE QUALITÉ MARCHANDE,
|
|
17
|
+
D'ADÉQUATION À UN USAGE PARTICULIER ET D'ABSENCE DE CONTREFAÇON. EN AUCUN CAS LES
|
|
18
|
+
AUTEURS OU TITULAIRES DU DROIT D'AUTEUR NE SAURAIENT ÊTRE TENUS RESPONSABLES DE TOUTE RÉCLAMATION, DE TOUT DOMMAGE OU DE TOUTE AUTRE
|
|
19
|
+
RESPONSABILITÉ, QUE CE SOIT DANS LE CADRE D'UNE ACTION CONTRACTUELLE, DÉLICTUELLE OU AUTRE, DÉCOULANT
|
|
20
|
+
DU LOGICIEL, DE SON UTILISATION OU DE TOUTE AUTRE OPÉRATION LE CONCERNANT.
|
package/README.md
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
# audit-device-tracker
|
|
2
|
+
[](https://github.com/eliedvp/audit-device-tracker/actions/workflows/ci.yml)
|
|
3
|
+
[](https://www.npmjs.com/package/audit-device-tracker)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
|
|
6
|
+
Identifiez et auditez les appareils utilisés par vos utilisateurs pour se connecter. Détectez les **identifiants partagés** (par exemple, un collègue utilisant le compte d'une autre personne depuis un autre ordinateur), les **nouveaux appareils**, les **déplacements impossibles** et les **connexions simultanées**, tout en intégrant la protection de la vie privée.
|
|
7
|
+
|
|
8
|
+
## Pourquoi ?
|
|
9
|
+
|
|
10
|
+
Un mot de passe partagé paraît parfaitement valide pour un système d'authentification classique. Ce package ajoute une question importante : **quel appareil utilise cet utilisateur et l'a-t-il déjà utilisé auparavant ?**
|
|
11
|
+
|
|
12
|
+
```text
|
|
13
|
+
alice faible 0 premier_appareil
|
|
14
|
+
|
|
15
|
+
bob élevé 60 premier_appareil, appareil_partage_avec_autres_utilisateurs
|
|
16
|
+
- Appareil également utilisé par : alice
|
|
17
|
+
|
|
18
|
+
alice élevé 100 nouvel_appareil, déplacement_impossible, connexion_simultanée
|
|
19
|
+
- Nouvel appareil : Safari sur iOS
|
|
20
|
+
- Abidjan -> Paris : 4874 km en 0 min
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Vous pouvez tester le fonctionnement avec :
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npm run demo
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Installation
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
npm install audit-device-tracker
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Nécessite **Node.js 22 ou supérieur**.
|
|
36
|
+
|
|
37
|
+
Le stockage SQLite nécessite un pilote SQLite : `node:sqlite` intégré à Node.js **22.13+ sans option supplémentaire**, ou `better-sqlite3`.
|
|
38
|
+
|
|
39
|
+
Ce package ne vous impose aucune dépendance native.
|
|
40
|
+
|
|
41
|
+
## Utilisation rapide avec Express
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
import express from 'express';
|
|
45
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
46
|
+
import {
|
|
47
|
+
DeviceTracker,
|
|
48
|
+
SqliteStorage,
|
|
49
|
+
trackLogin
|
|
50
|
+
} from 'audit-device-tracker';
|
|
51
|
+
|
|
52
|
+
const tracker = new DeviceTracker({
|
|
53
|
+
storage: new SqliteStorage(new DatabaseSync('devices.db')),
|
|
54
|
+
secret: process.env.DEVICE_SECRET!, // au moins 16 caractères
|
|
55
|
+
ipAnonymization: 'partial',
|
|
56
|
+
|
|
57
|
+
onNewDevice: (result, userId) =>
|
|
58
|
+
notifyUser(userId, result.device),
|
|
59
|
+
|
|
60
|
+
onSuspicious: (result, userId) =>
|
|
61
|
+
alertSecurityTeam(userId, result),
|
|
62
|
+
|
|
63
|
+
onError: (error) =>
|
|
64
|
+
console.error(error),
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const app = express();
|
|
68
|
+
|
|
69
|
+
app.use(express.json());
|
|
70
|
+
|
|
71
|
+
app.post('/login', async (req, res) => {
|
|
72
|
+
const user = await authenticate(req.body); // votre propre authentification
|
|
73
|
+
|
|
74
|
+
const result = await trackLogin(
|
|
75
|
+
tracker,
|
|
76
|
+
req,
|
|
77
|
+
res,
|
|
78
|
+
user.id
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
if (result.riskLevel === 'high') {
|
|
82
|
+
return res.status(403).json({
|
|
83
|
+
mfaRequired: true
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
res.json({ ok: true });
|
|
88
|
+
});
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Appelez `trackLogin` **uniquement après une authentification réussie**.
|
|
92
|
+
|
|
93
|
+
`result.findings` explique le score obtenu en termes simples et peut être utilisé directement dans un journal d'audit.
|
|
94
|
+
|
|
95
|
+
## Comment un appareil est reconnu
|
|
96
|
+
|
|
97
|
+
Le package utilise principalement deux mécanismes :
|
|
98
|
+
|
|
99
|
+
1. **Cookie signé (HMAC-SHA256)** : signal fort qui ne peut pas être falsifié sans votre clé secrète. Un cookie valide est considéré comme fiable.
|
|
100
|
+
|
|
101
|
+
2. **Fingerprint de secours** : empreinte basée sur le navigateur, le système d'exploitation, le type d'appareil et la langue, avec éventuellement le fingerprint fourni par le navigateur. L'adresse IP et la version du navigateur ne sont pas utilisées. Ce mécanisme est moins fiable et est utilisé uniquement lorsqu'aucun cookie valide n'est disponible.
|
|
102
|
+
|
|
103
|
+
## Règles et calcul du risque
|
|
104
|
+
|
|
105
|
+
Chaque règle est un petit objet indépendant. Les points des règles déclenchées sont additionnés jusqu'à un maximum de 100.
|
|
106
|
+
|
|
107
|
+
| Règle | Motif | Points |
|
|
108
|
+
| --------------------------------------------------------------- | -------------------------------- | -----: |
|
|
109
|
+
| Appareil déjà utilisé par **un autre utilisateur** | `device_shared_with_other_users` | +60 |
|
|
110
|
+
| Nouvel appareil alors que l'utilisateur en possédait déjà | `new_device` | +35 |
|
|
111
|
+
| Nouvelle IP sur un appareil connu | `new_ip` | +10 |
|
|
112
|
+
| Déplacement impossible sur un nouvel appareil | `impossible_travel` | +60 |
|
|
113
|
+
| Déplacement impossible sur un appareil connu | `impossible_travel` | +40 |
|
|
114
|
+
| Connexion simultanée (autre appareil, autre IP, moins de 5 min) | `simultaneous_login` | +20 |
|
|
115
|
+
|
|
116
|
+
### Niveaux de risque
|
|
117
|
+
|
|
118
|
+
* **Faible** : < 30
|
|
119
|
+
* **Moyen** : 30 à 59
|
|
120
|
+
* **Élevé** : ≥ 60
|
|
121
|
+
|
|
122
|
+
Le tout premier appareil d'un utilisateur sert de référence et obtient un score de **0**.
|
|
123
|
+
|
|
124
|
+
### Ajouter sa propre règle
|
|
125
|
+
|
|
126
|
+
Vous pouvez ajouter vos propres règles ou remplacer celles fournies par défaut :
|
|
127
|
+
|
|
128
|
+
```ts
|
|
129
|
+
import {
|
|
130
|
+
defaultRules,
|
|
131
|
+
type Rule
|
|
132
|
+
} from 'audit-device-tracker';
|
|
133
|
+
|
|
134
|
+
const nightLogin: Rule = {
|
|
135
|
+
name: 'night-login',
|
|
136
|
+
|
|
137
|
+
evaluate: (ctx) =>
|
|
138
|
+
ctx.now.getUTCHours() < 5
|
|
139
|
+
? {
|
|
140
|
+
reason: 'night_login',
|
|
141
|
+
points: 15,
|
|
142
|
+
detail: 'Connexion effectuée pendant la nuit'
|
|
143
|
+
}
|
|
144
|
+
: null,
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
new DeviceTracker({
|
|
148
|
+
storage,
|
|
149
|
+
secret,
|
|
150
|
+
rules: [
|
|
151
|
+
...defaultRules(),
|
|
152
|
+
nightLogin
|
|
153
|
+
]
|
|
154
|
+
});
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## Géolocalisation et déplacements impossibles
|
|
158
|
+
|
|
159
|
+
Vous pouvez fournir une option `geo` avec une méthode `lookup(ip)`.
|
|
160
|
+
|
|
161
|
+
Sans fournisseur de géolocalisation, les règles liées à la localisation restent désactivées.
|
|
162
|
+
|
|
163
|
+
```ts
|
|
164
|
+
import maxmind, { type CityResponse } from 'maxmind';
|
|
165
|
+
import { MaxMindGeo } from 'audit-device-tracker';
|
|
166
|
+
|
|
167
|
+
const reader = await maxmind.open<CityResponse>(
|
|
168
|
+
'./GeoLite2-City.mmdb'
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
const tracker = new DeviceTracker({
|
|
172
|
+
storage,
|
|
173
|
+
secret,
|
|
174
|
+
geo: new MaxMindGeo(reader)
|
|
175
|
+
});
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
La base **GeoLite2** est gratuite, mais nécessite un compte MaxMind pour être téléchargée et n'est pas incluse dans le package.
|
|
179
|
+
|
|
180
|
+
`StaticGeo` permet d'utiliser une table de localisation fixe pour les tests et les démonstrations.
|
|
181
|
+
|
|
182
|
+
## Fingerprint côté navigateur (optionnel)
|
|
183
|
+
|
|
184
|
+
```ts
|
|
185
|
+
import {
|
|
186
|
+
fingerprintHeaders
|
|
187
|
+
} from 'audit-device-tracker/client';
|
|
188
|
+
|
|
189
|
+
await fetch('/login', {
|
|
190
|
+
method: 'POST',
|
|
191
|
+
headers: {
|
|
192
|
+
...(await fingerprintHeaders())
|
|
193
|
+
},
|
|
194
|
+
body
|
|
195
|
+
});
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Le fingerprint utilise uniquement quelques informations à faible pouvoir d'identification :
|
|
199
|
+
|
|
200
|
+
* résolution de l'écran ;
|
|
201
|
+
* fuseau horaire ;
|
|
202
|
+
* nombre de cœurs ;
|
|
203
|
+
* plateforme ;
|
|
204
|
+
* langues.
|
|
205
|
+
|
|
206
|
+
Le package n'utilise volontairement **ni Canvas, ni WebGL, ni analyse des polices**.
|
|
207
|
+
|
|
208
|
+
Ces techniques peuvent être utilisées pour suivre les personnes plutôt que pour auditer les appareils.
|
|
209
|
+
|
|
210
|
+
Le serveur considère le fingerprint comme **un simple indice et non comme une identité**.
|
|
211
|
+
|
|
212
|
+
## Options
|
|
213
|
+
|
|
214
|
+
| Option | Valeur par défaut | Description |
|
|
215
|
+
| ------------------------------ | ----------------- | ------------------------------------------------------------------------------------------------------------ |
|
|
216
|
+
| `storage` | obligatoire | Un `StorageAdapter` : `MemoryStorage`, `SqliteStorage` ou votre propre stockage |
|
|
217
|
+
| `secret` | obligatoire | Sert à signer le cookie de l'appareil (minimum 16 caractères) |
|
|
218
|
+
| `cookieName` | `adt_did` | Nom du cookie de l'appareil |
|
|
219
|
+
| `cookieSecure` | `true` | Mettre `false` uniquement en développement local avec HTTP |
|
|
220
|
+
| `ipAnonymization` | `none` | `partial` masque la dernière partie d'une IPv4 / le suffixe d'une IPv6 |
|
|
221
|
+
| `geo` | - | Un `GeoProvider` pour activer les règles de localisation |
|
|
222
|
+
| `rules` | règles intégrées | Remplace les règles ou les combine avec `defaultRules()` |
|
|
223
|
+
| `suspiciousThreshold` | `60` | Score à partir duquel `onSuspicious` est déclenché |
|
|
224
|
+
| `onNewDevice` / `onSuspicious` | - | Hooks permettant de déclencher des alertes |
|
|
225
|
+
| `onError` | - | Appelé lorsqu'un hook, une règle ou le fournisseur Geo génère une erreur ; la connexion n'est jamais bloquée |
|
|
226
|
+
|
|
227
|
+
Vous pouvez également utiliser :
|
|
228
|
+
|
|
229
|
+
```ts
|
|
230
|
+
tracker.getDevices(userId)
|
|
231
|
+
tracker.getLoginHistory(userId)
|
|
232
|
+
tracker.revokeDevice(userId, id)
|
|
233
|
+
tracker.trustDevice(userId, id)
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
Cela permet notamment de créer une page **« Mes appareils »**.
|
|
237
|
+
|
|
238
|
+
`trustDevice` permet de déclarer comme légitime un appareil partagé, par exemple un ordinateur d'accueil ou de réception.
|
|
239
|
+
|
|
240
|
+
## Créer son propre stockage
|
|
241
|
+
|
|
242
|
+
Vous pouvez implémenter l'interface `StorageAdapter` composée de 8 méthodes.
|
|
243
|
+
|
|
244
|
+
Utilisez ensuite la suite de tests commune pour vérifier que votre stockage se comporte comme les stockages intégrés :
|
|
245
|
+
|
|
246
|
+
```text
|
|
247
|
+
tests/storage-contract.ts
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
## Limites et précautions
|
|
251
|
+
|
|
252
|
+
* `MemoryStorage` perd toutes ses données au redémarrage. Utilisez `SqliteStorage` ou votre propre stockage pour conserver les données.
|
|
253
|
+
* La détection des appareils partagés dépend de la conservation du cookie. Utilisez HTTPS en production.
|
|
254
|
+
* Un navigateur qui refuse ou supprime le cookie réduit l'efficacité de cette détection.
|
|
255
|
+
* La géolocalisation par IP reste approximative et un VPN peut modifier la localisation détectée.
|
|
256
|
+
* Un déplacement impossible constitue **un signal à examiner et non une preuve de fraude**.
|
|
257
|
+
* Les déplacements courts de moins de 300 km sont ignorés.
|
|
258
|
+
* Derrière un proxy, configurez Express afin que `req.ip` corresponde à l'adresse réelle du client :
|
|
259
|
+
|
|
260
|
+
```ts
|
|
261
|
+
app.set('trust proxy', 1);
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
Ne faites pas confiance à `X-Forwarded-For` sans configuration appropriée du proxy.
|
|
265
|
+
|
|
266
|
+
* Un appareil partagé peut être parfaitement légitime, par exemple dans une salle de formation ou une réception. Utilisez `trustDevice` dans ce cas.
|
|
267
|
+
* Les données relatives aux appareils peuvent constituer des données personnelles. Vous êtes responsable de la base légale, de la durée de conservation et de l'information des utilisateurs requises par la réglementation applicable.
|
|
268
|
+
* Utilisez notamment `ipAnonymization` et `revokeDevice` lorsque cela est approprié.
|
|
269
|
+
|
|
270
|
+
## Développement
|
|
271
|
+
|
|
272
|
+
```bash
|
|
273
|
+
npm test
|
|
274
|
+
npm run build
|
|
275
|
+
npm run demo
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
## Licence
|
|
279
|
+
|
|
280
|
+
MIT — voir le fichier `LICENSE`.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { GeoLocation, GeoProvider } from '../../types.js';
|
|
2
|
+
/** The part of a MaxMind GeoLite2-City record we read. */
|
|
3
|
+
export interface MaxMindCityRecord {
|
|
4
|
+
country?: {
|
|
5
|
+
iso_code?: string;
|
|
6
|
+
};
|
|
7
|
+
city?: {
|
|
8
|
+
names?: Record<string, string | undefined>;
|
|
9
|
+
};
|
|
10
|
+
location?: {
|
|
11
|
+
latitude?: number;
|
|
12
|
+
longitude?: number;
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Anything with a `get(ip)` method: the reader returned by `maxmind.open()`.
|
|
17
|
+
*
|
|
18
|
+
* import maxmind, { CityResponse } from 'maxmind';
|
|
19
|
+
* const reader = await maxmind.open<CityResponse>('./GeoLite2-City.mmdb');
|
|
20
|
+
* const geo = new MaxMindGeo(reader);
|
|
21
|
+
*
|
|
22
|
+
* The GeoLite2 database is free but requires a MaxMind account to download.
|
|
23
|
+
*/
|
|
24
|
+
export interface MaxMindReader {
|
|
25
|
+
get(ip: string): MaxMindCityRecord | null;
|
|
26
|
+
}
|
|
27
|
+
export declare class MaxMindGeo implements GeoProvider {
|
|
28
|
+
private readonly reader;
|
|
29
|
+
constructor(reader: MaxMindReader);
|
|
30
|
+
lookup(ip: string): GeoLocation | null;
|
|
31
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export class MaxMindGeo {
|
|
2
|
+
reader;
|
|
3
|
+
constructor(reader) {
|
|
4
|
+
this.reader = reader;
|
|
5
|
+
}
|
|
6
|
+
lookup(ip) {
|
|
7
|
+
let record;
|
|
8
|
+
try {
|
|
9
|
+
record = this.reader.get(ip);
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return null; // not a valid IP address
|
|
13
|
+
}
|
|
14
|
+
const latitude = record?.location?.latitude;
|
|
15
|
+
const longitude = record?.location?.longitude;
|
|
16
|
+
if (typeof latitude !== 'number' || typeof longitude !== 'number')
|
|
17
|
+
return null;
|
|
18
|
+
return {
|
|
19
|
+
country: record?.country?.iso_code ?? null,
|
|
20
|
+
city: record?.city?.names?.en ?? null,
|
|
21
|
+
latitude,
|
|
22
|
+
longitude,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { GeoLocation, GeoProvider } from '../../types.js';
|
|
2
|
+
/** A fixed IP -> location table: for tests and demos, no database needed. */
|
|
3
|
+
export declare class StaticGeo implements GeoProvider {
|
|
4
|
+
private readonly table;
|
|
5
|
+
constructor(table: Record<string, GeoLocation>);
|
|
6
|
+
lookup(ip: string): GeoLocation | null;
|
|
7
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { DeviceRecord, LoginEvent, StorageAdapter } from '../../types.js';
|
|
2
|
+
export declare class MemoryStorage implements StorageAdapter {
|
|
3
|
+
private devices;
|
|
4
|
+
private logins;
|
|
5
|
+
private key;
|
|
6
|
+
getDevice(userId: string, deviceId: string): Promise<DeviceRecord | null>;
|
|
7
|
+
findDeviceByFingerprint(userId: string, fingerprint: string): Promise<DeviceRecord | null>;
|
|
8
|
+
saveDevice(device: DeviceRecord): Promise<void>;
|
|
9
|
+
listDevices(userId: string): Promise<DeviceRecord[]>;
|
|
10
|
+
listUsersByDevice(deviceId: string): Promise<string[]>;
|
|
11
|
+
removeDevice(userId: string, deviceId: string): Promise<void>;
|
|
12
|
+
addLogin(event: LoginEvent): Promise<void>;
|
|
13
|
+
listLogins(userId: string, limit?: number): Promise<LoginEvent[]>;
|
|
14
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
const clone = (value) => structuredClone(value);
|
|
2
|
+
export class MemoryStorage {
|
|
3
|
+
devices = new Map();
|
|
4
|
+
logins = [];
|
|
5
|
+
key(userId, deviceId) {
|
|
6
|
+
return JSON.stringify([userId, deviceId]);
|
|
7
|
+
}
|
|
8
|
+
async getDevice(userId, deviceId) {
|
|
9
|
+
const found = this.devices.get(this.key(userId, deviceId));
|
|
10
|
+
return found ? clone(found) : null;
|
|
11
|
+
}
|
|
12
|
+
async findDeviceByFingerprint(userId, fingerprint) {
|
|
13
|
+
let best = null;
|
|
14
|
+
for (const device of this.devices.values()) {
|
|
15
|
+
if (device.userId !== userId || device.fingerprint !== fingerprint)
|
|
16
|
+
continue;
|
|
17
|
+
if (!best || device.lastSeen.getTime() > best.lastSeen.getTime())
|
|
18
|
+
best = device;
|
|
19
|
+
}
|
|
20
|
+
return best ? clone(best) : null;
|
|
21
|
+
}
|
|
22
|
+
async saveDevice(device) {
|
|
23
|
+
this.devices.set(this.key(device.userId, device.id), clone(device));
|
|
24
|
+
}
|
|
25
|
+
async listDevices(userId) {
|
|
26
|
+
return [...this.devices.values()].filter((d) => d.userId === userId).map(clone);
|
|
27
|
+
}
|
|
28
|
+
async listUsersByDevice(deviceId) {
|
|
29
|
+
return [...this.devices.values()].filter((d) => d.id === deviceId).map((d) => d.userId);
|
|
30
|
+
}
|
|
31
|
+
async removeDevice(userId, deviceId) {
|
|
32
|
+
this.devices.delete(this.key(userId, deviceId));
|
|
33
|
+
}
|
|
34
|
+
async addLogin(event) {
|
|
35
|
+
this.logins.push(clone(event));
|
|
36
|
+
}
|
|
37
|
+
async listLogins(userId, limit = 50) {
|
|
38
|
+
return this.logins
|
|
39
|
+
.filter((l) => l.userId === userId)
|
|
40
|
+
.slice(-limit)
|
|
41
|
+
.reverse()
|
|
42
|
+
.map(clone);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { DeviceRecord, LoginEvent, StorageAdapter } from '../../types.js';
|
|
2
|
+
/** The part of a prepared statement we use. Matches `node:sqlite` and `better-sqlite3`. */
|
|
3
|
+
export interface SqliteStatement {
|
|
4
|
+
run(...params: unknown[]): unknown;
|
|
5
|
+
get(...params: unknown[]): unknown;
|
|
6
|
+
all(...params: unknown[]): unknown[];
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* The part of a SQLite database we use. Pass either
|
|
10
|
+
* new DatabaseSync('devices.db') // from 'node:sqlite' (Node 22.5+, nothing to install)
|
|
11
|
+
* new Database('devices.db') // from 'better-sqlite3'
|
|
12
|
+
* so this package never forces a native dependency on you.
|
|
13
|
+
*/
|
|
14
|
+
export interface SqliteDatabase {
|
|
15
|
+
exec(sql: string): unknown;
|
|
16
|
+
prepare(sql: string): SqliteStatement;
|
|
17
|
+
}
|
|
18
|
+
/** Persistent storage on SQLite. Creates its tables on first use. */
|
|
19
|
+
export declare class SqliteStorage implements StorageAdapter {
|
|
20
|
+
private readonly db;
|
|
21
|
+
constructor(db: SqliteDatabase);
|
|
22
|
+
getDevice(userId: string, deviceId: string): Promise<DeviceRecord | null>;
|
|
23
|
+
findDeviceByFingerprint(userId: string, fingerprint: string): Promise<DeviceRecord | null>;
|
|
24
|
+
saveDevice(d: DeviceRecord): Promise<void>;
|
|
25
|
+
listDevices(userId: string): Promise<DeviceRecord[]>;
|
|
26
|
+
listUsersByDevice(deviceId: string): Promise<string[]>;
|
|
27
|
+
removeDevice(userId: string, deviceId: string): Promise<void>;
|
|
28
|
+
addLogin(e: LoginEvent): Promise<void>;
|
|
29
|
+
listLogins(userId: string, limit?: number): Promise<LoginEvent[]>;
|
|
30
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
const SCHEMA = `
|
|
2
|
+
CREATE TABLE IF NOT EXISTS adt_devices (
|
|
3
|
+
user_id TEXT NOT NULL,
|
|
4
|
+
device_id TEXT NOT NULL,
|
|
5
|
+
fingerprint TEXT NOT NULL,
|
|
6
|
+
id_source TEXT NOT NULL,
|
|
7
|
+
browser TEXT NOT NULL,
|
|
8
|
+
browser_version TEXT NOT NULL,
|
|
9
|
+
os TEXT NOT NULL,
|
|
10
|
+
os_version TEXT NOT NULL,
|
|
11
|
+
device_type TEXT NOT NULL,
|
|
12
|
+
language TEXT,
|
|
13
|
+
first_seen TEXT NOT NULL,
|
|
14
|
+
last_seen TEXT NOT NULL,
|
|
15
|
+
login_count INTEGER NOT NULL,
|
|
16
|
+
last_ip TEXT NOT NULL,
|
|
17
|
+
known_ips TEXT NOT NULL,
|
|
18
|
+
trusted INTEGER NOT NULL,
|
|
19
|
+
PRIMARY KEY (user_id, device_id)
|
|
20
|
+
);
|
|
21
|
+
CREATE INDEX IF NOT EXISTS adt_devices_by_device ON adt_devices (device_id);
|
|
22
|
+
CREATE INDEX IF NOT EXISTS adt_devices_by_fingerprint ON adt_devices (user_id, fingerprint);
|
|
23
|
+
CREATE TABLE IF NOT EXISTS adt_logins (
|
|
24
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
25
|
+
user_id TEXT NOT NULL,
|
|
26
|
+
device_id TEXT NOT NULL,
|
|
27
|
+
ip TEXT NOT NULL,
|
|
28
|
+
user_agent TEXT NOT NULL,
|
|
29
|
+
at TEXT NOT NULL,
|
|
30
|
+
risk_score INTEGER NOT NULL,
|
|
31
|
+
reasons TEXT NOT NULL,
|
|
32
|
+
location TEXT
|
|
33
|
+
);
|
|
34
|
+
CREATE INDEX IF NOT EXISTS adt_logins_by_user ON adt_logins (user_id, at, id);
|
|
35
|
+
`;
|
|
36
|
+
function toDevice(row) {
|
|
37
|
+
return {
|
|
38
|
+
id: row.device_id,
|
|
39
|
+
userId: row.user_id,
|
|
40
|
+
fingerprint: row.fingerprint,
|
|
41
|
+
idSource: row.id_source,
|
|
42
|
+
browser: row.browser,
|
|
43
|
+
browserVersion: row.browser_version,
|
|
44
|
+
os: row.os,
|
|
45
|
+
osVersion: row.os_version,
|
|
46
|
+
deviceType: row.device_type,
|
|
47
|
+
language: row.language ?? null,
|
|
48
|
+
firstSeen: new Date(row.first_seen),
|
|
49
|
+
lastSeen: new Date(row.last_seen),
|
|
50
|
+
loginCount: Number(row.login_count),
|
|
51
|
+
lastIp: row.last_ip,
|
|
52
|
+
knownIps: JSON.parse(row.known_ips),
|
|
53
|
+
trusted: Number(row.trusted) === 1,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
function toLogin(row) {
|
|
57
|
+
return {
|
|
58
|
+
userId: row.user_id,
|
|
59
|
+
deviceId: row.device_id,
|
|
60
|
+
ip: row.ip,
|
|
61
|
+
userAgent: row.user_agent,
|
|
62
|
+
at: new Date(row.at),
|
|
63
|
+
riskScore: Number(row.risk_score),
|
|
64
|
+
reasons: JSON.parse(row.reasons),
|
|
65
|
+
location: row.location ? JSON.parse(row.location) : null,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/** Persistent storage on SQLite. Creates its tables on first use. */
|
|
69
|
+
export class SqliteStorage {
|
|
70
|
+
db;
|
|
71
|
+
constructor(db) {
|
|
72
|
+
this.db = db;
|
|
73
|
+
db.exec(SCHEMA);
|
|
74
|
+
}
|
|
75
|
+
async getDevice(userId, deviceId) {
|
|
76
|
+
const row = this.db
|
|
77
|
+
.prepare('SELECT * FROM adt_devices WHERE user_id = ? AND device_id = ?')
|
|
78
|
+
.get(userId, deviceId);
|
|
79
|
+
return row ? toDevice(row) : null;
|
|
80
|
+
}
|
|
81
|
+
async findDeviceByFingerprint(userId, fingerprint) {
|
|
82
|
+
const row = this.db
|
|
83
|
+
.prepare('SELECT * FROM adt_devices WHERE user_id = ? AND fingerprint = ? ORDER BY last_seen DESC LIMIT 1')
|
|
84
|
+
.get(userId, fingerprint);
|
|
85
|
+
return row ? toDevice(row) : null;
|
|
86
|
+
}
|
|
87
|
+
async saveDevice(d) {
|
|
88
|
+
this.db
|
|
89
|
+
.prepare(`INSERT OR REPLACE INTO adt_devices (
|
|
90
|
+
user_id, device_id, fingerprint, id_source, browser, browser_version, os, os_version,
|
|
91
|
+
device_type, language, first_seen, last_seen, login_count, last_ip, known_ips, trusted
|
|
92
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
93
|
+
.run(d.userId, d.id, d.fingerprint, d.idSource, d.browser, d.browserVersion, d.os, d.osVersion, d.deviceType, d.language, d.firstSeen.toISOString(), d.lastSeen.toISOString(), d.loginCount, d.lastIp, JSON.stringify(d.knownIps), d.trusted ? 1 : 0);
|
|
94
|
+
}
|
|
95
|
+
async listDevices(userId) {
|
|
96
|
+
const rows = this.db.prepare('SELECT * FROM adt_devices WHERE user_id = ?').all(userId);
|
|
97
|
+
return rows.map(toDevice);
|
|
98
|
+
}
|
|
99
|
+
async listUsersByDevice(deviceId) {
|
|
100
|
+
const rows = this.db
|
|
101
|
+
.prepare('SELECT DISTINCT user_id FROM adt_devices WHERE device_id = ?')
|
|
102
|
+
.all(deviceId);
|
|
103
|
+
return rows.map((row) => row.user_id);
|
|
104
|
+
}
|
|
105
|
+
async removeDevice(userId, deviceId) {
|
|
106
|
+
this.db.prepare('DELETE FROM adt_devices WHERE user_id = ? AND device_id = ?').run(userId, deviceId);
|
|
107
|
+
}
|
|
108
|
+
async addLogin(e) {
|
|
109
|
+
this.db
|
|
110
|
+
.prepare(`INSERT INTO adt_logins (user_id, device_id, ip, user_agent, at, risk_score, reasons, location)
|
|
111
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
112
|
+
.run(e.userId, e.deviceId, e.ip, e.userAgent, e.at.toISOString(), e.riskScore, JSON.stringify(e.reasons), e.location ? JSON.stringify(e.location) : null);
|
|
113
|
+
}
|
|
114
|
+
async listLogins(userId, limit = 50) {
|
|
115
|
+
const rows = this.db
|
|
116
|
+
.prepare('SELECT * FROM adt_logins WHERE user_id = ? ORDER BY at DESC, id DESC LIMIT ?')
|
|
117
|
+
.all(userId, limit);
|
|
118
|
+
return rows.map(toLogin);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { DEFAULT_FINGERPRINT_HEADER } from '../constants.js';
|
|
2
|
+
/**
|
|
3
|
+
* Browser-side helper (import from 'audit-device-tracker/client').
|
|
4
|
+
*
|
|
5
|
+
* Privacy by design: only a handful of low-entropy signals are read. There is deliberately
|
|
6
|
+
* NO canvas, WebGL or font probing: those techniques track people rather than audit devices.
|
|
7
|
+
*/
|
|
8
|
+
export interface ClientSignals {
|
|
9
|
+
screen: string;
|
|
10
|
+
timezone: string;
|
|
11
|
+
cores: number | null;
|
|
12
|
+
platform: string;
|
|
13
|
+
languages: string;
|
|
14
|
+
touchPoints: number;
|
|
15
|
+
}
|
|
16
|
+
/** What we read from the browser. Injectable so the code can be tested without one. */
|
|
17
|
+
export interface ClientEnvironment {
|
|
18
|
+
screen?: {
|
|
19
|
+
width: number;
|
|
20
|
+
height: number;
|
|
21
|
+
colorDepth: number;
|
|
22
|
+
};
|
|
23
|
+
navigator?: {
|
|
24
|
+
hardwareConcurrency?: number;
|
|
25
|
+
platform?: string;
|
|
26
|
+
languages?: readonly string[];
|
|
27
|
+
maxTouchPoints?: number;
|
|
28
|
+
};
|
|
29
|
+
timeZone?: string;
|
|
30
|
+
}
|
|
31
|
+
export { DEFAULT_FINGERPRINT_HEADER as FINGERPRINT_HEADER };
|
|
32
|
+
export declare function collectSignals(env?: ClientEnvironment): ClientSignals;
|
|
33
|
+
/** A 32-character hex hash of the signals. */
|
|
34
|
+
export declare function getClientFingerprint(env?: ClientEnvironment): Promise<string>;
|
|
35
|
+
/** Headers to add to your login request: fetch('/login', { headers: await fingerprintHeaders() }) */
|
|
36
|
+
export declare function fingerprintHeaders(env?: ClientEnvironment): Promise<Record<string, string>>;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { DEFAULT_FINGERPRINT_HEADER } from '../constants.js';
|
|
2
|
+
export { DEFAULT_FINGERPRINT_HEADER as FINGERPRINT_HEADER };
|
|
3
|
+
function browserEnvironment() {
|
|
4
|
+
const g = globalThis;
|
|
5
|
+
let timeZone;
|
|
6
|
+
try {
|
|
7
|
+
timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
timeZone = undefined;
|
|
11
|
+
}
|
|
12
|
+
return { screen: g.screen, navigator: g.navigator, timeZone };
|
|
13
|
+
}
|
|
14
|
+
export function collectSignals(env = browserEnvironment()) {
|
|
15
|
+
const { screen, navigator } = env;
|
|
16
|
+
return {
|
|
17
|
+
screen: screen ? `${screen.width}x${screen.height}x${screen.colorDepth}` : 'unknown',
|
|
18
|
+
timezone: env.timeZone ?? 'unknown',
|
|
19
|
+
cores: navigator?.hardwareConcurrency ?? null,
|
|
20
|
+
platform: navigator?.platform ?? 'unknown',
|
|
21
|
+
languages: (navigator?.languages ?? []).join(','),
|
|
22
|
+
touchPoints: navigator?.maxTouchPoints ?? 0,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
/** A 32-character hex hash of the signals. */
|
|
26
|
+
export async function getClientFingerprint(env) {
|
|
27
|
+
const subtle = globalThis.crypto?.subtle;
|
|
28
|
+
if (!subtle)
|
|
29
|
+
throw new Error('audit-device-tracker/client: Web Crypto is not available (HTTPS required)');
|
|
30
|
+
const data = new TextEncoder().encode(JSON.stringify(collectSignals(env)));
|
|
31
|
+
const digest = new Uint8Array(await subtle.digest('SHA-256', data));
|
|
32
|
+
return Array.from(digest, (byte) => byte.toString(16).padStart(2, '0')).join('').slice(0, 32);
|
|
33
|
+
}
|
|
34
|
+
/** Headers to add to your login request: fetch('/login', { headers: await fingerprintHeaders() }) */
|
|
35
|
+
export async function fingerprintHeaders(env) {
|
|
36
|
+
return { [DEFAULT_FINGERPRINT_HEADER]: await getClientFingerprint(env) };
|
|
37
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { GeoLocation } from '../types.js';
|
|
2
|
+
type Point = Pick<GeoLocation, 'latitude' | 'longitude'>;
|
|
3
|
+
/** Great-circle distance between two points on Earth (Haversine formula), in kilometres. */
|
|
4
|
+
export declare function distanceKm(a: Point, b: Point): number;
|
|
5
|
+
export {};
|