webspresso 0.0.99 → 0.0.100
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/.agents/AGENTS.md +2 -2
- package/.agents/AUTH.md +246 -0
- package/.agents/CLI.md +47 -0
- package/.agents/ERRORS.md +59 -0
- package/.agents/ORM.md +90 -0
- package/.agents/PLUGINS.md +44 -7
- package/.agents/QUEUE.md +18 -10
- package/.agents/ROUTING.md +33 -0
- package/.agents/STREAMING.md +35 -0
- package/AGENTS.md +1 -0
- package/README.md +145 -93
- package/bin/commands/add-docker.js +467 -0
- package/bin/commands/deploy-check.js +339 -0
- package/bin/webspresso.js +4 -0
- package/core/auth/index.js +63 -1
- package/core/auth/magic-link.js +170 -0
- package/core/auth/manager.js +343 -3
- package/core/auth/middleware.js +30 -5
- package/core/auth/session-manager.js +255 -0
- package/core/auth/sessions.js +351 -0
- package/core/auth/tokens.js +1 -0
- package/core/auth/totp.js +4 -1
- package/core/auth/user-agent.js +161 -0
- package/core/orm/index.js +2 -1
- package/core/orm/model.js +51 -1
- package/core/orm/repository.js +283 -13
- package/core/orm/scopes.js +3 -2
- package/core/orm/types.js +6 -1
- package/core/queue/cron.js +252 -39
- package/core/queue/manager.js +20 -2
- package/core/queue/scheduler.js +115 -59
- package/core/ssr/stream.js +62 -2
- package/index.d.ts +274 -14
- package/index.js +4 -0
- package/package.json +7 -5
- package/plugins/admin-panel/modules/admin-users.js +1 -1
- package/plugins/audit-log/admin-component.js +54 -54
- package/plugins/audit-log/index.js +2 -2
- package/plugins/content/client/inline-edit.js +2 -46
- package/plugins/cors.js +13 -2
- package/plugins/email/auth-bridge.js +66 -0
- package/plugins/email/bundled-templates.js +15 -0
- package/plugins/email/templates/auth-magic-link.mjml +13 -0
- package/plugins/file-manager/admin-component.js +49 -49
- package/plugins/file-manager/index.js +4 -4
- package/plugins/index.js +2 -0
- package/plugins/webhooks/admin-component.js +608 -0
- package/plugins/webhooks/api-handlers.js +237 -0
- package/plugins/webhooks/crypto.js +144 -0
- package/plugins/webhooks/dispatcher.js +708 -0
- package/plugins/webhooks/index.js +263 -0
- package/plugins/webhooks/matcher.js +113 -0
- package/plugins/webhooks/migration-template.js +75 -0
- package/plugins/webhooks/models.js +98 -0
- package/plugins/webhooks/sender.js +176 -0
- package/src/bundle/index.js +2 -1
- package/src/file-router.js +173 -4
- package/src/helpers.js +7 -1
- package/src/njk-frontmatter.js +3 -0
- package/src/routing/cascading.js +112 -0
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webspresso Session Revocation & Device Manager
|
|
3
|
+
* Orchestrates session tracking, activity throttling, store cleanup, and event dispatching
|
|
4
|
+
* @module core/auth/session-manager
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
'use strict';
|
|
8
|
+
|
|
9
|
+
const { getClientIp, parseUserAgent } = require('./user-agent');
|
|
10
|
+
const { createMemorySessionTracker, createKnexSessionTracker } = require('./sessions');
|
|
11
|
+
|
|
12
|
+
class SessionManager {
|
|
13
|
+
/**
|
|
14
|
+
* @param {Object} options
|
|
15
|
+
* @param {Object} [options.tracker] - Session tracker adapter (knex or memory)
|
|
16
|
+
* @param {Object} [options.sessionStore] - Optional express-session store instance
|
|
17
|
+
* @param {number} [options.touchThrottleMs=60000] - Throttle last_active_at updates (default 1 min)
|
|
18
|
+
* @param {import('events').EventEmitter} [options.emitter] - AuthManager instance for event dispatching
|
|
19
|
+
*/
|
|
20
|
+
constructor(options = {}) {
|
|
21
|
+
this.tracker = options.tracker || createMemorySessionTracker();
|
|
22
|
+
this.sessionStore = options.sessionStore || null;
|
|
23
|
+
this.touchThrottleMs = options.touchThrottleMs ?? 60000;
|
|
24
|
+
this.emitter = options.emitter || null;
|
|
25
|
+
/** @type {Map<string, number>} */
|
|
26
|
+
this._lastTouchMap = new Map();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Record or update session for a user request
|
|
31
|
+
* @param {string} sessionId
|
|
32
|
+
* @param {string|number} userId
|
|
33
|
+
* @param {Object} req - Express request
|
|
34
|
+
* @returns {Promise<Object>}
|
|
35
|
+
*/
|
|
36
|
+
async track(sessionId, userId, req) {
|
|
37
|
+
if (!sessionId || !userId) return null;
|
|
38
|
+
|
|
39
|
+
const userAgent = req?.headers?.['user-agent'] || '';
|
|
40
|
+
const ip = getClientIp(req);
|
|
41
|
+
const parsed = parseUserAgent(userAgent);
|
|
42
|
+
|
|
43
|
+
const record = await this.tracker.track(sessionId, userId, {
|
|
44
|
+
ip,
|
|
45
|
+
userAgent,
|
|
46
|
+
browser: parsed.browser,
|
|
47
|
+
os: parsed.os,
|
|
48
|
+
device: parsed.device,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
this._lastTouchMap.set(sessionId, Date.now());
|
|
52
|
+
|
|
53
|
+
if (this.emitter) {
|
|
54
|
+
this.emitter.emit('session:created', {
|
|
55
|
+
sessionId,
|
|
56
|
+
userId,
|
|
57
|
+
ip,
|
|
58
|
+
browser: parsed.browser,
|
|
59
|
+
os: parsed.os,
|
|
60
|
+
device: parsed.device,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return record;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Touch session to update last_active_at with throttling
|
|
69
|
+
* @param {string} sessionId
|
|
70
|
+
* @returns {Promise<boolean>}
|
|
71
|
+
*/
|
|
72
|
+
async touch(sessionId) {
|
|
73
|
+
if (!sessionId) return false;
|
|
74
|
+
|
|
75
|
+
const now = Date.now();
|
|
76
|
+
const lastTouch = this._lastTouchMap.get(sessionId) || 0;
|
|
77
|
+
|
|
78
|
+
if (now - lastTouch < this.touchThrottleMs) {
|
|
79
|
+
return false; // Throttled
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
this._lastTouchMap.set(sessionId, now);
|
|
83
|
+
return await this.tracker.touch(sessionId);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Check if session is valid and active
|
|
88
|
+
* @param {string} sessionId
|
|
89
|
+
* @param {string|number} [userId]
|
|
90
|
+
* @returns {Promise<boolean>}
|
|
91
|
+
*/
|
|
92
|
+
async isSessionValid(sessionId, userId = null) {
|
|
93
|
+
if (!sessionId) return false;
|
|
94
|
+
return await this.tracker.isSessionValid(sessionId, userId);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* List all active sessions for a user
|
|
99
|
+
* @param {string|number} userId
|
|
100
|
+
* @param {string} [currentSessionId]
|
|
101
|
+
* @returns {Promise<Array<Object>>}
|
|
102
|
+
*/
|
|
103
|
+
async listSessions(userId, currentSessionId = null) {
|
|
104
|
+
if (!userId) return [];
|
|
105
|
+
return await this.tracker.list(userId, currentSessionId);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Revoke a specific session
|
|
110
|
+
* @param {string} sessionId
|
|
111
|
+
* @param {Object} [options]
|
|
112
|
+
* @param {string|number} [options.userId]
|
|
113
|
+
* @param {string} [options.revokedBy]
|
|
114
|
+
* @returns {Promise<boolean>}
|
|
115
|
+
*/
|
|
116
|
+
async revokeSession(sessionId, options = {}) {
|
|
117
|
+
if (!sessionId) return false;
|
|
118
|
+
|
|
119
|
+
this._lastTouchMap.delete(sessionId);
|
|
120
|
+
const success = await this.tracker.revoke(sessionId);
|
|
121
|
+
|
|
122
|
+
// If express-session store is available, destroy physical session
|
|
123
|
+
if (this.sessionStore && typeof this.sessionStore.destroy === 'function') {
|
|
124
|
+
try {
|
|
125
|
+
await new Promise((resolve) => this.sessionStore.destroy(sessionId, () => resolve()));
|
|
126
|
+
} catch (err) {
|
|
127
|
+
// Continue even if session store destroy errors
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (this.emitter) {
|
|
132
|
+
this.emitter.emit('session:revoked', {
|
|
133
|
+
sessionId,
|
|
134
|
+
userId: options.userId,
|
|
135
|
+
revokedBy: options.revokedBy,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return success;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Revoke all other sessions for a user except the current one
|
|
144
|
+
* @param {string|number} userId
|
|
145
|
+
* @param {string} currentSessionId
|
|
146
|
+
* @returns {Promise<number>} Count of revoked sessions
|
|
147
|
+
*/
|
|
148
|
+
async revokeOtherSessions(userId, currentSessionId) {
|
|
149
|
+
if (!userId) return 0;
|
|
150
|
+
|
|
151
|
+
const count = await this.tracker.revokeOthers(userId, currentSessionId);
|
|
152
|
+
|
|
153
|
+
if (this.emitter) {
|
|
154
|
+
this.emitter.emit('sessions:revoked_others', {
|
|
155
|
+
userId,
|
|
156
|
+
currentSessionId,
|
|
157
|
+
count,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return count;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Revoke all sessions for a user (e.g. password reset or admin security action)
|
|
166
|
+
* @param {string|number} userId
|
|
167
|
+
* @returns {Promise<number>} Count of revoked sessions
|
|
168
|
+
*/
|
|
169
|
+
async revokeAllSessions(userId) {
|
|
170
|
+
if (!userId) return 0;
|
|
171
|
+
|
|
172
|
+
const count = await this.tracker.revokeAll(userId);
|
|
173
|
+
|
|
174
|
+
if (this.emitter) {
|
|
175
|
+
this.emitter.emit('sessions:revoked_all', {
|
|
176
|
+
userId,
|
|
177
|
+
count,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return count;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Route handler to list active sessions for current user (GET /api/auth/sessions)
|
|
187
|
+
* @param {import('./manager').AuthManager} authManager
|
|
188
|
+
* @returns {Function} Express route handler
|
|
189
|
+
*/
|
|
190
|
+
function createListSessionsHandler(authManager) {
|
|
191
|
+
return async function listSessionsHandler(req, res) {
|
|
192
|
+
if (!req.user || !req.user.id) {
|
|
193
|
+
return res.status(401).json({ error: 'Authentication required' });
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
try {
|
|
197
|
+
const currentSessionId = req.sessionID || null;
|
|
198
|
+
const sessions = await authManager.listSessions(req.user.id, currentSessionId);
|
|
199
|
+
return res.json({ ok: true, sessions });
|
|
200
|
+
} catch (err) {
|
|
201
|
+
return res.status(500).json({ error: err.message || 'Failed to list sessions' });
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Route handler to revoke a session (POST /api/auth/sessions/revoke)
|
|
208
|
+
* @param {import('./manager').AuthManager} authManager
|
|
209
|
+
* @returns {Function} Express route handler
|
|
210
|
+
*/
|
|
211
|
+
function createRevokeSessionHandler(authManager) {
|
|
212
|
+
return async function revokeSessionHandler(req, res) {
|
|
213
|
+
if (!req.user || !req.user.id) {
|
|
214
|
+
return res.status(401).json({ error: 'Authentication required' });
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const { sessionId, allOthers } = req.body || {};
|
|
218
|
+
|
|
219
|
+
try {
|
|
220
|
+
if (allOthers) {
|
|
221
|
+
const count = await authManager.revokeOtherSessions(req.user.id, req.sessionID);
|
|
222
|
+
return res.json({ ok: true, revokedCount: count, message: 'All other sessions revoked' });
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (!sessionId) {
|
|
226
|
+
return res.status(400).json({ error: 'sessionId is required' });
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Check ownership: ensure session belongs to current user unless user is admin
|
|
230
|
+
const sessions = await authManager.listSessions(req.user.id);
|
|
231
|
+
const ownsSession = sessions.some((s) => s.session_id === sessionId);
|
|
232
|
+
|
|
233
|
+
if (!ownsSession && req.user.role !== 'admin') {
|
|
234
|
+
return res.status(403).json({ error: 'Cannot revoke session belonging to another user' });
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
await authManager.revokeSession(sessionId, { userId: req.user.id });
|
|
238
|
+
|
|
239
|
+
// If user revoked their own current session, log them out
|
|
240
|
+
if (sessionId === req.sessionID && req.auth && typeof req.auth.logout === 'function') {
|
|
241
|
+
await req.auth.logout();
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return res.json({ ok: true, message: 'Session revoked successfully' });
|
|
245
|
+
} catch (err) {
|
|
246
|
+
return res.status(500).json({ error: err.message || 'Failed to revoke session' });
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
module.exports = {
|
|
252
|
+
SessionManager,
|
|
253
|
+
createListSessionsHandler,
|
|
254
|
+
createRevokeSessionHandler,
|
|
255
|
+
};
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webspresso Active Sessions & Device Management Store
|
|
3
|
+
* Tracks active devices, last active timestamps, and handles remote session revocation
|
|
4
|
+
* @module core/auth/sessions
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
'use strict';
|
|
8
|
+
|
|
9
|
+
const { parseUserAgent, getClientIp } = require('./user-agent');
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Migration helper to create user_sessions table
|
|
13
|
+
* @param {import('knex').Knex} knex
|
|
14
|
+
* @returns {Promise<void>}
|
|
15
|
+
*/
|
|
16
|
+
async function createUserSessionsTable(knex) {
|
|
17
|
+
const exists = await knex.schema.hasTable('user_sessions');
|
|
18
|
+
if (exists) return;
|
|
19
|
+
|
|
20
|
+
await knex.schema.createTable('user_sessions', (table) => {
|
|
21
|
+
table.bigIncrements('id').primary();
|
|
22
|
+
table.string('session_id', 128).notNullable().unique();
|
|
23
|
+
table.bigInteger('user_id').unsigned().notNullable();
|
|
24
|
+
table.string('ip_address', 64).nullable();
|
|
25
|
+
table.text('user_agent').nullable();
|
|
26
|
+
table.string('browser', 64).nullable();
|
|
27
|
+
table.string('os', 64).nullable();
|
|
28
|
+
table.string('device', 32).nullable();
|
|
29
|
+
table.timestamp('last_active_at').notNullable().defaultTo(knex.fn.now());
|
|
30
|
+
table.timestamp('created_at').defaultTo(knex.fn.now());
|
|
31
|
+
|
|
32
|
+
table.index(['user_id', 'last_active_at']);
|
|
33
|
+
table.index('session_id');
|
|
34
|
+
table.index('last_active_at');
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Migration helper to drop user_sessions table
|
|
40
|
+
* @param {import('knex').Knex} knex
|
|
41
|
+
* @returns {Promise<void>}
|
|
42
|
+
*/
|
|
43
|
+
async function dropUserSessionsTable(knex) {
|
|
44
|
+
await knex.schema.dropTableIfExists('user_sessions');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Create Knex session tracker adapter
|
|
49
|
+
* @param {Object} db - Database with knex or knex instance
|
|
50
|
+
* @param {Object} [options]
|
|
51
|
+
* @param {string} [options.tableName='user_sessions']
|
|
52
|
+
* @returns {Object} Session tracker adapter
|
|
53
|
+
*/
|
|
54
|
+
function createKnexSessionTracker(db, options = {}) {
|
|
55
|
+
const knex = db.knex || db;
|
|
56
|
+
const tableName = options.tableName || 'user_sessions';
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
/**
|
|
60
|
+
* Record or update an active session for a user
|
|
61
|
+
* @param {string} sessionId - Express session ID
|
|
62
|
+
* @param {string|number} userId - User ID
|
|
63
|
+
* @param {Object} [metadata]
|
|
64
|
+
* @param {string} [metadata.ip]
|
|
65
|
+
* @param {string} [metadata.userAgent]
|
|
66
|
+
* @param {string} [metadata.browser]
|
|
67
|
+
* @param {string} [metadata.os]
|
|
68
|
+
* @param {string} [metadata.device]
|
|
69
|
+
* @returns {Promise<Object>}
|
|
70
|
+
*/
|
|
71
|
+
track: async (sessionId, userId, metadata = {}) => {
|
|
72
|
+
if (!sessionId || !userId) return null;
|
|
73
|
+
|
|
74
|
+
const ua = metadata.userAgent || '';
|
|
75
|
+
const parsed = parseUserAgent(ua);
|
|
76
|
+
const ip = metadata.ip || '127.0.0.1';
|
|
77
|
+
const browser = metadata.browser || parsed.browser;
|
|
78
|
+
const os = metadata.os || parsed.os;
|
|
79
|
+
const device = metadata.device || parsed.device;
|
|
80
|
+
|
|
81
|
+
const existing = await knex(tableName).where({ session_id: sessionId }).first();
|
|
82
|
+
const now = new Date();
|
|
83
|
+
|
|
84
|
+
if (existing) {
|
|
85
|
+
await knex(tableName).where({ session_id: sessionId }).update({
|
|
86
|
+
user_id: userId,
|
|
87
|
+
ip_address: ip,
|
|
88
|
+
user_agent: ua ? ua.slice(0, 1000) : null,
|
|
89
|
+
browser,
|
|
90
|
+
os,
|
|
91
|
+
device,
|
|
92
|
+
last_active_at: now,
|
|
93
|
+
});
|
|
94
|
+
return {
|
|
95
|
+
...existing,
|
|
96
|
+
user_id: userId,
|
|
97
|
+
ip_address: ip,
|
|
98
|
+
browser,
|
|
99
|
+
os,
|
|
100
|
+
device,
|
|
101
|
+
last_active_at: now,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const [id] = await knex(tableName).insert({
|
|
106
|
+
session_id: sessionId,
|
|
107
|
+
user_id: userId,
|
|
108
|
+
ip_address: ip,
|
|
109
|
+
user_agent: ua ? ua.slice(0, 1000) : null,
|
|
110
|
+
browser,
|
|
111
|
+
os,
|
|
112
|
+
device,
|
|
113
|
+
last_active_at: now,
|
|
114
|
+
created_at: now,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
id,
|
|
119
|
+
session_id: sessionId,
|
|
120
|
+
user_id: userId,
|
|
121
|
+
ip_address: ip,
|
|
122
|
+
browser,
|
|
123
|
+
os,
|
|
124
|
+
device,
|
|
125
|
+
last_active_at: now,
|
|
126
|
+
created_at: now,
|
|
127
|
+
};
|
|
128
|
+
},
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Touch session to update last_active_at
|
|
132
|
+
* @param {string} sessionId
|
|
133
|
+
* @returns {Promise<boolean>}
|
|
134
|
+
*/
|
|
135
|
+
touch: async (sessionId) => {
|
|
136
|
+
if (!sessionId) return false;
|
|
137
|
+
const count = await knex(tableName).where({ session_id: sessionId }).update({
|
|
138
|
+
last_active_at: new Date(),
|
|
139
|
+
});
|
|
140
|
+
return count > 0;
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Get a session record by session_id
|
|
145
|
+
* @param {string} sessionId
|
|
146
|
+
* @returns {Promise<Object|null>}
|
|
147
|
+
*/
|
|
148
|
+
get: async (sessionId) => {
|
|
149
|
+
if (!sessionId) return null;
|
|
150
|
+
return await knex(tableName).where({ session_id: sessionId }).first() || null;
|
|
151
|
+
},
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* List all active sessions for a user, marking the current session
|
|
155
|
+
* @param {string|number} userId
|
|
156
|
+
* @param {string} [currentSessionId]
|
|
157
|
+
* @returns {Promise<Array<Object>>}
|
|
158
|
+
*/
|
|
159
|
+
list: async (userId, currentSessionId = null) => {
|
|
160
|
+
if (!userId) return [];
|
|
161
|
+
const rows = await knex(tableName)
|
|
162
|
+
.where({ user_id: userId })
|
|
163
|
+
.orderBy('last_active_at', 'desc');
|
|
164
|
+
|
|
165
|
+
return rows.map((row) => ({
|
|
166
|
+
...row,
|
|
167
|
+
is_current: Boolean(currentSessionId && row.session_id === currentSessionId),
|
|
168
|
+
}));
|
|
169
|
+
},
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Check if a session exists and belongs to the given user
|
|
173
|
+
* @param {string} sessionId
|
|
174
|
+
* @param {string|number} [userId]
|
|
175
|
+
* @returns {Promise<boolean>}
|
|
176
|
+
*/
|
|
177
|
+
isSessionValid: async (sessionId, userId = null) => {
|
|
178
|
+
if (!sessionId) return false;
|
|
179
|
+
const query = knex(tableName).where({ session_id: sessionId });
|
|
180
|
+
if (userId) {
|
|
181
|
+
query.andWhere({ user_id: userId });
|
|
182
|
+
}
|
|
183
|
+
const record = await query.first();
|
|
184
|
+
return Boolean(record);
|
|
185
|
+
},
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Revoke a single session
|
|
189
|
+
* @param {string} sessionId
|
|
190
|
+
* @returns {Promise<boolean>}
|
|
191
|
+
*/
|
|
192
|
+
revoke: async (sessionId) => {
|
|
193
|
+
if (!sessionId) return false;
|
|
194
|
+
const count = await knex(tableName).where({ session_id: sessionId }).delete();
|
|
195
|
+
return count > 0;
|
|
196
|
+
},
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Revoke all other sessions for user, keeping the current session active
|
|
200
|
+
* @param {string|number} userId
|
|
201
|
+
* @param {string} currentSessionId
|
|
202
|
+
* @returns {Promise<number>} Number of revoked sessions
|
|
203
|
+
*/
|
|
204
|
+
revokeOthers: async (userId, currentSessionId) => {
|
|
205
|
+
if (!userId) return 0;
|
|
206
|
+
let query = knex(tableName).where({ user_id: userId });
|
|
207
|
+
if (currentSessionId) {
|
|
208
|
+
query = query.whereNot({ session_id: currentSessionId });
|
|
209
|
+
}
|
|
210
|
+
return await query.delete();
|
|
211
|
+
},
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Revoke all sessions for a user (force logout from all devices)
|
|
215
|
+
* @param {string|number} userId
|
|
216
|
+
* @returns {Promise<number>} Number of revoked sessions
|
|
217
|
+
*/
|
|
218
|
+
revokeAll: async (userId) => {
|
|
219
|
+
if (!userId) return 0;
|
|
220
|
+
return await knex(tableName).where({ user_id: userId }).delete();
|
|
221
|
+
},
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Purge sessions older than maxAgeMs
|
|
225
|
+
* @param {number} maxAgeMs
|
|
226
|
+
* @returns {Promise<number>}
|
|
227
|
+
*/
|
|
228
|
+
purgeExpired: async (maxAgeMs) => {
|
|
229
|
+
const cutoff = new Date(Date.now() - maxAgeMs);
|
|
230
|
+
return await knex(tableName).where('last_active_at', '<', cutoff).delete();
|
|
231
|
+
},
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* In-memory session tracker for testing or development without a database table
|
|
237
|
+
* @returns {Object} Session tracker adapter
|
|
238
|
+
*/
|
|
239
|
+
function createMemorySessionTracker() {
|
|
240
|
+
/** @type {Map<string, Object>} */
|
|
241
|
+
const sessions = new Map();
|
|
242
|
+
|
|
243
|
+
return {
|
|
244
|
+
track: async (sessionId, userId, metadata = {}) => {
|
|
245
|
+
if (!sessionId || !userId) return null;
|
|
246
|
+
|
|
247
|
+
const ua = metadata.userAgent || '';
|
|
248
|
+
const parsed = parseUserAgent(ua);
|
|
249
|
+
const ip = metadata.ip || '127.0.0.1';
|
|
250
|
+
const browser = metadata.browser || parsed.browser;
|
|
251
|
+
const os = metadata.os || parsed.os;
|
|
252
|
+
const device = metadata.device || parsed.device;
|
|
253
|
+
const now = new Date();
|
|
254
|
+
|
|
255
|
+
const existing = sessions.get(sessionId);
|
|
256
|
+
const record = {
|
|
257
|
+
id: existing?.id || sessions.size + 1,
|
|
258
|
+
session_id: sessionId,
|
|
259
|
+
user_id: userId,
|
|
260
|
+
ip_address: ip,
|
|
261
|
+
user_agent: ua ? ua.slice(0, 1000) : null,
|
|
262
|
+
browser,
|
|
263
|
+
os,
|
|
264
|
+
device,
|
|
265
|
+
last_active_at: now,
|
|
266
|
+
created_at: existing?.created_at || now,
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
sessions.set(sessionId, record);
|
|
270
|
+
return record;
|
|
271
|
+
},
|
|
272
|
+
|
|
273
|
+
touch: async (sessionId) => {
|
|
274
|
+
const record = sessions.get(sessionId);
|
|
275
|
+
if (!record) return false;
|
|
276
|
+
record.last_active_at = new Date();
|
|
277
|
+
return true;
|
|
278
|
+
},
|
|
279
|
+
|
|
280
|
+
get: async (sessionId) => {
|
|
281
|
+
return sessions.get(sessionId) || null;
|
|
282
|
+
},
|
|
283
|
+
|
|
284
|
+
list: async (userId, currentSessionId = null) => {
|
|
285
|
+
const list = [];
|
|
286
|
+
for (const row of sessions.values()) {
|
|
287
|
+
if (String(row.user_id) === String(userId)) {
|
|
288
|
+
list.push({
|
|
289
|
+
...row,
|
|
290
|
+
is_current: Boolean(currentSessionId && row.session_id === currentSessionId),
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return list.sort((a, b) => b.last_active_at.getTime() - a.last_active_at.getTime());
|
|
295
|
+
},
|
|
296
|
+
|
|
297
|
+
isSessionValid: async (sessionId, userId = null) => {
|
|
298
|
+
const record = sessions.get(sessionId);
|
|
299
|
+
if (!record) return false;
|
|
300
|
+
if (userId && String(record.user_id) !== String(userId)) return false;
|
|
301
|
+
return true;
|
|
302
|
+
},
|
|
303
|
+
|
|
304
|
+
revoke: async (sessionId) => {
|
|
305
|
+
return sessions.delete(sessionId);
|
|
306
|
+
},
|
|
307
|
+
|
|
308
|
+
revokeOthers: async (userId, currentSessionId) => {
|
|
309
|
+
let count = 0;
|
|
310
|
+
for (const [sid, row] of sessions.entries()) {
|
|
311
|
+
if (String(row.user_id) === String(userId) && sid !== currentSessionId) {
|
|
312
|
+
sessions.delete(sid);
|
|
313
|
+
count++;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return count;
|
|
317
|
+
},
|
|
318
|
+
|
|
319
|
+
revokeAll: async (userId) => {
|
|
320
|
+
let count = 0;
|
|
321
|
+
for (const [sid, row] of sessions.entries()) {
|
|
322
|
+
if (String(row.user_id) === String(userId)) {
|
|
323
|
+
sessions.delete(sid);
|
|
324
|
+
count++;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return count;
|
|
328
|
+
},
|
|
329
|
+
|
|
330
|
+
purgeExpired: async (maxAgeMs) => {
|
|
331
|
+
const cutoff = Date.now() - maxAgeMs;
|
|
332
|
+
let count = 0;
|
|
333
|
+
for (const [sid, row] of sessions.entries()) {
|
|
334
|
+
if (row.last_active_at.getTime() < cutoff) {
|
|
335
|
+
sessions.delete(sid);
|
|
336
|
+
count++;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return count;
|
|
340
|
+
},
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
module.exports = {
|
|
345
|
+
createUserSessionsTable,
|
|
346
|
+
dropUserSessionsTable,
|
|
347
|
+
createKnexSessionTracker,
|
|
348
|
+
createMemorySessionTracker,
|
|
349
|
+
getClientIp,
|
|
350
|
+
parseUserAgent,
|
|
351
|
+
};
|
package/core/auth/tokens.js
CHANGED
package/core/auth/totp.js
CHANGED
|
@@ -58,7 +58,10 @@ function base32Decode(input) {
|
|
|
58
58
|
throw new TypeError('Base32 input must be a string');
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
|
|
61
|
+
let clean = input.toUpperCase().replace(/[\s-]/g, '');
|
|
62
|
+
while (clean.endsWith('=')) {
|
|
63
|
+
clean = clean.slice(0, -1);
|
|
64
|
+
}
|
|
62
65
|
if (clean.length === 0) return Buffer.alloc(0);
|
|
63
66
|
|
|
64
67
|
let bits = 0;
|