@wbce-d9/api 11.1.0 → 11.1.2
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/dist/database/migrations/20250814A-add-session-fallback-token.d.ts +3 -0
- package/dist/database/migrations/20250814A-add-session-fallback-token.js +10 -0
- package/dist/database/migrations/20250910A-add-session-tracking.d.ts +3 -0
- package/dist/database/migrations/20250910A-add-session-tracking.js +16 -0
- package/dist/env.js +2 -0
- package/dist/middleware/authenticate.js +60 -44
- package/dist/request/response-interceptor.d.ts +1 -1
- package/dist/services/authentication.js +58 -8
- package/dist/services/items.js +3 -0
- package/dist/services/shares.js +2 -0
- package/package.json +8 -8
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export async function up(knex) {
|
|
2
|
+
await knex.schema.alterTable('directus_sessions', (table) => {
|
|
3
|
+
table.string('fallback_token', 64).nullable();
|
|
4
|
+
});
|
|
5
|
+
}
|
|
6
|
+
export async function down(knex) {
|
|
7
|
+
await knex.schema.alterTable('directus_sessions', (table) => {
|
|
8
|
+
table.dropColumn('fallback_token');
|
|
9
|
+
});
|
|
10
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export async function up(knex) {
|
|
2
|
+
await knex.schema.alterTable('directus_activity', (table) => {
|
|
3
|
+
table.string('session_id', 64).nullable();
|
|
4
|
+
});
|
|
5
|
+
await knex.schema.alterTable('directus_sessions', (table) => {
|
|
6
|
+
table.string('session_id', 64).nullable();
|
|
7
|
+
});
|
|
8
|
+
}
|
|
9
|
+
export async function down(knex) {
|
|
10
|
+
await knex.schema.alterTable('directus_activity', (table) => {
|
|
11
|
+
table.dropColumn('session_id');
|
|
12
|
+
});
|
|
13
|
+
await knex.schema.alterTable('directus_sessions', (table) => {
|
|
14
|
+
table.dropColumn('session_id');
|
|
15
|
+
});
|
|
16
|
+
}
|
package/dist/env.js
CHANGED
|
@@ -43,6 +43,7 @@ const allowedEnvironmentVars = [
|
|
|
43
43
|
'REFRESH_TOKEN_COOKIE_SECURE',
|
|
44
44
|
'REFRESH_TOKEN_COOKIE_SAME_SITE',
|
|
45
45
|
'REFRESH_TOKEN_COOKIE_NAME',
|
|
46
|
+
'REFRESH_TOKEN_OVERLAP_DURATION',
|
|
46
47
|
'LOGIN_STALL_TIME',
|
|
47
48
|
'PASSWORD_RESET_URL_ALLOW_LIST',
|
|
48
49
|
'USER_INVITE_URL_ALLOW_LIST',
|
|
@@ -225,6 +226,7 @@ const defaults = {
|
|
|
225
226
|
REFRESH_TOKEN_COOKIE_SECURE: false,
|
|
226
227
|
REFRESH_TOKEN_COOKIE_SAME_SITE: 'lax',
|
|
227
228
|
REFRESH_TOKEN_COOKIE_NAME: 'directus_refresh_token',
|
|
229
|
+
REFRESH_TOKEN_OVERLAP_DURATION: '10s',
|
|
228
230
|
LOGIN_STALL_TIME: 500,
|
|
229
231
|
SERVER_SHUTDOWN_TIMEOUT: 1000,
|
|
230
232
|
ROOT_REDIRECT: './admin',
|
|
@@ -2,11 +2,12 @@ import { isEqual } from 'lodash-es';
|
|
|
2
2
|
import getDatabase from '../database/index.js';
|
|
3
3
|
import emitter from '../emitter.js';
|
|
4
4
|
import env from '../env.js';
|
|
5
|
-
import { InvalidCredentialsException,
|
|
5
|
+
import { InvalidCredentialsException, InvalidTokenException } from '../exceptions/index.js';
|
|
6
6
|
import asyncHandler from '../utils/async-handler.js';
|
|
7
7
|
import { getIPFromReq } from '../utils/get-ip-from-req.js';
|
|
8
8
|
import isDirectusJWT from '../utils/is-directus-jwt.js';
|
|
9
9
|
import { verifyAccessJWT } from '../utils/jwt.js';
|
|
10
|
+
import { ACCESS_COOKIE_CLEAR_OPTIONS, REFRESH_COOKIE_CLEAR_OPTIONS } from '../constants.js';
|
|
10
11
|
/**
|
|
11
12
|
* Verify the passed JWT and assign the user ID and role to `req`
|
|
12
13
|
*/
|
|
@@ -36,53 +37,68 @@ export const handler = async (req, _res, next) => {
|
|
|
36
37
|
req.accountability = customAccountability;
|
|
37
38
|
return next();
|
|
38
39
|
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
if (
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
40
|
+
try {
|
|
41
|
+
req.accountability = defaultAccountability;
|
|
42
|
+
if (req.token) {
|
|
43
|
+
if (isDirectusJWT(req.token)) {
|
|
44
|
+
const payload = verifyAccessJWT(req.token, env['SECRET']);
|
|
45
|
+
// Try finding the user with a valid refresh token stored in the access token
|
|
46
|
+
const user = await database
|
|
47
|
+
.select({
|
|
48
|
+
session_expires: 's.expires',
|
|
49
|
+
user_id: 'u.id',
|
|
50
|
+
session_id: 's.session_id',
|
|
51
|
+
})
|
|
52
|
+
.from('directus_sessions AS s')
|
|
53
|
+
.leftJoin('directus_users AS u', 's.user', 'u.id')
|
|
54
|
+
.where('s.token', payload.refresh_token)
|
|
55
|
+
.andWhere('s.expires', '>=', new Date())
|
|
56
|
+
.first();
|
|
57
|
+
if (!user) {
|
|
58
|
+
throw new InvalidCredentialsException();
|
|
59
|
+
}
|
|
60
|
+
req.accountability.role = payload.role;
|
|
61
|
+
req.accountability.admin = payload.admin_access === true || payload.admin_access == 1;
|
|
62
|
+
req.accountability.app = payload.app_access === true || payload.app_access == 1;
|
|
63
|
+
req.accountability.session_id = user.session_id;
|
|
64
|
+
if (payload.share)
|
|
65
|
+
req.accountability.share = payload.share;
|
|
66
|
+
if (payload.share_scope)
|
|
67
|
+
req.accountability.share_scope = payload.share_scope;
|
|
68
|
+
if (payload.id)
|
|
69
|
+
req.accountability.user = payload.id;
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
// Try finding the user with the provided token
|
|
73
|
+
const user = await database
|
|
74
|
+
.select('directus_users.id', 'directus_users.role', 'directus_roles.admin_access', 'directus_roles.app_access')
|
|
75
|
+
.from('directus_users')
|
|
76
|
+
.leftJoin('directus_roles', 'directus_users.role', 'directus_roles.id')
|
|
77
|
+
.where({
|
|
78
|
+
'directus_users.token': req.token,
|
|
79
|
+
status: 'active',
|
|
80
|
+
})
|
|
81
|
+
.first();
|
|
82
|
+
if (!user) {
|
|
83
|
+
throw new InvalidCredentialsException();
|
|
84
|
+
}
|
|
85
|
+
req.accountability.user = user.id;
|
|
86
|
+
req.accountability.role = user.role;
|
|
87
|
+
req.accountability.admin = user.admin_access === true || user.admin_access == 1;
|
|
88
|
+
req.accountability.app = user.app_access === true || user.app_access == 1;
|
|
56
89
|
}
|
|
57
|
-
req.accountability.role = payload.role;
|
|
58
|
-
req.accountability.admin = payload.admin_access === true || payload.admin_access == 1;
|
|
59
|
-
req.accountability.app = payload.app_access === true || payload.app_access == 1;
|
|
60
|
-
if (payload.share)
|
|
61
|
-
req.accountability.share = payload.share;
|
|
62
|
-
if (payload.share_scope)
|
|
63
|
-
req.accountability.share_scope = payload.share_scope;
|
|
64
|
-
if (payload.id)
|
|
65
|
-
req.accountability.user = payload.id;
|
|
66
90
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
'
|
|
75
|
-
status: 'active',
|
|
76
|
-
})
|
|
77
|
-
.first();
|
|
78
|
-
if (!user) {
|
|
79
|
-
throw new InvalidCredentialsException();
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
if (error instanceof InvalidCredentialsException || error instanceof InvalidTokenException) {
|
|
94
|
+
if (req?.cookies[env['ACCESS_TOKEN_COOKIE_NAME']]) {
|
|
95
|
+
_res?.clearCookie(env['ACCESS_TOKEN_COOKIE_NAME'], ACCESS_COOKIE_CLEAR_OPTIONS);
|
|
96
|
+
}
|
|
97
|
+
if (req?.cookies[env['REFRESH_TOKEN_COOKIE_NAME']]) {
|
|
98
|
+
_res?.clearCookie(env['REFRESH_TOKEN_COOKIE_NAME'], REFRESH_COOKIE_CLEAR_OPTIONS);
|
|
80
99
|
}
|
|
81
|
-
req.accountability.user = user.id;
|
|
82
|
-
req.accountability.role = user.role;
|
|
83
|
-
req.accountability.admin = user.admin_access === true || user.admin_access == 1;
|
|
84
|
-
req.accountability.app = user.app_access === true || user.app_access == 1;
|
|
85
100
|
}
|
|
101
|
+
throw error;
|
|
86
102
|
}
|
|
87
103
|
return next();
|
|
88
104
|
};
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
import type { AxiosResponse } from 'axios';
|
|
2
|
-
export declare const responseInterceptor: (config: AxiosResponse<any, any>) => Promise<AxiosResponse<any, any>>;
|
|
2
|
+
export declare const responseInterceptor: (config: AxiosResponse<any, any>) => Promise<AxiosResponse<any, any, {}>>;
|
|
@@ -120,6 +120,7 @@ export class AuthenticationService {
|
|
|
120
120
|
}
|
|
121
121
|
const refreshToken = nanoid(64);
|
|
122
122
|
const refreshTokenExpiration = new Date(Date.now() + getMilliseconds(env['REFRESH_TOKEN_TTL'], 0));
|
|
123
|
+
const sessionId = nanoid(64);
|
|
123
124
|
await this.knex('directus_sessions').insert({
|
|
124
125
|
token: refreshToken,
|
|
125
126
|
user: user.id,
|
|
@@ -127,6 +128,7 @@ export class AuthenticationService {
|
|
|
127
128
|
ip: this.accountability?.ip,
|
|
128
129
|
user_agent: this.accountability?.userAgent,
|
|
129
130
|
origin: this.accountability?.origin,
|
|
131
|
+
session_id: sessionId,
|
|
130
132
|
});
|
|
131
133
|
const tokenPayload = {
|
|
132
134
|
id: user.id,
|
|
@@ -159,6 +161,7 @@ export class AuthenticationService {
|
|
|
159
161
|
origin: this.accountability.origin,
|
|
160
162
|
collection: 'directus_users',
|
|
161
163
|
item: user.id,
|
|
164
|
+
session_id: sessionId,
|
|
162
165
|
});
|
|
163
166
|
}
|
|
164
167
|
await this.knex('directus_users').update({ last_access: new Date() }).where({ id: user.id });
|
|
@@ -184,6 +187,8 @@ export class AuthenticationService {
|
|
|
184
187
|
const record = await this.knex
|
|
185
188
|
.select({
|
|
186
189
|
session_expires: 's.expires',
|
|
190
|
+
session_fallback_token: 's.fallback_token',
|
|
191
|
+
session_id: 's.session_id',
|
|
187
192
|
user_id: 'u.id',
|
|
188
193
|
user_first_name: 'u.first_name',
|
|
189
194
|
user_last_name: 'u.last_name',
|
|
@@ -251,14 +256,54 @@ export class AuthenticationService {
|
|
|
251
256
|
admin_access: record.role_admin_access,
|
|
252
257
|
});
|
|
253
258
|
}
|
|
254
|
-
|
|
255
|
-
const refreshTokenExpiration = new Date(Date.now() + getMilliseconds(env['REFRESH_TOKEN_TTL'], 0));
|
|
259
|
+
// Clean up expired sessions for this user first
|
|
256
260
|
await this.knex('directus_sessions')
|
|
257
|
-
.
|
|
258
|
-
|
|
259
|
-
expires
|
|
260
|
-
|
|
261
|
-
|
|
261
|
+
.delete()
|
|
262
|
+
.where('user', '=', record.user_id)
|
|
263
|
+
.andWhere('expires', '<', new Date());
|
|
264
|
+
let newRefreshToken = record.session_fallback_token ?? nanoid(64);
|
|
265
|
+
const refreshTokenExpiration = new Date(Date.now() + getMilliseconds(env['REFRESH_TOKEN_TTL'], 0));
|
|
266
|
+
const sessionId = record.session_id ?? nanoid(64);
|
|
267
|
+
// If a fallback session already exists, just update its expiration
|
|
268
|
+
if (record.session_fallback_token) {
|
|
269
|
+
await this.knex('directus_sessions')
|
|
270
|
+
.update({
|
|
271
|
+
expires: refreshTokenExpiration,
|
|
272
|
+
})
|
|
273
|
+
.where({ token: newRefreshToken });
|
|
274
|
+
}
|
|
275
|
+
else {
|
|
276
|
+
// Otherwise, create the race condition protection mechanism
|
|
277
|
+
const overlapDuration = getMilliseconds(env['REFRESH_TOKEN_OVERLAP_DURATION'], 10000);
|
|
278
|
+
// Atomic protection: only the first request can create the fallback_token
|
|
279
|
+
const sessionUpdated = await this.knex('directus_sessions')
|
|
280
|
+
.update({
|
|
281
|
+
fallback_token: newRefreshToken,
|
|
282
|
+
expires: new Date(Date.now() + overlapDuration), // Current session expires after the overlapDuration
|
|
283
|
+
})
|
|
284
|
+
.where({ token: refreshToken, fallback_token: null }); // Atomic condition to prevent race conditions
|
|
285
|
+
if (sessionUpdated === 0) {
|
|
286
|
+
// Another request already created the fallback_token, retrieve it
|
|
287
|
+
const existingFallback = await this.knex('directus_sessions')
|
|
288
|
+
.select('fallback_token')
|
|
289
|
+
.where({ token: refreshToken })
|
|
290
|
+
.first();
|
|
291
|
+
newRefreshToken = existingFallback.fallback_token;
|
|
292
|
+
}
|
|
293
|
+
else {
|
|
294
|
+
// We successfully created the fallback_token, create the new session
|
|
295
|
+
await this.knex('directus_sessions').insert({
|
|
296
|
+
token: newRefreshToken,
|
|
297
|
+
user: record.user_id,
|
|
298
|
+
share: record.share_id,
|
|
299
|
+
expires: refreshTokenExpiration,
|
|
300
|
+
ip: this.accountability?.ip,
|
|
301
|
+
user_agent: this.accountability?.userAgent,
|
|
302
|
+
origin: this.accountability?.origin,
|
|
303
|
+
session_id: sessionId,
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
}
|
|
262
307
|
const tokenPayload = {
|
|
263
308
|
id: record.user_id,
|
|
264
309
|
role: record.role_id,
|
|
@@ -312,7 +357,12 @@ export class AuthenticationService {
|
|
|
312
357
|
const user = record;
|
|
313
358
|
const provider = getAuthProvider(user.provider);
|
|
314
359
|
await provider.logout(clone(user));
|
|
315
|
-
|
|
360
|
+
// Remove all related sessions (main and fallback)
|
|
361
|
+
await this.knex
|
|
362
|
+
.delete()
|
|
363
|
+
.from('directus_sessions')
|
|
364
|
+
.where('token', refreshToken)
|
|
365
|
+
.orWhere('fallback_token', refreshToken);
|
|
316
366
|
}
|
|
317
367
|
}
|
|
318
368
|
async verifyPassword(userID, password) {
|
package/dist/services/items.js
CHANGED
|
@@ -162,6 +162,7 @@ export class ItemsService {
|
|
|
162
162
|
user_agent: this.accountability.userAgent,
|
|
163
163
|
origin: this.accountability.origin,
|
|
164
164
|
item: primaryKey,
|
|
165
|
+
session_id: this.accountability.session_id,
|
|
165
166
|
});
|
|
166
167
|
// If revisions are tracked, create revisions record
|
|
167
168
|
if (this.schema.collections[this.collection].accountability === 'all') {
|
|
@@ -496,6 +497,7 @@ export class ItemsService {
|
|
|
496
497
|
user_agent: this.accountability.userAgent,
|
|
497
498
|
origin: this.accountability.origin,
|
|
498
499
|
item: key,
|
|
500
|
+
session_id: this.accountability?.session_id,
|
|
499
501
|
})), { bypassLimits: true });
|
|
500
502
|
if (this.schema.collections[this.collection].accountability === 'all') {
|
|
501
503
|
const itemsService = new ItemsService(this.collection, {
|
|
@@ -680,6 +682,7 @@ export class ItemsService {
|
|
|
680
682
|
user_agent: this.accountability.userAgent,
|
|
681
683
|
origin: this.accountability.origin,
|
|
682
684
|
item: key,
|
|
685
|
+
session_id: this.accountability.session_id,
|
|
683
686
|
})), { bypassLimits: true });
|
|
684
687
|
}
|
|
685
688
|
});
|
package/dist/services/shares.js
CHANGED
|
@@ -61,6 +61,7 @@ export class SharesService extends ItemsService {
|
|
|
61
61
|
.where('id', record.share_id);
|
|
62
62
|
const refreshToken = nanoid(64);
|
|
63
63
|
const refreshTokenExpiration = new Date(Date.now() + getMilliseconds(env['REFRESH_TOKEN_TTL'], 0));
|
|
64
|
+
const sessionId = nanoid(64);
|
|
64
65
|
await this.knex('directus_sessions').insert({
|
|
65
66
|
token: refreshToken,
|
|
66
67
|
expires: refreshTokenExpiration,
|
|
@@ -68,6 +69,7 @@ export class SharesService extends ItemsService {
|
|
|
68
69
|
user_agent: this.accountability?.userAgent,
|
|
69
70
|
origin: this.accountability?.origin,
|
|
70
71
|
share: record.share_id,
|
|
72
|
+
session_id: sessionId,
|
|
71
73
|
});
|
|
72
74
|
await this.knex('directus_sessions').delete().where('expires', '<', new Date());
|
|
73
75
|
const tokenPayload = {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wbce-d9/api",
|
|
3
|
-
"version": "11.1.
|
|
3
|
+
"version": "11.1.2",
|
|
4
4
|
"description": "Directus is a real-time API and App dashboard for managing SQL database content",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -72,7 +72,7 @@
|
|
|
72
72
|
"@wbce-d9/format-title": "10.0.0",
|
|
73
73
|
"argon2": "0.30.3",
|
|
74
74
|
"async": "3.2.4",
|
|
75
|
-
"axios": "^1.
|
|
75
|
+
"axios": "^1.12.0",
|
|
76
76
|
"busboy": "1.6.0",
|
|
77
77
|
"bytes": "3.1.2",
|
|
78
78
|
"camelcase": "7.0.1",
|
|
@@ -143,19 +143,19 @@
|
|
|
143
143
|
"uuid": "9.0.0",
|
|
144
144
|
"uuid-validate": "0.0.3",
|
|
145
145
|
"wellknown": "0.5.0",
|
|
146
|
-
"@wbce-d9/app": "9.33.
|
|
146
|
+
"@wbce-d9/app": "9.33.3",
|
|
147
|
+
"@wbce-d9/exceptions": "9.26.4",
|
|
148
|
+
"@wbce-d9/extensions-sdk": "9.27.3",
|
|
147
149
|
"@wbce-d9/constants": "9.28.0",
|
|
148
|
-
"@wbce-d9/schema": "9.26.4",
|
|
149
150
|
"@wbce-d9/specs": "9.26.5",
|
|
150
|
-
"@wbce-d9/exceptions": "9.26.4",
|
|
151
151
|
"@wbce-d9/storage": "9.26.4",
|
|
152
152
|
"@wbce-d9/storage-driver-azure": "9.26.9",
|
|
153
|
-
"@wbce-d9/
|
|
153
|
+
"@wbce-d9/storage-driver-cloudinary": "9.27.2",
|
|
154
154
|
"@wbce-d9/storage-driver-gcs": "9.26.9",
|
|
155
155
|
"@wbce-d9/storage-driver-s3": "9.26.11",
|
|
156
156
|
"@wbce-d9/storage-driver-local": "9.26.9",
|
|
157
157
|
"@wbce-d9/update-check": "9.28.0",
|
|
158
|
-
"@wbce-d9/
|
|
158
|
+
"@wbce-d9/schema": "9.26.4",
|
|
159
159
|
"@wbce-d9/utils": "9.29.2"
|
|
160
160
|
},
|
|
161
161
|
"devDependencies": {
|
|
@@ -204,7 +204,7 @@
|
|
|
204
204
|
"supertest": "6.3.3",
|
|
205
205
|
"typescript": "5.0.4",
|
|
206
206
|
"vitest": "0.30.1",
|
|
207
|
-
"@wbce-d9/types": "9.28.
|
|
207
|
+
"@wbce-d9/types": "9.28.1"
|
|
208
208
|
},
|
|
209
209
|
"optionalDependencies": {
|
|
210
210
|
"@keyv/redis": "2.5.7",
|