@technomoron/api-server-base 1.1.3 → 1.1.4
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.txt +3 -1
- package/dist/cjs/api-server-base.cjs +177 -53
- package/dist/cjs/api-server-base.d.ts +14 -2
- package/dist/cjs/auth-storage.d.ts +6 -0
- package/dist/esm/api-server-base.d.ts +14 -2
- package/dist/esm/api-server-base.js +177 -53
- package/dist/esm/auth-storage.d.ts +6 -0
- package/package.json +1 -1
package/README.txt
CHANGED
|
@@ -127,7 +127,9 @@ Request Lifecycle
|
|
|
127
127
|
|
|
128
128
|
Client IP Helpers
|
|
129
129
|
-----------------
|
|
130
|
-
|
|
130
|
+
Call `apiReq.getClientInfo()` when you need the entire client fingerprint captured during request hydration. It returns the raw user-agent string plus derived browser/OS/device labels along with the computed `ip` and `ipchain`.
|
|
131
|
+
|
|
132
|
+
Call `apiReq.getClientIp()` to obtain the most likely client address, skipping loopback entries collected from proxy headers. Use `apiReq.getClientIpChain()` when you need the de-duplicated sequence gathered from the standard Forwarded/X-Forwarded-For/X-Real-IP headers as well as Express' `req.ip`/`req.ips` and the underlying socket. Both helpers reuse the cached payload returned by `apiReq.getClientInfo()`.
|
|
131
133
|
|
|
132
134
|
Extending the Base Classes
|
|
133
135
|
--------------------------
|
|
@@ -110,6 +110,111 @@ function extractForwardedHeader(header) {
|
|
|
110
110
|
}
|
|
111
111
|
return ips;
|
|
112
112
|
}
|
|
113
|
+
function detectBrowser(userAgent) {
|
|
114
|
+
const browserMatchers = [
|
|
115
|
+
{ label: 'Edge', pattern: /(Edg|Edge|EdgiOS|EdgA)\/([\d.]+)/i, versionGroup: 2 },
|
|
116
|
+
{ label: 'Chrome', pattern: /(Chrome|CriOS)\/([\d.]+)/i, versionGroup: 2 },
|
|
117
|
+
{ label: 'Firefox', pattern: /(Firefox|FxiOS)\/([\d.]+)/i, versionGroup: 2 },
|
|
118
|
+
{ label: 'Safari', pattern: /Version\/([\d.]+).*Safari/i, versionGroup: 1 },
|
|
119
|
+
{ label: 'Opera', pattern: /(OPR|Opera)\/([\d.]+)/i, versionGroup: 2 },
|
|
120
|
+
{ label: 'Brave', pattern: /Brave\/([\d.]+)/i, versionGroup: 1 },
|
|
121
|
+
{ label: 'Vivaldi', pattern: /Vivaldi\/([\d.]+)/i, versionGroup: 1 },
|
|
122
|
+
{ label: 'Electron', pattern: /Electron\/([\d.]+)/i, versionGroup: 1 },
|
|
123
|
+
{ label: 'Node', pattern: /Node\.js\/([\d.]+)/i, versionGroup: 1 },
|
|
124
|
+
{ label: 'IE', pattern: /MSIE ([\d.]+)/i, versionGroup: 1 },
|
|
125
|
+
{ label: 'IE', pattern: /Trident\/.*rv:([\d.]+)/i, versionGroup: 1 }
|
|
126
|
+
];
|
|
127
|
+
for (const matcher of browserMatchers) {
|
|
128
|
+
const m = userAgent.match(matcher.pattern);
|
|
129
|
+
if (m) {
|
|
130
|
+
const version = matcher.versionGroup ? m[matcher.versionGroup] : '';
|
|
131
|
+
return version ? `${matcher.label} ${version}` : matcher.label;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return '';
|
|
135
|
+
}
|
|
136
|
+
function detectOs(userAgent) {
|
|
137
|
+
const osMatchers = [
|
|
138
|
+
{
|
|
139
|
+
label: 'Windows',
|
|
140
|
+
pattern: /Windows NT ([\d.]+)/i,
|
|
141
|
+
transform: (match) => `Windows ${match[1]}`
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
label: 'iOS',
|
|
145
|
+
pattern: /iPhone OS ([\d_]+)/i,
|
|
146
|
+
transform: (match) => `iOS ${match[1].replace(/_/g, '.')}`
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
label: 'iPadOS',
|
|
150
|
+
pattern: /iPad; CPU OS ([\d_]+)/i,
|
|
151
|
+
transform: (match) => `iPadOS ${match[1].replace(/_/g, '.')}`
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
label: 'macOS',
|
|
155
|
+
pattern: /Mac OS X ([\d_]+)/i,
|
|
156
|
+
transform: (match) => `macOS ${match[1].replace(/_/g, '.')}`
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
label: 'Android',
|
|
160
|
+
pattern: /Android ([\d.]+)/i,
|
|
161
|
+
transform: (match) => `Android ${match[1]}`
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
label: 'ChromeOS',
|
|
165
|
+
pattern: /CrOS [^ ]+ ([\d.]+)/i,
|
|
166
|
+
transform: (match) => `ChromeOS ${match[1]}`
|
|
167
|
+
},
|
|
168
|
+
{ label: 'Linux', pattern: /Linux/i },
|
|
169
|
+
{ label: 'Unix', pattern: /X11/i }
|
|
170
|
+
];
|
|
171
|
+
for (const matcher of osMatchers) {
|
|
172
|
+
const m = userAgent.match(matcher.pattern);
|
|
173
|
+
if (m) {
|
|
174
|
+
return matcher.transform ? matcher.transform(m) : matcher.label;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return '';
|
|
178
|
+
}
|
|
179
|
+
function detectDevice(userAgent, osLabel) {
|
|
180
|
+
if (/iPhone/i.test(userAgent)) {
|
|
181
|
+
return 'iPhone';
|
|
182
|
+
}
|
|
183
|
+
if (/iPad/i.test(userAgent)) {
|
|
184
|
+
return 'iPad';
|
|
185
|
+
}
|
|
186
|
+
if (/iPod/i.test(userAgent)) {
|
|
187
|
+
return 'iPod';
|
|
188
|
+
}
|
|
189
|
+
if (/Android/i.test(userAgent)) {
|
|
190
|
+
const match = userAgent.match(/;\s*([^;]+)\s+Build/i);
|
|
191
|
+
if (match) {
|
|
192
|
+
return match[1];
|
|
193
|
+
}
|
|
194
|
+
return 'Android Device';
|
|
195
|
+
}
|
|
196
|
+
if (/Macintosh/i.test(userAgent)) {
|
|
197
|
+
return 'Mac';
|
|
198
|
+
}
|
|
199
|
+
if (/Windows/i.test(userAgent)) {
|
|
200
|
+
return 'PC';
|
|
201
|
+
}
|
|
202
|
+
if (/CrOS/i.test(userAgent)) {
|
|
203
|
+
return 'Chromebook';
|
|
204
|
+
}
|
|
205
|
+
return osLabel;
|
|
206
|
+
}
|
|
207
|
+
function parseClientAgent(userAgentHeader) {
|
|
208
|
+
const raw = Array.isArray(userAgentHeader) ? userAgentHeader[0] : userAgentHeader;
|
|
209
|
+
const ua = typeof raw === 'string' ? raw.trim() : '';
|
|
210
|
+
if (!ua) {
|
|
211
|
+
return { ua: '', browser: '', os: '', device: '' };
|
|
212
|
+
}
|
|
213
|
+
const os = detectOs(ua);
|
|
214
|
+
const browser = detectBrowser(ua);
|
|
215
|
+
const device = detectDevice(ua, os);
|
|
216
|
+
return { ua, browser, os, device };
|
|
217
|
+
}
|
|
113
218
|
function isLoopbackAddress(ip) {
|
|
114
219
|
if (ip === '::1' || ip === '0:0:0:0:0:0:0:1') {
|
|
115
220
|
return true;
|
|
@@ -122,6 +227,71 @@ function isLoopbackAddress(ip) {
|
|
|
122
227
|
}
|
|
123
228
|
return false;
|
|
124
229
|
}
|
|
230
|
+
function collectClientIpChain(req) {
|
|
231
|
+
const seen = new Set();
|
|
232
|
+
const result = [];
|
|
233
|
+
const pushNormalized = (ip) => {
|
|
234
|
+
if (!ip || seen.has(ip)) {
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
seen.add(ip);
|
|
238
|
+
result.push(ip);
|
|
239
|
+
};
|
|
240
|
+
for (const ip of extractForwardedFor(req.headers['x-forwarded-for'])) {
|
|
241
|
+
pushNormalized(ip);
|
|
242
|
+
}
|
|
243
|
+
for (const ip of extractForwardedHeader(req.headers['forwarded'])) {
|
|
244
|
+
pushNormalized(ip);
|
|
245
|
+
}
|
|
246
|
+
const realIp = req.headers['x-real-ip'];
|
|
247
|
+
if (Array.isArray(realIp)) {
|
|
248
|
+
realIp.forEach((value) => pushNormalized(normalizeIpAddress(value)));
|
|
249
|
+
}
|
|
250
|
+
else if (typeof realIp === 'string') {
|
|
251
|
+
pushNormalized(normalizeIpAddress(realIp));
|
|
252
|
+
}
|
|
253
|
+
if (Array.isArray(req.ips)) {
|
|
254
|
+
for (const ip of req.ips) {
|
|
255
|
+
pushNormalized(normalizeIpAddress(ip));
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
if (typeof req.ip === 'string') {
|
|
259
|
+
pushNormalized(normalizeIpAddress(req.ip));
|
|
260
|
+
}
|
|
261
|
+
const socketAddress = req.socket?.remoteAddress;
|
|
262
|
+
if (typeof socketAddress === 'string') {
|
|
263
|
+
pushNormalized(normalizeIpAddress(socketAddress));
|
|
264
|
+
}
|
|
265
|
+
const connectionAddress = req.connection?.remoteAddress;
|
|
266
|
+
if (typeof connectionAddress === 'string') {
|
|
267
|
+
pushNormalized(normalizeIpAddress(connectionAddress));
|
|
268
|
+
}
|
|
269
|
+
return result;
|
|
270
|
+
}
|
|
271
|
+
function selectClientIp(chain) {
|
|
272
|
+
for (const ip of chain) {
|
|
273
|
+
if (!isLoopbackAddress(ip)) {
|
|
274
|
+
return ip;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return chain[0] ?? null;
|
|
278
|
+
}
|
|
279
|
+
function buildClientInfo(req) {
|
|
280
|
+
const agent = parseClientAgent(req.headers['user-agent']);
|
|
281
|
+
const ipchain = collectClientIpChain(req);
|
|
282
|
+
const ip = selectClientIp(ipchain);
|
|
283
|
+
return {
|
|
284
|
+
...agent,
|
|
285
|
+
ip,
|
|
286
|
+
ipchain
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
function ensureClientInfo(apiReq) {
|
|
290
|
+
if (!apiReq.clientInfo) {
|
|
291
|
+
apiReq.clientInfo = buildClientInfo(apiReq.req);
|
|
292
|
+
}
|
|
293
|
+
return apiReq.clientInfo;
|
|
294
|
+
}
|
|
125
295
|
class ApiError extends Error {
|
|
126
296
|
constructor({ code, message, data, errors }) {
|
|
127
297
|
const msg = guess_exception_text(message, '[Unknown error (null/undefined)]');
|
|
@@ -420,13 +590,17 @@ class ApiServer {
|
|
|
420
590
|
return async (req, res, next) => {
|
|
421
591
|
void next;
|
|
422
592
|
try {
|
|
423
|
-
const apiReq =
|
|
593
|
+
const apiReq = {
|
|
424
594
|
server: this,
|
|
425
595
|
req,
|
|
426
596
|
res,
|
|
427
597
|
token: '',
|
|
428
|
-
tokenData: null
|
|
429
|
-
|
|
598
|
+
tokenData: null,
|
|
599
|
+
getClientInfo: () => ensureClientInfo(apiReq),
|
|
600
|
+
getClientIp: () => ensureClientInfo(apiReq).ip,
|
|
601
|
+
getClientIpChain: () => ensureClientInfo(apiReq).ipchain
|
|
602
|
+
};
|
|
603
|
+
this.currReq = apiReq;
|
|
430
604
|
if (this.config.hydrateGetBody) {
|
|
431
605
|
hydrateGetBody(apiReq.req);
|
|
432
606
|
}
|
|
@@ -473,56 +647,6 @@ class ApiServer {
|
|
|
473
647
|
}
|
|
474
648
|
};
|
|
475
649
|
}
|
|
476
|
-
getClientIp(req) {
|
|
477
|
-
const chain = this.getClientIpChain(req);
|
|
478
|
-
for (const ip of chain) {
|
|
479
|
-
if (!isLoopbackAddress(ip)) {
|
|
480
|
-
return ip;
|
|
481
|
-
}
|
|
482
|
-
}
|
|
483
|
-
return chain[0] ?? null;
|
|
484
|
-
}
|
|
485
|
-
getClientIpChain(req) {
|
|
486
|
-
const seen = new Set();
|
|
487
|
-
const result = [];
|
|
488
|
-
const pushNormalized = (ip) => {
|
|
489
|
-
if (!ip || seen.has(ip)) {
|
|
490
|
-
return;
|
|
491
|
-
}
|
|
492
|
-
seen.add(ip);
|
|
493
|
-
result.push(ip);
|
|
494
|
-
};
|
|
495
|
-
for (const ip of extractForwardedFor(req.headers['x-forwarded-for'])) {
|
|
496
|
-
pushNormalized(ip);
|
|
497
|
-
}
|
|
498
|
-
for (const ip of extractForwardedHeader(req.headers['forwarded'])) {
|
|
499
|
-
pushNormalized(ip);
|
|
500
|
-
}
|
|
501
|
-
const realIp = req.headers['x-real-ip'];
|
|
502
|
-
if (Array.isArray(realIp)) {
|
|
503
|
-
realIp.forEach((value) => pushNormalized(normalizeIpAddress(value)));
|
|
504
|
-
}
|
|
505
|
-
else if (typeof realIp === 'string') {
|
|
506
|
-
pushNormalized(normalizeIpAddress(realIp));
|
|
507
|
-
}
|
|
508
|
-
if (Array.isArray(req.ips)) {
|
|
509
|
-
for (const ip of req.ips) {
|
|
510
|
-
pushNormalized(normalizeIpAddress(ip));
|
|
511
|
-
}
|
|
512
|
-
}
|
|
513
|
-
if (typeof req.ip === 'string') {
|
|
514
|
-
pushNormalized(normalizeIpAddress(req.ip));
|
|
515
|
-
}
|
|
516
|
-
const socketAddress = req.socket?.remoteAddress;
|
|
517
|
-
if (typeof socketAddress === 'string') {
|
|
518
|
-
pushNormalized(normalizeIpAddress(socketAddress));
|
|
519
|
-
}
|
|
520
|
-
const connectionAddress = req.connection?.remoteAddress;
|
|
521
|
-
if (typeof connectionAddress === 'string') {
|
|
522
|
-
pushNormalized(normalizeIpAddress(connectionAddress));
|
|
523
|
-
}
|
|
524
|
-
return result;
|
|
525
|
-
}
|
|
526
650
|
api(module) {
|
|
527
651
|
const router = express_1.default.Router();
|
|
528
652
|
module.server = this;
|
|
@@ -46,6 +46,20 @@ export interface ApiRequest {
|
|
|
46
46
|
res: Response;
|
|
47
47
|
tokenData?: ApiTokenData | null;
|
|
48
48
|
token?: string;
|
|
49
|
+
clientInfo?: ClientInfo;
|
|
50
|
+
getClientInfo: () => ClientInfo;
|
|
51
|
+
getClientIp: () => string | null;
|
|
52
|
+
getClientIpChain: () => string[];
|
|
53
|
+
}
|
|
54
|
+
export interface ClientAgentProfile {
|
|
55
|
+
ua: string;
|
|
56
|
+
browser: string;
|
|
57
|
+
os: string;
|
|
58
|
+
device: string;
|
|
59
|
+
}
|
|
60
|
+
export interface ClientInfo extends ClientAgentProfile {
|
|
61
|
+
ip: string | null;
|
|
62
|
+
ipchain: string[];
|
|
49
63
|
}
|
|
50
64
|
export { ApiModule } from './api-module.js';
|
|
51
65
|
export type { ApiHandler, ApiAuthType, ApiAuthClass, ApiRoute, ApiKey } from './api-module.js';
|
|
@@ -121,8 +135,6 @@ export declare class ApiServer {
|
|
|
121
135
|
private verifyJWT;
|
|
122
136
|
private authenticate;
|
|
123
137
|
private handle_request;
|
|
124
|
-
getClientIp(req: RequestWithStuff): string | null;
|
|
125
|
-
getClientIpChain(req: RequestWithStuff): string[];
|
|
126
138
|
api<T extends ApiModule<any>>(module: T): this;
|
|
127
139
|
dumpRequest(apiReq: ApiRequest): void;
|
|
128
140
|
}
|
|
@@ -4,12 +4,18 @@ export interface AuthTokenMetadata {
|
|
|
4
4
|
domain?: string;
|
|
5
5
|
fingerprint?: string;
|
|
6
6
|
label?: string;
|
|
7
|
+
browser?: string;
|
|
8
|
+
device?: string;
|
|
9
|
+
ip?: string;
|
|
10
|
+
os?: string;
|
|
7
11
|
scope?: string | string[];
|
|
8
12
|
revokeSessions?: 'device' | 'domain' | 'client' | 'user';
|
|
9
13
|
}
|
|
10
14
|
export interface AuthTokenData extends AuthTokenMetadata {
|
|
11
15
|
access: string;
|
|
12
16
|
expires?: Date;
|
|
17
|
+
issuedAt?: Date;
|
|
18
|
+
lastSeenAt?: Date;
|
|
13
19
|
refresh: string;
|
|
14
20
|
userId: AuthIdentifier;
|
|
15
21
|
}
|
|
@@ -46,6 +46,20 @@ export interface ApiRequest {
|
|
|
46
46
|
res: Response;
|
|
47
47
|
tokenData?: ApiTokenData | null;
|
|
48
48
|
token?: string;
|
|
49
|
+
clientInfo?: ClientInfo;
|
|
50
|
+
getClientInfo: () => ClientInfo;
|
|
51
|
+
getClientIp: () => string | null;
|
|
52
|
+
getClientIpChain: () => string[];
|
|
53
|
+
}
|
|
54
|
+
export interface ClientAgentProfile {
|
|
55
|
+
ua: string;
|
|
56
|
+
browser: string;
|
|
57
|
+
os: string;
|
|
58
|
+
device: string;
|
|
59
|
+
}
|
|
60
|
+
export interface ClientInfo extends ClientAgentProfile {
|
|
61
|
+
ip: string | null;
|
|
62
|
+
ipchain: string[];
|
|
49
63
|
}
|
|
50
64
|
export { ApiModule } from './api-module.js';
|
|
51
65
|
export type { ApiHandler, ApiAuthType, ApiAuthClass, ApiRoute, ApiKey } from './api-module.js';
|
|
@@ -121,8 +135,6 @@ export declare class ApiServer {
|
|
|
121
135
|
private verifyJWT;
|
|
122
136
|
private authenticate;
|
|
123
137
|
private handle_request;
|
|
124
|
-
getClientIp(req: RequestWithStuff): string | null;
|
|
125
|
-
getClientIpChain(req: RequestWithStuff): string[];
|
|
126
138
|
api<T extends ApiModule<any>>(module: T): this;
|
|
127
139
|
dumpRequest(apiReq: ApiRequest): void;
|
|
128
140
|
}
|
|
@@ -103,6 +103,111 @@ function extractForwardedHeader(header) {
|
|
|
103
103
|
}
|
|
104
104
|
return ips;
|
|
105
105
|
}
|
|
106
|
+
function detectBrowser(userAgent) {
|
|
107
|
+
const browserMatchers = [
|
|
108
|
+
{ label: 'Edge', pattern: /(Edg|Edge|EdgiOS|EdgA)\/([\d.]+)/i, versionGroup: 2 },
|
|
109
|
+
{ label: 'Chrome', pattern: /(Chrome|CriOS)\/([\d.]+)/i, versionGroup: 2 },
|
|
110
|
+
{ label: 'Firefox', pattern: /(Firefox|FxiOS)\/([\d.]+)/i, versionGroup: 2 },
|
|
111
|
+
{ label: 'Safari', pattern: /Version\/([\d.]+).*Safari/i, versionGroup: 1 },
|
|
112
|
+
{ label: 'Opera', pattern: /(OPR|Opera)\/([\d.]+)/i, versionGroup: 2 },
|
|
113
|
+
{ label: 'Brave', pattern: /Brave\/([\d.]+)/i, versionGroup: 1 },
|
|
114
|
+
{ label: 'Vivaldi', pattern: /Vivaldi\/([\d.]+)/i, versionGroup: 1 },
|
|
115
|
+
{ label: 'Electron', pattern: /Electron\/([\d.]+)/i, versionGroup: 1 },
|
|
116
|
+
{ label: 'Node', pattern: /Node\.js\/([\d.]+)/i, versionGroup: 1 },
|
|
117
|
+
{ label: 'IE', pattern: /MSIE ([\d.]+)/i, versionGroup: 1 },
|
|
118
|
+
{ label: 'IE', pattern: /Trident\/.*rv:([\d.]+)/i, versionGroup: 1 }
|
|
119
|
+
];
|
|
120
|
+
for (const matcher of browserMatchers) {
|
|
121
|
+
const m = userAgent.match(matcher.pattern);
|
|
122
|
+
if (m) {
|
|
123
|
+
const version = matcher.versionGroup ? m[matcher.versionGroup] : '';
|
|
124
|
+
return version ? `${matcher.label} ${version}` : matcher.label;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return '';
|
|
128
|
+
}
|
|
129
|
+
function detectOs(userAgent) {
|
|
130
|
+
const osMatchers = [
|
|
131
|
+
{
|
|
132
|
+
label: 'Windows',
|
|
133
|
+
pattern: /Windows NT ([\d.]+)/i,
|
|
134
|
+
transform: (match) => `Windows ${match[1]}`
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
label: 'iOS',
|
|
138
|
+
pattern: /iPhone OS ([\d_]+)/i,
|
|
139
|
+
transform: (match) => `iOS ${match[1].replace(/_/g, '.')}`
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
label: 'iPadOS',
|
|
143
|
+
pattern: /iPad; CPU OS ([\d_]+)/i,
|
|
144
|
+
transform: (match) => `iPadOS ${match[1].replace(/_/g, '.')}`
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
label: 'macOS',
|
|
148
|
+
pattern: /Mac OS X ([\d_]+)/i,
|
|
149
|
+
transform: (match) => `macOS ${match[1].replace(/_/g, '.')}`
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
label: 'Android',
|
|
153
|
+
pattern: /Android ([\d.]+)/i,
|
|
154
|
+
transform: (match) => `Android ${match[1]}`
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
label: 'ChromeOS',
|
|
158
|
+
pattern: /CrOS [^ ]+ ([\d.]+)/i,
|
|
159
|
+
transform: (match) => `ChromeOS ${match[1]}`
|
|
160
|
+
},
|
|
161
|
+
{ label: 'Linux', pattern: /Linux/i },
|
|
162
|
+
{ label: 'Unix', pattern: /X11/i }
|
|
163
|
+
];
|
|
164
|
+
for (const matcher of osMatchers) {
|
|
165
|
+
const m = userAgent.match(matcher.pattern);
|
|
166
|
+
if (m) {
|
|
167
|
+
return matcher.transform ? matcher.transform(m) : matcher.label;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return '';
|
|
171
|
+
}
|
|
172
|
+
function detectDevice(userAgent, osLabel) {
|
|
173
|
+
if (/iPhone/i.test(userAgent)) {
|
|
174
|
+
return 'iPhone';
|
|
175
|
+
}
|
|
176
|
+
if (/iPad/i.test(userAgent)) {
|
|
177
|
+
return 'iPad';
|
|
178
|
+
}
|
|
179
|
+
if (/iPod/i.test(userAgent)) {
|
|
180
|
+
return 'iPod';
|
|
181
|
+
}
|
|
182
|
+
if (/Android/i.test(userAgent)) {
|
|
183
|
+
const match = userAgent.match(/;\s*([^;]+)\s+Build/i);
|
|
184
|
+
if (match) {
|
|
185
|
+
return match[1];
|
|
186
|
+
}
|
|
187
|
+
return 'Android Device';
|
|
188
|
+
}
|
|
189
|
+
if (/Macintosh/i.test(userAgent)) {
|
|
190
|
+
return 'Mac';
|
|
191
|
+
}
|
|
192
|
+
if (/Windows/i.test(userAgent)) {
|
|
193
|
+
return 'PC';
|
|
194
|
+
}
|
|
195
|
+
if (/CrOS/i.test(userAgent)) {
|
|
196
|
+
return 'Chromebook';
|
|
197
|
+
}
|
|
198
|
+
return osLabel;
|
|
199
|
+
}
|
|
200
|
+
function parseClientAgent(userAgentHeader) {
|
|
201
|
+
const raw = Array.isArray(userAgentHeader) ? userAgentHeader[0] : userAgentHeader;
|
|
202
|
+
const ua = typeof raw === 'string' ? raw.trim() : '';
|
|
203
|
+
if (!ua) {
|
|
204
|
+
return { ua: '', browser: '', os: '', device: '' };
|
|
205
|
+
}
|
|
206
|
+
const os = detectOs(ua);
|
|
207
|
+
const browser = detectBrowser(ua);
|
|
208
|
+
const device = detectDevice(ua, os);
|
|
209
|
+
return { ua, browser, os, device };
|
|
210
|
+
}
|
|
106
211
|
function isLoopbackAddress(ip) {
|
|
107
212
|
if (ip === '::1' || ip === '0:0:0:0:0:0:0:1') {
|
|
108
213
|
return true;
|
|
@@ -115,6 +220,71 @@ function isLoopbackAddress(ip) {
|
|
|
115
220
|
}
|
|
116
221
|
return false;
|
|
117
222
|
}
|
|
223
|
+
function collectClientIpChain(req) {
|
|
224
|
+
const seen = new Set();
|
|
225
|
+
const result = [];
|
|
226
|
+
const pushNormalized = (ip) => {
|
|
227
|
+
if (!ip || seen.has(ip)) {
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
seen.add(ip);
|
|
231
|
+
result.push(ip);
|
|
232
|
+
};
|
|
233
|
+
for (const ip of extractForwardedFor(req.headers['x-forwarded-for'])) {
|
|
234
|
+
pushNormalized(ip);
|
|
235
|
+
}
|
|
236
|
+
for (const ip of extractForwardedHeader(req.headers['forwarded'])) {
|
|
237
|
+
pushNormalized(ip);
|
|
238
|
+
}
|
|
239
|
+
const realIp = req.headers['x-real-ip'];
|
|
240
|
+
if (Array.isArray(realIp)) {
|
|
241
|
+
realIp.forEach((value) => pushNormalized(normalizeIpAddress(value)));
|
|
242
|
+
}
|
|
243
|
+
else if (typeof realIp === 'string') {
|
|
244
|
+
pushNormalized(normalizeIpAddress(realIp));
|
|
245
|
+
}
|
|
246
|
+
if (Array.isArray(req.ips)) {
|
|
247
|
+
for (const ip of req.ips) {
|
|
248
|
+
pushNormalized(normalizeIpAddress(ip));
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
if (typeof req.ip === 'string') {
|
|
252
|
+
pushNormalized(normalizeIpAddress(req.ip));
|
|
253
|
+
}
|
|
254
|
+
const socketAddress = req.socket?.remoteAddress;
|
|
255
|
+
if (typeof socketAddress === 'string') {
|
|
256
|
+
pushNormalized(normalizeIpAddress(socketAddress));
|
|
257
|
+
}
|
|
258
|
+
const connectionAddress = req.connection?.remoteAddress;
|
|
259
|
+
if (typeof connectionAddress === 'string') {
|
|
260
|
+
pushNormalized(normalizeIpAddress(connectionAddress));
|
|
261
|
+
}
|
|
262
|
+
return result;
|
|
263
|
+
}
|
|
264
|
+
function selectClientIp(chain) {
|
|
265
|
+
for (const ip of chain) {
|
|
266
|
+
if (!isLoopbackAddress(ip)) {
|
|
267
|
+
return ip;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return chain[0] ?? null;
|
|
271
|
+
}
|
|
272
|
+
function buildClientInfo(req) {
|
|
273
|
+
const agent = parseClientAgent(req.headers['user-agent']);
|
|
274
|
+
const ipchain = collectClientIpChain(req);
|
|
275
|
+
const ip = selectClientIp(ipchain);
|
|
276
|
+
return {
|
|
277
|
+
...agent,
|
|
278
|
+
ip,
|
|
279
|
+
ipchain
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
function ensureClientInfo(apiReq) {
|
|
283
|
+
if (!apiReq.clientInfo) {
|
|
284
|
+
apiReq.clientInfo = buildClientInfo(apiReq.req);
|
|
285
|
+
}
|
|
286
|
+
return apiReq.clientInfo;
|
|
287
|
+
}
|
|
118
288
|
export class ApiError extends Error {
|
|
119
289
|
constructor({ code, message, data, errors }) {
|
|
120
290
|
const msg = guess_exception_text(message, '[Unknown error (null/undefined)]');
|
|
@@ -412,13 +582,17 @@ export class ApiServer {
|
|
|
412
582
|
return async (req, res, next) => {
|
|
413
583
|
void next;
|
|
414
584
|
try {
|
|
415
|
-
const apiReq =
|
|
585
|
+
const apiReq = {
|
|
416
586
|
server: this,
|
|
417
587
|
req,
|
|
418
588
|
res,
|
|
419
589
|
token: '',
|
|
420
|
-
tokenData: null
|
|
421
|
-
|
|
590
|
+
tokenData: null,
|
|
591
|
+
getClientInfo: () => ensureClientInfo(apiReq),
|
|
592
|
+
getClientIp: () => ensureClientInfo(apiReq).ip,
|
|
593
|
+
getClientIpChain: () => ensureClientInfo(apiReq).ipchain
|
|
594
|
+
};
|
|
595
|
+
this.currReq = apiReq;
|
|
422
596
|
if (this.config.hydrateGetBody) {
|
|
423
597
|
hydrateGetBody(apiReq.req);
|
|
424
598
|
}
|
|
@@ -465,56 +639,6 @@ export class ApiServer {
|
|
|
465
639
|
}
|
|
466
640
|
};
|
|
467
641
|
}
|
|
468
|
-
getClientIp(req) {
|
|
469
|
-
const chain = this.getClientIpChain(req);
|
|
470
|
-
for (const ip of chain) {
|
|
471
|
-
if (!isLoopbackAddress(ip)) {
|
|
472
|
-
return ip;
|
|
473
|
-
}
|
|
474
|
-
}
|
|
475
|
-
return chain[0] ?? null;
|
|
476
|
-
}
|
|
477
|
-
getClientIpChain(req) {
|
|
478
|
-
const seen = new Set();
|
|
479
|
-
const result = [];
|
|
480
|
-
const pushNormalized = (ip) => {
|
|
481
|
-
if (!ip || seen.has(ip)) {
|
|
482
|
-
return;
|
|
483
|
-
}
|
|
484
|
-
seen.add(ip);
|
|
485
|
-
result.push(ip);
|
|
486
|
-
};
|
|
487
|
-
for (const ip of extractForwardedFor(req.headers['x-forwarded-for'])) {
|
|
488
|
-
pushNormalized(ip);
|
|
489
|
-
}
|
|
490
|
-
for (const ip of extractForwardedHeader(req.headers['forwarded'])) {
|
|
491
|
-
pushNormalized(ip);
|
|
492
|
-
}
|
|
493
|
-
const realIp = req.headers['x-real-ip'];
|
|
494
|
-
if (Array.isArray(realIp)) {
|
|
495
|
-
realIp.forEach((value) => pushNormalized(normalizeIpAddress(value)));
|
|
496
|
-
}
|
|
497
|
-
else if (typeof realIp === 'string') {
|
|
498
|
-
pushNormalized(normalizeIpAddress(realIp));
|
|
499
|
-
}
|
|
500
|
-
if (Array.isArray(req.ips)) {
|
|
501
|
-
for (const ip of req.ips) {
|
|
502
|
-
pushNormalized(normalizeIpAddress(ip));
|
|
503
|
-
}
|
|
504
|
-
}
|
|
505
|
-
if (typeof req.ip === 'string') {
|
|
506
|
-
pushNormalized(normalizeIpAddress(req.ip));
|
|
507
|
-
}
|
|
508
|
-
const socketAddress = req.socket?.remoteAddress;
|
|
509
|
-
if (typeof socketAddress === 'string') {
|
|
510
|
-
pushNormalized(normalizeIpAddress(socketAddress));
|
|
511
|
-
}
|
|
512
|
-
const connectionAddress = req.connection?.remoteAddress;
|
|
513
|
-
if (typeof connectionAddress === 'string') {
|
|
514
|
-
pushNormalized(normalizeIpAddress(connectionAddress));
|
|
515
|
-
}
|
|
516
|
-
return result;
|
|
517
|
-
}
|
|
518
642
|
api(module) {
|
|
519
643
|
const router = express.Router();
|
|
520
644
|
module.server = this;
|
|
@@ -4,12 +4,18 @@ export interface AuthTokenMetadata {
|
|
|
4
4
|
domain?: string;
|
|
5
5
|
fingerprint?: string;
|
|
6
6
|
label?: string;
|
|
7
|
+
browser?: string;
|
|
8
|
+
device?: string;
|
|
9
|
+
ip?: string;
|
|
10
|
+
os?: string;
|
|
7
11
|
scope?: string | string[];
|
|
8
12
|
revokeSessions?: 'device' | 'domain' | 'client' | 'user';
|
|
9
13
|
}
|
|
10
14
|
export interface AuthTokenData extends AuthTokenMetadata {
|
|
11
15
|
access: string;
|
|
12
16
|
expires?: Date;
|
|
17
|
+
issuedAt?: Date;
|
|
18
|
+
lastSeenAt?: Date;
|
|
13
19
|
refresh: string;
|
|
14
20
|
userId: AuthIdentifier;
|
|
15
21
|
}
|