@yunsoft/yuncms-core 0.1.3 → 0.1.6
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/package.json +1 -1
- package/src/auth/external-state.js +76 -0
- package/src/bootstrap.js +6 -0
- package/src/cache.js +85 -0
- package/src/config.js +115 -20
- package/src/context.js +5 -2
- package/src/hooks.js +117 -32
- package/src/index.js +32 -7
- package/src/mail/smtp-mailer.js +58 -14
- package/src/maintenance-state.js +89 -0
- package/src/migrations/0011-role-permission-actions.js +15 -0
- package/src/migrations/0012-files-read-filters.js +14 -0
- package/src/migrations/0013-external-auth-foundation.js +35 -0
- package/src/migrations.js +113 -3
- package/src/query.js +156 -54
- package/src/redis.js +300 -0
- package/src/relation-expansion.js +564 -142
- package/src/services/auth-service.js +40 -2
- package/src/services/core-services.js +2 -0
- package/src/services/external-auth-service.js +323 -0
- package/src/services/files-service.js +85 -7
- package/src/services/items-service.js +121 -81
- package/src/services/permissions-service.js +35 -15
- package/src/services/roles-service.js +44 -23
- package/src/services/users-service.js +27 -3
- package/src/system-permissions.js +50 -7
|
@@ -38,6 +38,16 @@ function publicUser(user) {
|
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
export class AuthService extends BaseService {
|
|
41
|
+
async action(event, payload, context = {}) {
|
|
42
|
+
if (!this.emitter) return;
|
|
43
|
+
await this.emitter.action(event, payload, {
|
|
44
|
+
accountability: this.accountability,
|
|
45
|
+
requestId: this.requestId,
|
|
46
|
+
collection: 'yuncms_users',
|
|
47
|
+
...context,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
41
51
|
createSessionsService() {
|
|
42
52
|
return new SessionsService({
|
|
43
53
|
accountability: this.accountability,
|
|
@@ -78,10 +88,19 @@ export class AuthService extends BaseService {
|
|
|
78
88
|
);
|
|
79
89
|
|
|
80
90
|
if (!user || !passwordMatches || user.status !== 'active') {
|
|
91
|
+
await this.action('auth.login.failed', {
|
|
92
|
+
method: 'local',
|
|
93
|
+
reason: 'invalid_credentials',
|
|
94
|
+
}, { ip });
|
|
81
95
|
throw invalidLogin();
|
|
82
96
|
}
|
|
83
97
|
|
|
84
98
|
const tokens = await this.createSessionsService().createForUser(user, { ip, userAgent });
|
|
99
|
+
await this.action('auth.login.success', {
|
|
100
|
+
method: 'local',
|
|
101
|
+
user: user.id,
|
|
102
|
+
role: user.role ?? null,
|
|
103
|
+
}, { ip });
|
|
85
104
|
return {
|
|
86
105
|
user: publicUser(user),
|
|
87
106
|
...tokens,
|
|
@@ -138,6 +157,11 @@ export class AuthService extends BaseService {
|
|
|
138
157
|
|
|
139
158
|
async refresh(refreshToken) {
|
|
140
159
|
const result = await this.createSessionsService().rotateRefreshToken(refreshToken);
|
|
160
|
+
await this.action('auth.refresh.success', {
|
|
161
|
+
method: 'session',
|
|
162
|
+
user: result.user,
|
|
163
|
+
role: result.role ?? null,
|
|
164
|
+
});
|
|
141
165
|
return {
|
|
142
166
|
user: {
|
|
143
167
|
id: result.user,
|
|
@@ -153,7 +177,14 @@ export class AuthService extends BaseService {
|
|
|
153
177
|
}
|
|
154
178
|
|
|
155
179
|
async logout(accessToken) {
|
|
156
|
-
|
|
180
|
+
const revoked = await this.createSessionsService().revokeByAccessToken(accessToken);
|
|
181
|
+
await this.action('auth.logout', {
|
|
182
|
+
method: 'session',
|
|
183
|
+
user: this.accountability.user ?? null,
|
|
184
|
+
all: false,
|
|
185
|
+
revoked: Boolean(revoked),
|
|
186
|
+
});
|
|
187
|
+
return revoked;
|
|
157
188
|
}
|
|
158
189
|
|
|
159
190
|
async logoutAll() {
|
|
@@ -162,6 +193,13 @@ export class AuthService extends BaseService {
|
|
|
162
193
|
error.code = 'UNAUTHORIZED';
|
|
163
194
|
throw error;
|
|
164
195
|
}
|
|
165
|
-
|
|
196
|
+
const revoked = await this.createSessionsService().revokeAllForUser(this.accountability.user);
|
|
197
|
+
await this.action('auth.logout', {
|
|
198
|
+
method: 'session',
|
|
199
|
+
user: this.accountability.user,
|
|
200
|
+
all: true,
|
|
201
|
+
revoked: Number(revoked ?? 0),
|
|
202
|
+
});
|
|
203
|
+
return revoked;
|
|
166
204
|
}
|
|
167
205
|
}
|
|
@@ -3,6 +3,7 @@ import { AuditService } from './audit-service.js';
|
|
|
3
3
|
import { AuthService } from './auth-service.js';
|
|
4
4
|
import { AuthTokensService } from './auth-tokens-service.js';
|
|
5
5
|
import { CollectionsService } from './collections-service.js';
|
|
6
|
+
import { ExternalAuthService } from './external-auth-service.js';
|
|
6
7
|
import { FieldsService } from './fields-service.js';
|
|
7
8
|
import { FileReconciliationService } from './file-reconciliation-service.js';
|
|
8
9
|
import { FilesService } from './files-service.js';
|
|
@@ -19,6 +20,7 @@ export function createCoreServiceRegistry() {
|
|
|
19
20
|
return createServiceRegistry({
|
|
20
21
|
AuthService,
|
|
21
22
|
AuthTokensService,
|
|
23
|
+
ExternalAuthService,
|
|
22
24
|
ApiTokensService,
|
|
23
25
|
AuditService,
|
|
24
26
|
ItemsService,
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
assertLocalRedirectTarget,
|
|
5
|
+
decryptExternalAuthSecret,
|
|
6
|
+
encryptExternalAuthSecret,
|
|
7
|
+
hashExternalAuthState,
|
|
8
|
+
} from '../auth/external-state.js';
|
|
9
|
+
import { withTransaction } from '../transaction.js';
|
|
10
|
+
import { BaseService } from './base-service.js';
|
|
11
|
+
import { SessionsService } from './sessions-service.js';
|
|
12
|
+
|
|
13
|
+
const AUTH_TRANSACTION_TTL_MS = 5 * 60 * 1000;
|
|
14
|
+
|
|
15
|
+
function externalAuthError(code, message) {
|
|
16
|
+
const error = new Error(message);
|
|
17
|
+
error.code = code;
|
|
18
|
+
return error;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function normalizeProvider(value) {
|
|
22
|
+
const provider = String(value ?? '').trim();
|
|
23
|
+
if (!/^[a-z0-9][a-z0-9._-]{0,63}$/i.test(provider)) {
|
|
24
|
+
throw externalAuthError('INVALID_AUTH_PROVIDER', 'External auth provider id is invalid');
|
|
25
|
+
}
|
|
26
|
+
return provider;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function normalizeSubject(value) {
|
|
30
|
+
const subject = String(value ?? '').trim();
|
|
31
|
+
if (!subject || subject.length > 255 || /[\r\n\0]/.test(subject)) {
|
|
32
|
+
throw externalAuthError('INVALID_EXTERNAL_IDENTITY', 'External identity subject is invalid');
|
|
33
|
+
}
|
|
34
|
+
return subject;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function normalizeEmail(value) {
|
|
38
|
+
if (value == null || value === '') return null;
|
|
39
|
+
const email = String(value).trim().toLowerCase();
|
|
40
|
+
if (!email || email.length > 191 || !email.includes('@') || /[\r\n\0]/.test(email)) return null;
|
|
41
|
+
return email;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function safeProfile(profile) {
|
|
45
|
+
if (!profile || typeof profile !== 'object' || Array.isArray(profile)) return null;
|
|
46
|
+
const safe = {};
|
|
47
|
+
for (const key of ['name', 'given_name', 'family_name', 'preferred_username', 'picture', 'groups']) {
|
|
48
|
+
if (!Object.hasOwn(profile, key)) continue;
|
|
49
|
+
const value = profile[key];
|
|
50
|
+
if (typeof value === 'string' && value.length <= 1024) safe[key] = value;
|
|
51
|
+
else if (Array.isArray(value) && value.length <= 100) safe[key] = value.filter((entry) => typeof entry === 'string').slice(0, 100);
|
|
52
|
+
}
|
|
53
|
+
return Object.keys(safe).length ? safe : null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function publicUser(user) {
|
|
57
|
+
return {
|
|
58
|
+
id: user.id,
|
|
59
|
+
email: user.email,
|
|
60
|
+
role: user.role ?? null,
|
|
61
|
+
role_name: user.role_name ?? null,
|
|
62
|
+
status: user.status,
|
|
63
|
+
email_verified_at: user.email_verified_at ?? null,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function isDuplicateEntry(error) {
|
|
68
|
+
return error?.code === 'ER_DUP_ENTRY' || error?.errno === 1062;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export class ExternalAuthService extends BaseService {
|
|
72
|
+
constructor(options = {}) {
|
|
73
|
+
super(options);
|
|
74
|
+
this.stateSecret = options.stateSecret;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async action(event, payload, context = {}) {
|
|
78
|
+
if (!this.emitter) return;
|
|
79
|
+
await this.emitter.action(event, payload, {
|
|
80
|
+
accountability: this.accountability,
|
|
81
|
+
requestId: this.requestId,
|
|
82
|
+
...context,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async beginTransaction({ provider, state, secret = null, redirectTarget = '/', metadata = null, ttlMs = AUTH_TRANSACTION_TTL_MS } = {}) {
|
|
87
|
+
const providerId = normalizeProvider(provider);
|
|
88
|
+
const stateHash = hashExternalAuthState(state);
|
|
89
|
+
const redirect = assertLocalRedirectTarget(redirectTarget);
|
|
90
|
+
if (!Number.isInteger(ttlMs) || ttlMs < 30_000 || ttlMs > 15 * 60_000) {
|
|
91
|
+
throw externalAuthError('INVALID_AUTH_TRANSACTION', 'External auth transaction TTL must be between 30 seconds and 15 minutes');
|
|
92
|
+
}
|
|
93
|
+
const expiresAt = new Date(Date.now() + ttlMs);
|
|
94
|
+
const id = randomUUID();
|
|
95
|
+
const encrypted = secret == null ? null : encryptExternalAuthSecret(this.stateSecret, secret);
|
|
96
|
+
const safeMetadata = metadata && typeof metadata === 'object' && !Array.isArray(metadata) ? metadata : null;
|
|
97
|
+
|
|
98
|
+
await this.database.query(
|
|
99
|
+
`INSERT INTO yuncms_auth_transactions
|
|
100
|
+
(id, provider, state_hash, secret_ciphertext, redirect_target, metadata, expires_at)
|
|
101
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
102
|
+
[id, providerId, stateHash, encrypted, redirect, safeMetadata == null ? null : JSON.stringify(safeMetadata), expiresAt],
|
|
103
|
+
);
|
|
104
|
+
return { id, provider: providerId, redirectTarget: redirect, expiresAt };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async consumeTransaction({ provider, state } = {}) {
|
|
108
|
+
const providerId = normalizeProvider(provider);
|
|
109
|
+
const stateHash = hashExternalAuthState(state);
|
|
110
|
+
|
|
111
|
+
return withTransaction(this.database, async (connection) => {
|
|
112
|
+
const [rows] = await connection.query(
|
|
113
|
+
`SELECT id, provider, secret_ciphertext, redirect_target, metadata, expires_at, used_at
|
|
114
|
+
FROM yuncms_auth_transactions
|
|
115
|
+
WHERE provider = ? AND state_hash = ?
|
|
116
|
+
LIMIT 1 FOR UPDATE`,
|
|
117
|
+
[providerId, stateHash],
|
|
118
|
+
);
|
|
119
|
+
const transaction = rows[0];
|
|
120
|
+
if (!transaction || transaction.used_at || new Date(transaction.expires_at).getTime() <= Date.now()) {
|
|
121
|
+
throw externalAuthError('INVALID_AUTH_TRANSACTION', 'External authentication transaction is invalid or expired');
|
|
122
|
+
}
|
|
123
|
+
const [result] = await connection.query(
|
|
124
|
+
`UPDATE yuncms_auth_transactions
|
|
125
|
+
SET used_at = CURRENT_TIMESTAMP(3)
|
|
126
|
+
WHERE id = ? AND used_at IS NULL`,
|
|
127
|
+
[transaction.id],
|
|
128
|
+
);
|
|
129
|
+
if (result.affectedRows !== 1) throw externalAuthError('INVALID_AUTH_TRANSACTION', 'External authentication transaction was already consumed');
|
|
130
|
+
|
|
131
|
+
let metadata = transaction.metadata;
|
|
132
|
+
if (typeof metadata === 'string') {
|
|
133
|
+
try { metadata = JSON.parse(metadata); } catch { metadata = null; }
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
id: transaction.id,
|
|
137
|
+
provider: transaction.provider,
|
|
138
|
+
redirectTarget: assertLocalRedirectTarget(transaction.redirect_target),
|
|
139
|
+
metadata: metadata && typeof metadata === 'object' ? metadata : null,
|
|
140
|
+
secret: transaction.secret_ciphertext == null ? null : decryptExternalAuthSecret(this.stateSecret, transaction.secret_ciphertext),
|
|
141
|
+
};
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async cleanupTransactions({ batchSize = 1000 } = {}) {
|
|
146
|
+
if (!Number.isInteger(batchSize) || batchSize < 1 || batchSize > 5000) throw new Error('Invalid auth transaction cleanup batch size');
|
|
147
|
+
const [rows] = await this.database.query(
|
|
148
|
+
`SELECT id FROM yuncms_auth_transactions
|
|
149
|
+
WHERE expires_at < CURRENT_TIMESTAMP(3) OR used_at IS NOT NULL
|
|
150
|
+
ORDER BY created_at ASC
|
|
151
|
+
LIMIT ?`,
|
|
152
|
+
[batchSize],
|
|
153
|
+
);
|
|
154
|
+
if (rows.length === 0) return 0;
|
|
155
|
+
const placeholders = rows.map(() => '?').join(', ');
|
|
156
|
+
const [result] = await this.database.query(
|
|
157
|
+
`DELETE FROM yuncms_auth_transactions WHERE id IN (${placeholders})`,
|
|
158
|
+
rows.map((row) => row.id),
|
|
159
|
+
);
|
|
160
|
+
return result.affectedRows;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async readIdentity(provider, subject, database = this.database) {
|
|
164
|
+
const [rows] = await database.query(
|
|
165
|
+
`SELECT i.id AS identity_id, i.provider, i.subject, i.user, i.email AS identity_email,
|
|
166
|
+
u.id, u.email, u.role, u.status, u.email_verified_at,
|
|
167
|
+
r.name AS role_name, r.admin AS role_admin, r.public AS role_public
|
|
168
|
+
FROM yuncms_auth_identities i
|
|
169
|
+
INNER JOIN yuncms_users u ON u.id = i.user
|
|
170
|
+
LEFT JOIN yuncms_roles r ON r.id = u.role
|
|
171
|
+
WHERE i.provider = ? AND i.subject = ?
|
|
172
|
+
LIMIT 1`,
|
|
173
|
+
[normalizeProvider(provider), normalizeSubject(subject)],
|
|
174
|
+
);
|
|
175
|
+
return rows[0] ?? null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async resolveJitRole(roleId, database) {
|
|
179
|
+
if (!roleId) throw externalAuthError('EXTERNAL_JIT_ROLE_REQUIRED', 'JIT external authentication requires a default role');
|
|
180
|
+
const [rows] = await database.query(
|
|
181
|
+
'SELECT id, name, admin, public FROM yuncms_roles WHERE id = ? LIMIT 1',
|
|
182
|
+
[String(roleId)],
|
|
183
|
+
);
|
|
184
|
+
const role = rows[0];
|
|
185
|
+
if (!role || role.admin || role.public) {
|
|
186
|
+
throw externalAuthError('INVALID_EXTERNAL_JIT_ROLE', 'External JIT role must be an existing non-admin, non-public role');
|
|
187
|
+
}
|
|
188
|
+
return role;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async #createOrLinkIdentity({ providerId, subjectId, normalizedEmail, emailVerified, sanitizedProfile, policy }) {
|
|
192
|
+
if (!normalizedEmail || emailVerified !== true) {
|
|
193
|
+
throw externalAuthError('VERIFIED_EXTERNAL_EMAIL_REQUIRED', 'A verified external email is required to create or link a YunCMS user');
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
try {
|
|
197
|
+
return await withTransaction(this.database, async (connection) => {
|
|
198
|
+
const existingIdentity = await this.readIdentity(providerId, subjectId, connection);
|
|
199
|
+
if (existingIdentity) return existingIdentity.user;
|
|
200
|
+
|
|
201
|
+
const [emailRows] = await connection.query(
|
|
202
|
+
`SELECT u.id, u.email, u.role, u.status, u.email_verified_at,
|
|
203
|
+
r.name AS role_name, r.admin AS role_admin, r.public AS role_public
|
|
204
|
+
FROM yuncms_users u
|
|
205
|
+
LEFT JOIN yuncms_roles r ON r.id = u.role
|
|
206
|
+
WHERE u.email = ? LIMIT 1 FOR UPDATE`,
|
|
207
|
+
[normalizedEmail],
|
|
208
|
+
);
|
|
209
|
+
const existingUser = emailRows[0] ?? null;
|
|
210
|
+
let localUserId;
|
|
211
|
+
|
|
212
|
+
if (existingUser) {
|
|
213
|
+
if (policy.linkByVerifiedEmail !== true) {
|
|
214
|
+
throw externalAuthError('EXTERNAL_EMAIL_CONFLICT', 'A YunCMS user already exists for this email and automatic linking is disabled');
|
|
215
|
+
}
|
|
216
|
+
if (existingUser.status !== 'active') throw externalAuthError('EXTERNAL_USER_INACTIVE', 'Linked YunCMS user is not active');
|
|
217
|
+
if (existingUser.role_admin && policy.allowAdminLink !== true) {
|
|
218
|
+
throw externalAuthError('EXTERNAL_ADMIN_LINK_FORBIDDEN', 'Automatic external identity linking to administrator users is disabled');
|
|
219
|
+
}
|
|
220
|
+
localUserId = existingUser.id;
|
|
221
|
+
} else {
|
|
222
|
+
if (policy.jit !== true) throw externalAuthError('EXTERNAL_IDENTITY_NOT_LINKED', 'External identity is not linked to a YunCMS user');
|
|
223
|
+
const role = await this.resolveJitRole(policy.defaultRole, connection);
|
|
224
|
+
localUserId = randomUUID();
|
|
225
|
+
await connection.query(
|
|
226
|
+
`INSERT INTO yuncms_users
|
|
227
|
+
(id, email, password_hash, role, status, email_verified_at)
|
|
228
|
+
VALUES (?, ?, NULL, ?, 'active', CURRENT_TIMESTAMP(3))`,
|
|
229
|
+
[localUserId, normalizedEmail, role.id],
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
await connection.query(
|
|
234
|
+
`INSERT INTO yuncms_auth_identities
|
|
235
|
+
(id, provider, subject, user, email, profile, last_login_at)
|
|
236
|
+
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP(3))`,
|
|
237
|
+
[randomUUID(), providerId, subjectId, localUserId, normalizedEmail, sanitizedProfile == null ? null : JSON.stringify(sanitizedProfile)],
|
|
238
|
+
);
|
|
239
|
+
return localUserId;
|
|
240
|
+
});
|
|
241
|
+
} catch (error) {
|
|
242
|
+
if (!isDuplicateEntry(error)) throw error;
|
|
243
|
+
const identity = await this.readIdentity(providerId, subjectId);
|
|
244
|
+
if (!identity) throw error;
|
|
245
|
+
return identity.user;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async completeLogin({
|
|
250
|
+
provider,
|
|
251
|
+
subject,
|
|
252
|
+
email = null,
|
|
253
|
+
emailVerified = false,
|
|
254
|
+
profile = null,
|
|
255
|
+
policy = {},
|
|
256
|
+
ip = null,
|
|
257
|
+
userAgent = null,
|
|
258
|
+
} = {}) {
|
|
259
|
+
const providerId = normalizeProvider(provider);
|
|
260
|
+
const subjectId = normalizeSubject(subject);
|
|
261
|
+
const normalizedEmail = normalizeEmail(email);
|
|
262
|
+
const sanitizedProfile = safeProfile(profile);
|
|
263
|
+
let user = await this.readIdentity(providerId, subjectId);
|
|
264
|
+
|
|
265
|
+
if (!user) {
|
|
266
|
+
if (policy.jit !== true && policy.linkByVerifiedEmail !== true) {
|
|
267
|
+
throw externalAuthError('EXTERNAL_IDENTITY_NOT_LINKED', 'External identity is not linked to a YunCMS user');
|
|
268
|
+
}
|
|
269
|
+
const userId = await this.#createOrLinkIdentity({
|
|
270
|
+
providerId,
|
|
271
|
+
subjectId,
|
|
272
|
+
normalizedEmail,
|
|
273
|
+
emailVerified,
|
|
274
|
+
sanitizedProfile,
|
|
275
|
+
policy,
|
|
276
|
+
});
|
|
277
|
+
const [userRows] = await this.database.query(
|
|
278
|
+
`SELECT u.id, u.email, u.role, u.status, u.email_verified_at,
|
|
279
|
+
r.name AS role_name, r.admin AS role_admin, r.public AS role_public
|
|
280
|
+
FROM yuncms_users u
|
|
281
|
+
LEFT JOIN yuncms_roles r ON r.id = u.role
|
|
282
|
+
WHERE u.id = ? LIMIT 1`,
|
|
283
|
+
[userId],
|
|
284
|
+
);
|
|
285
|
+
user = userRows[0] ?? null;
|
|
286
|
+
} else {
|
|
287
|
+
await this.database.query(
|
|
288
|
+
`UPDATE yuncms_auth_identities
|
|
289
|
+
SET email = COALESCE(?, email), profile = COALESCE(?, profile), last_login_at = CURRENT_TIMESTAMP(3)
|
|
290
|
+
WHERE id = ?`,
|
|
291
|
+
[normalizedEmail, sanitizedProfile == null ? null : JSON.stringify(sanitizedProfile), user.identity_id],
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (!user || user.status !== 'active') throw externalAuthError('EXTERNAL_USER_INACTIVE', 'Linked YunCMS user is not active');
|
|
296
|
+
const sessions = new SessionsService({
|
|
297
|
+
accountability: this.accountability,
|
|
298
|
+
database: this.database,
|
|
299
|
+
schema: this.schema,
|
|
300
|
+
emitter: this.emitter,
|
|
301
|
+
logger: this.logger,
|
|
302
|
+
requestId: this.requestId,
|
|
303
|
+
});
|
|
304
|
+
const tokens = await sessions.createForUser(user, { ip, userAgent });
|
|
305
|
+
await this.action('auth.login.success', {
|
|
306
|
+
method: 'external',
|
|
307
|
+
provider: providerId,
|
|
308
|
+
user: user.id,
|
|
309
|
+
role: user.role ?? null,
|
|
310
|
+
});
|
|
311
|
+
return { user: publicUser(user), ...tokens };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async loginFailed(provider, reason = 'external_auth_failed') {
|
|
315
|
+
await this.action('auth.login.failed', {
|
|
316
|
+
method: 'external',
|
|
317
|
+
provider: normalizeProvider(provider),
|
|
318
|
+
reason: String(reason).slice(0, 100),
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export { AUTH_TRANSACTION_TTL_MS };
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
|
|
3
|
+
import { compileFilter } from '../query.js';
|
|
3
4
|
import { BaseService } from './base-service.js';
|
|
4
5
|
import { resolveSystemResourceAccess } from './system-resource-access.js';
|
|
5
6
|
|
|
@@ -27,6 +28,38 @@ function normalizeMimeType(value) {
|
|
|
27
28
|
return mimetype;
|
|
28
29
|
}
|
|
29
30
|
|
|
31
|
+
function startsWithBytes(buffer, bytes, offset = 0) {
|
|
32
|
+
if (buffer.byteLength < offset + bytes.length) return false;
|
|
33
|
+
return bytes.every((byte, index) => buffer[offset + index] === byte);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function hasKnownMimeSignature(contents, mimetype) {
|
|
37
|
+
const buffer = Buffer.isBuffer(contents) ? contents : Buffer.from(contents);
|
|
38
|
+
switch (mimetype) {
|
|
39
|
+
case 'application/pdf':
|
|
40
|
+
return startsWithBytes(buffer, [0x25, 0x50, 0x44, 0x46, 0x2d]);
|
|
41
|
+
case 'image/png':
|
|
42
|
+
return startsWithBytes(buffer, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
43
|
+
case 'image/jpeg':
|
|
44
|
+
return startsWithBytes(buffer, [0xff, 0xd8, 0xff]);
|
|
45
|
+
case 'image/gif':
|
|
46
|
+
return buffer.subarray(0, 6).toString('ascii') === 'GIF87a'
|
|
47
|
+
|| buffer.subarray(0, 6).toString('ascii') === 'GIF89a';
|
|
48
|
+
case 'image/webp':
|
|
49
|
+
return buffer.subarray(0, 4).toString('ascii') === 'RIFF'
|
|
50
|
+
&& buffer.subarray(8, 12).toString('ascii') === 'WEBP';
|
|
51
|
+
default:
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function assertMimeSignature(contents, mimetype) {
|
|
57
|
+
const matches = hasKnownMimeSignature(contents, mimetype);
|
|
58
|
+
if (matches === false) {
|
|
59
|
+
throw fileError('FILE_MIME_MISMATCH', `File contents do not match declared MIME type: ${mimetype}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
30
63
|
function decodeJson(value) {
|
|
31
64
|
if (value == null || typeof value === 'object') return value ?? null;
|
|
32
65
|
try {
|
|
@@ -56,6 +89,31 @@ export class FilesService extends BaseService {
|
|
|
56
89
|
});
|
|
57
90
|
}
|
|
58
91
|
|
|
92
|
+
#compileReadScope(permission, id = null) {
|
|
93
|
+
let sql = '';
|
|
94
|
+
let params = [];
|
|
95
|
+
|
|
96
|
+
if (permission?.filter) {
|
|
97
|
+
const collectionSchema = this.schema?.collections?.yuncms_files;
|
|
98
|
+
if (!collectionSchema) {
|
|
99
|
+
throw fileError(
|
|
100
|
+
'SYSTEM_SCHEMA_REQUIRED',
|
|
101
|
+
'System schema is required to enforce a filtered Files permission',
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
const compiled = compileFilter(permission.filter, collectionSchema);
|
|
105
|
+
sql = compiled.sql;
|
|
106
|
+
params = [...compiled.params];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (id != null) {
|
|
110
|
+
sql = sql ? `${sql} AND id = ?` : ' WHERE id = ?';
|
|
111
|
+
params.push(id);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return { sql, params };
|
|
115
|
+
}
|
|
116
|
+
|
|
59
117
|
async #readOneUnsafe(id) {
|
|
60
118
|
const [rows] = await this.database.query(
|
|
61
119
|
`SELECT id, storage, filename_disk, filename_download, title, mimetype, filesize,
|
|
@@ -68,20 +126,34 @@ export class FilesService extends BaseService {
|
|
|
68
126
|
return normalizeRow(rows[0]);
|
|
69
127
|
}
|
|
70
128
|
|
|
129
|
+
async #readOneAuthorized(id, permission) {
|
|
130
|
+
const scope = this.#compileReadScope(permission, id);
|
|
131
|
+
const [rows] = await this.database.query(
|
|
132
|
+
`SELECT id, storage, filename_disk, filename_download, title, mimetype, filesize,
|
|
133
|
+
uploaded_by, uploaded_at, metadata
|
|
134
|
+
FROM yuncms_files${scope.sql}
|
|
135
|
+
LIMIT 1`,
|
|
136
|
+
scope.params,
|
|
137
|
+
);
|
|
138
|
+
return normalizeRow(rows[0]);
|
|
139
|
+
}
|
|
140
|
+
|
|
71
141
|
async readMany() {
|
|
72
|
-
await resolveSystemResourceAccess(this, 'read', 'yuncms_files');
|
|
142
|
+
const permission = await resolveSystemResourceAccess(this, 'read', 'yuncms_files');
|
|
143
|
+
const scope = this.#compileReadScope(permission);
|
|
73
144
|
const [rows] = await this.database.query(
|
|
74
145
|
`SELECT id, storage, filename_disk, filename_download, title, mimetype, filesize,
|
|
75
146
|
uploaded_by, uploaded_at, metadata
|
|
76
|
-
FROM yuncms_files
|
|
147
|
+
FROM yuncms_files${scope.sql}
|
|
77
148
|
ORDER BY uploaded_at DESC, id DESC`,
|
|
149
|
+
scope.params,
|
|
78
150
|
);
|
|
79
151
|
return rows.map(normalizeRow);
|
|
80
152
|
}
|
|
81
153
|
|
|
82
154
|
async readOne(id) {
|
|
83
|
-
await resolveSystemResourceAccess(this, 'read', 'yuncms_files');
|
|
84
|
-
return this.#
|
|
155
|
+
const permission = await resolveSystemResourceAccess(this, 'read', 'yuncms_files');
|
|
156
|
+
return this.#readOneAuthorized(id, permission);
|
|
85
157
|
}
|
|
86
158
|
|
|
87
159
|
async createOne({
|
|
@@ -100,6 +172,7 @@ export class FilesService extends BaseService {
|
|
|
100
172
|
|
|
101
173
|
const filename = normalizeFilename(filenameDownload);
|
|
102
174
|
const normalizedMime = normalizeMimeType(mimetype);
|
|
175
|
+
assertMimeSignature(contents, normalizedMime);
|
|
103
176
|
const driver = this.storage.get(storage);
|
|
104
177
|
const id = randomUUID();
|
|
105
178
|
const filenameDisk = id;
|
|
@@ -144,8 +217,8 @@ export class FilesService extends BaseService {
|
|
|
144
217
|
}
|
|
145
218
|
|
|
146
219
|
async readContent(id) {
|
|
147
|
-
await resolveSystemResourceAccess(this, 'read', 'yuncms_files');
|
|
148
|
-
const file = await this.#
|
|
220
|
+
const permission = await resolveSystemResourceAccess(this, 'read', 'yuncms_files');
|
|
221
|
+
const file = await this.#readOneAuthorized(id, permission);
|
|
149
222
|
if (!file) throw fileError('FILE_NOT_FOUND', `Unknown file: ${id}`);
|
|
150
223
|
const driver = this.storage.get(file.storage);
|
|
151
224
|
const contents = await driver.get(file.filename_disk);
|
|
@@ -225,4 +298,9 @@ export class FilesService extends BaseService {
|
|
|
225
298
|
}
|
|
226
299
|
}
|
|
227
300
|
|
|
228
|
-
export {
|
|
301
|
+
export {
|
|
302
|
+
assertMimeSignature,
|
|
303
|
+
hasKnownMimeSignature,
|
|
304
|
+
normalizeFilename,
|
|
305
|
+
normalizeMimeType,
|
|
306
|
+
};
|