@yunsoft/yuncms-core 0.1.11 → 0.1.13
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/bootstrap.js +4 -0
- package/src/migrations/0018-public-registration-settings.js +10 -0
- package/src/migrations/0019-public-registration-email-verification.js +8 -0
- package/src/services/auth-service.js +23 -0
- package/src/services/auth-tokens-service.js +29 -0
- package/src/services/studio-settings-service.js +90 -6
- package/src/services/users-service.js +64 -0
package/package.json
CHANGED
package/src/bootstrap.js
CHANGED
|
@@ -17,6 +17,8 @@ import { aiSettingsMigration } from './migrations/0014-ai-settings.js';
|
|
|
17
17
|
import { navigationGroupsMigration } from './migrations/0015-navigation-groups.js';
|
|
18
18
|
import { navigationGroupCollapseMigration } from './migrations/0016-navigation-group-collapse.js';
|
|
19
19
|
import { mcpSettingsMigration } from './migrations/0017-mcp-settings.js';
|
|
20
|
+
import { publicRegistrationSettingsMigration } from './migrations/0018-public-registration-settings.js';
|
|
21
|
+
import { publicRegistrationEmailVerificationMigration } from './migrations/0019-public-registration-email-verification.js';
|
|
20
22
|
import { readSchemaVersion } from './schema-version.js';
|
|
21
23
|
import { ensurePublicRole } from './setup.js';
|
|
22
24
|
|
|
@@ -38,6 +40,8 @@ export const CORE_MIGRATIONS = Object.freeze([
|
|
|
38
40
|
navigationGroupsMigration,
|
|
39
41
|
navigationGroupCollapseMigration,
|
|
40
42
|
mcpSettingsMigration,
|
|
43
|
+
publicRegistrationSettingsMigration,
|
|
44
|
+
publicRegistrationEmailVerificationMigration,
|
|
41
45
|
]);
|
|
42
46
|
|
|
43
47
|
export const REQUIRED_CORE_MIGRATION_IDS = Object.freeze(
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export const publicRegistrationSettingsMigration = {
|
|
2
|
+
id: '0018-public-registration-settings',
|
|
3
|
+
statements: [
|
|
4
|
+
`ALTER TABLE yuncms_studio_settings
|
|
5
|
+
ADD COLUMN public_registration_enabled TINYINT(1) NOT NULL DEFAULT 0 AFTER default_locale,
|
|
6
|
+
ADD COLUMN public_registration_role CHAR(36) NULL AFTER public_registration_enabled,
|
|
7
|
+
ADD CONSTRAINT fk_yuncms_studio_settings_public_registration_role
|
|
8
|
+
FOREIGN KEY (public_registration_role) REFERENCES yuncms_roles(id) ON DELETE SET NULL`,
|
|
9
|
+
],
|
|
10
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export const publicRegistrationEmailVerificationMigration = {
|
|
2
|
+
id: '0019-public-registration-email-verification',
|
|
3
|
+
statements: [
|
|
4
|
+
`ALTER TABLE yuncms_studio_settings
|
|
5
|
+
ADD COLUMN public_registration_require_email_verification TINYINT(1) NOT NULL DEFAULT 0
|
|
6
|
+
AFTER public_registration_role`,
|
|
7
|
+
],
|
|
8
|
+
};
|
|
@@ -13,6 +13,12 @@ function invalidLogin() {
|
|
|
13
13
|
return error;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
function emailNotVerified() {
|
|
17
|
+
const error = new Error('Email verification is required before sign in');
|
|
18
|
+
error.code = 'EMAIL_NOT_VERIFIED';
|
|
19
|
+
return error;
|
|
20
|
+
}
|
|
21
|
+
|
|
16
22
|
function invalidToken() {
|
|
17
23
|
const error = new Error('Invalid or expired authentication token');
|
|
18
24
|
error.code = 'INVALID_CREDENTIALS';
|
|
@@ -95,6 +101,23 @@ export class AuthService extends BaseService {
|
|
|
95
101
|
throw invalidLogin();
|
|
96
102
|
}
|
|
97
103
|
|
|
104
|
+
if (!user.email_verified_at) {
|
|
105
|
+
const [settingsRows] = await this.database.query(
|
|
106
|
+
`SELECT public_registration_require_email_verification
|
|
107
|
+
FROM yuncms_studio_settings
|
|
108
|
+
WHERE id = 1
|
|
109
|
+
LIMIT 1`,
|
|
110
|
+
);
|
|
111
|
+
if (settingsRows[0]?.public_registration_require_email_verification) {
|
|
112
|
+
await this.action('auth.login.failed', {
|
|
113
|
+
method: 'local',
|
|
114
|
+
reason: 'email_not_verified',
|
|
115
|
+
user: user.id,
|
|
116
|
+
}, { ip });
|
|
117
|
+
throw emailNotVerified();
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
98
121
|
const tokens = await this.createSessionsService().createForUser(user, { ip, userAgent });
|
|
99
122
|
await this.action('auth.login.success', {
|
|
100
123
|
method: 'local',
|
|
@@ -30,6 +30,13 @@ function assertPositiveTtl(ttlMs) {
|
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
function assertVerificationManager(accountability) {
|
|
34
|
+
if (accountability.system === true || accountability.admin === true) return;
|
|
35
|
+
const error = new Error('Email verification lookup requires administrator accountability');
|
|
36
|
+
error.code = 'FORBIDDEN';
|
|
37
|
+
throw error;
|
|
38
|
+
}
|
|
39
|
+
|
|
33
40
|
export class AuthTokensService extends BaseService {
|
|
34
41
|
async createForUser(userId, type, { ttlMs = DEFAULT_TTL_MS[type] } = {}) {
|
|
35
42
|
if (!TOKEN_TYPES[type]) {
|
|
@@ -162,6 +169,28 @@ export class AuthTokensService extends BaseService {
|
|
|
162
169
|
return this.createForUser(userId, 'verify', options);
|
|
163
170
|
}
|
|
164
171
|
|
|
172
|
+
async requestEmailVerification(email, options = {}) {
|
|
173
|
+
assertVerificationManager(this.accountability);
|
|
174
|
+
|
|
175
|
+
let normalized;
|
|
176
|
+
try {
|
|
177
|
+
normalized = normalizeEmail(email);
|
|
178
|
+
} catch {
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const user = await readAuthenticationUserByEmail(this.database, normalized);
|
|
183
|
+
if (!user || user.status !== 'active' || user.email_verified_at) return null;
|
|
184
|
+
const result = await this.createForUser(user.id, 'verify', options);
|
|
185
|
+
return {
|
|
186
|
+
...result,
|
|
187
|
+
user: {
|
|
188
|
+
id: user.id,
|
|
189
|
+
email: user.email,
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
165
194
|
async verifyEmail(token) {
|
|
166
195
|
if (tokenType(token) !== 'verify') throw invalidActionToken();
|
|
167
196
|
const connection = await this.database.getConnection();
|
|
@@ -68,6 +68,18 @@ function normalizeLocale(value) {
|
|
|
68
68
|
return normalized;
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
function normalizeBoolean(value, label) {
|
|
72
|
+
if (value === true || value === false) return value;
|
|
73
|
+
throw invalid(`${label} must be a boolean`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function normalizeRegistrationRole(value) {
|
|
77
|
+
if (value == null || value === '') return null;
|
|
78
|
+
const normalized = String(value).trim();
|
|
79
|
+
if (!UUID_PATTERN.test(normalized)) throw invalid('Public registration role id must be a UUID');
|
|
80
|
+
return normalized;
|
|
81
|
+
}
|
|
82
|
+
|
|
71
83
|
function publicSettings(row) {
|
|
72
84
|
return {
|
|
73
85
|
brand_name: row.brand_name,
|
|
@@ -77,14 +89,25 @@ function publicSettings(row) {
|
|
|
77
89
|
accent_color: row.accent_color,
|
|
78
90
|
theme: row.theme,
|
|
79
91
|
default_locale: row.default_locale,
|
|
92
|
+
public_registration_enabled: Boolean(row.public_registration_enabled),
|
|
93
|
+
public_registration_require_email_verification: Boolean(row.public_registration_require_email_verification),
|
|
80
94
|
updated_at: row.updated_at ?? null,
|
|
81
95
|
};
|
|
82
96
|
}
|
|
83
97
|
|
|
98
|
+
function managedSettings(row) {
|
|
99
|
+
return {
|
|
100
|
+
...publicSettings(row),
|
|
101
|
+
public_registration_role: row.public_registration_role ?? null,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
84
105
|
export class StudioSettingsService extends BaseService {
|
|
85
|
-
async
|
|
106
|
+
async #readSettingsRow() {
|
|
86
107
|
const [rows] = await this.database.query(
|
|
87
|
-
`SELECT brand_name, logo_url, logo_file, favicon_file, accent_color, theme, default_locale,
|
|
108
|
+
`SELECT brand_name, logo_url, logo_file, favicon_file, accent_color, theme, default_locale,
|
|
109
|
+
public_registration_enabled, public_registration_role,
|
|
110
|
+
public_registration_require_email_verification, updated_at
|
|
88
111
|
FROM yuncms_studio_settings
|
|
89
112
|
WHERE id = 1
|
|
90
113
|
LIMIT 1`,
|
|
@@ -95,12 +118,16 @@ export class StudioSettingsService extends BaseService {
|
|
|
95
118
|
error.code = 'DATABASE_MIGRATION_REQUIRED';
|
|
96
119
|
throw error;
|
|
97
120
|
}
|
|
98
|
-
return
|
|
121
|
+
return row;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async readPublic() {
|
|
125
|
+
return publicSettings(await this.#readSettingsRow());
|
|
99
126
|
}
|
|
100
127
|
|
|
101
128
|
async readOne() {
|
|
102
129
|
assertManager(this.accountability);
|
|
103
|
-
return this
|
|
130
|
+
return managedSettings(await this.#readSettingsRow());
|
|
104
131
|
}
|
|
105
132
|
|
|
106
133
|
async readImageAssetContent(settingKey, label) {
|
|
@@ -147,16 +174,55 @@ export class StudioSettingsService extends BaseService {
|
|
|
147
174
|
}
|
|
148
175
|
}
|
|
149
176
|
|
|
177
|
+
async validateRegistrationRole(roleId) {
|
|
178
|
+
if (!roleId) return;
|
|
179
|
+
const [roles] = await this.database.query(
|
|
180
|
+
'SELECT id, admin, public FROM yuncms_roles WHERE id = ? LIMIT 1',
|
|
181
|
+
[roleId],
|
|
182
|
+
);
|
|
183
|
+
const role = roles[0];
|
|
184
|
+
if (!role) throw invalid('Selected public registration role does not exist');
|
|
185
|
+
if (role.admin) throw invalid('Administrator roles cannot be used for public registration');
|
|
186
|
+
if (role.public) throw invalid('The unauthenticated public role cannot be used for registered users');
|
|
187
|
+
}
|
|
188
|
+
|
|
150
189
|
async updateOne(patch = {}) {
|
|
151
190
|
assertManager(this.accountability);
|
|
152
191
|
if (!patch || typeof patch !== 'object' || Array.isArray(patch)) throw invalid('Studio settings patch must be an object');
|
|
153
192
|
|
|
154
193
|
const keys = Object.keys(patch);
|
|
155
|
-
const allowed = new Set([
|
|
194
|
+
const allowed = new Set([
|
|
195
|
+
'brand_name',
|
|
196
|
+
'logo_url',
|
|
197
|
+
'logo_file',
|
|
198
|
+
'favicon_file',
|
|
199
|
+
'accent_color',
|
|
200
|
+
'theme',
|
|
201
|
+
'default_locale',
|
|
202
|
+
'public_registration_enabled',
|
|
203
|
+
'public_registration_role',
|
|
204
|
+
'public_registration_require_email_verification',
|
|
205
|
+
]);
|
|
156
206
|
if (keys.length === 0 || keys.some((key) => !allowed.has(key))) {
|
|
157
207
|
throw invalid('Studio settings patch contains unsupported properties');
|
|
158
208
|
}
|
|
159
209
|
|
|
210
|
+
let normalizedRegistrationEnabled;
|
|
211
|
+
let normalizedRegistrationRole;
|
|
212
|
+
if (Object.hasOwn(patch, 'public_registration_enabled') || Object.hasOwn(patch, 'public_registration_role')) {
|
|
213
|
+
const current = await this.readOne();
|
|
214
|
+
normalizedRegistrationEnabled = Object.hasOwn(patch, 'public_registration_enabled')
|
|
215
|
+
? normalizeBoolean(patch.public_registration_enabled, 'Public registration enabled')
|
|
216
|
+
: current.public_registration_enabled;
|
|
217
|
+
normalizedRegistrationRole = Object.hasOwn(patch, 'public_registration_role')
|
|
218
|
+
? normalizeRegistrationRole(patch.public_registration_role)
|
|
219
|
+
: current.public_registration_role;
|
|
220
|
+
await this.validateRegistrationRole(normalizedRegistrationRole);
|
|
221
|
+
if (normalizedRegistrationEnabled && !normalizedRegistrationRole) {
|
|
222
|
+
throw invalid('A non-administrator role must be selected before public registration can be enabled');
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
160
226
|
const assignments = [];
|
|
161
227
|
const params = [];
|
|
162
228
|
if (Object.hasOwn(patch, 'brand_name')) {
|
|
@@ -191,13 +257,28 @@ export class StudioSettingsService extends BaseService {
|
|
|
191
257
|
assignments.push('default_locale = ?');
|
|
192
258
|
params.push(normalizeLocale(patch.default_locale));
|
|
193
259
|
}
|
|
260
|
+
if (Object.hasOwn(patch, 'public_registration_enabled')) {
|
|
261
|
+
assignments.push('public_registration_enabled = ?');
|
|
262
|
+
params.push(normalizedRegistrationEnabled ? 1 : 0);
|
|
263
|
+
}
|
|
264
|
+
if (Object.hasOwn(patch, 'public_registration_role')) {
|
|
265
|
+
assignments.push('public_registration_role = ?');
|
|
266
|
+
params.push(normalizedRegistrationRole);
|
|
267
|
+
}
|
|
268
|
+
if (Object.hasOwn(patch, 'public_registration_require_email_verification')) {
|
|
269
|
+
assignments.push('public_registration_require_email_verification = ?');
|
|
270
|
+
params.push(normalizeBoolean(
|
|
271
|
+
patch.public_registration_require_email_verification,
|
|
272
|
+
'Public registration email verification requirement',
|
|
273
|
+
) ? 1 : 0);
|
|
274
|
+
}
|
|
194
275
|
|
|
195
276
|
params.push(1);
|
|
196
277
|
await this.database.query(
|
|
197
278
|
`UPDATE yuncms_studio_settings SET ${assignments.join(', ')} WHERE id = ?`,
|
|
198
279
|
params,
|
|
199
280
|
);
|
|
200
|
-
return this.
|
|
281
|
+
return this.readOne();
|
|
201
282
|
}
|
|
202
283
|
}
|
|
203
284
|
|
|
@@ -209,4 +290,7 @@ export const STUDIO_SETTING_DEFAULTS = Object.freeze({
|
|
|
209
290
|
accent_color: '#2563eb',
|
|
210
291
|
theme: 'system',
|
|
211
292
|
default_locale: 'en',
|
|
293
|
+
public_registration_enabled: false,
|
|
294
|
+
public_registration_role: null,
|
|
295
|
+
public_registration_require_email_verification: false,
|
|
212
296
|
});
|
|
@@ -7,6 +7,7 @@ import { resolveSystemResourceAccess } from './system-resource-access.js';
|
|
|
7
7
|
|
|
8
8
|
const USER_STATUSES = new Set(['active', 'suspended', 'disabled']);
|
|
9
9
|
const USER_UPDATE_KEYS = new Set(['email', 'role', 'status']);
|
|
10
|
+
const PUBLIC_REGISTRATION_KEYS = new Set(['email', 'password']);
|
|
10
11
|
|
|
11
12
|
function normalizeEmail(email) {
|
|
12
13
|
if (typeof email !== 'string') {
|
|
@@ -29,6 +30,12 @@ function forbidden(message) {
|
|
|
29
30
|
return error;
|
|
30
31
|
}
|
|
31
32
|
|
|
33
|
+
function invalid(message) {
|
|
34
|
+
const error = new Error(message);
|
|
35
|
+
error.code = 'INVALID_PAYLOAD';
|
|
36
|
+
return error;
|
|
37
|
+
}
|
|
38
|
+
|
|
32
39
|
function assertStatus(status) {
|
|
33
40
|
if (!USER_STATUSES.has(status)) {
|
|
34
41
|
const error = new Error(`Unsupported user status: ${status}`);
|
|
@@ -154,6 +161,63 @@ export class UsersService extends BaseService {
|
|
|
154
161
|
return user;
|
|
155
162
|
}
|
|
156
163
|
|
|
164
|
+
async registerPublic(input = {}) {
|
|
165
|
+
if (this.accountability.public !== true) {
|
|
166
|
+
throw forbidden('Public registration is available only to unauthenticated requests');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const [settingsRows] = await this.database.query(
|
|
170
|
+
`SELECT public_registration_enabled, public_registration_role,
|
|
171
|
+
public_registration_require_email_verification
|
|
172
|
+
FROM yuncms_studio_settings
|
|
173
|
+
WHERE id = 1
|
|
174
|
+
LIMIT 1`,
|
|
175
|
+
);
|
|
176
|
+
const settings = settingsRows[0];
|
|
177
|
+
if (!settings) {
|
|
178
|
+
const error = new Error('Studio settings are missing; run YunCMS bootstrap');
|
|
179
|
+
error.code = 'DATABASE_MIGRATION_REQUIRED';
|
|
180
|
+
throw error;
|
|
181
|
+
}
|
|
182
|
+
if (!settings.public_registration_enabled) {
|
|
183
|
+
throw forbidden('Public registration is disabled');
|
|
184
|
+
}
|
|
185
|
+
if (!settings.public_registration_role) {
|
|
186
|
+
throw forbidden('Public registration has no assigned user role');
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const [roleRows] = await this.database.query(
|
|
190
|
+
'SELECT id, admin, public FROM yuncms_roles WHERE id = ? LIMIT 1',
|
|
191
|
+
[settings.public_registration_role],
|
|
192
|
+
);
|
|
193
|
+
const role = roleRows[0];
|
|
194
|
+
if (!role || role.admin || role.public) {
|
|
195
|
+
throw forbidden('Public registration role is invalid');
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
|
199
|
+
throw invalid('Public registration payload must be an object');
|
|
200
|
+
}
|
|
201
|
+
const keys = Object.keys(input);
|
|
202
|
+
if (keys.some((key) => !PUBLIC_REGISTRATION_KEYS.has(key))) {
|
|
203
|
+
throw invalid('Public registration accepts email and password only');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const email = normalizeEmail(input.email);
|
|
207
|
+
const passwordHash = await hashPassword(input.password);
|
|
208
|
+
const id = randomUUID();
|
|
209
|
+
const verifiedAt = settings.public_registration_require_email_verification ? null : new Date();
|
|
210
|
+
await this.database.query(
|
|
211
|
+
`INSERT INTO yuncms_users (id, email, password_hash, role, status, email_verified_at)
|
|
212
|
+
VALUES (?, ?, ?, ?, 'active', ?)`,
|
|
213
|
+
[id, email, passwordHash, role.id, verifiedAt],
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
const user = await this.#readOneUnsafe(id);
|
|
217
|
+
await this.action('users.create', { key: id, item: user, publicRegistration: true });
|
|
218
|
+
return user;
|
|
219
|
+
}
|
|
220
|
+
|
|
157
221
|
async updateOne(id, patch = {}) {
|
|
158
222
|
await resolveSystemResourceAccess(this, 'update', 'yuncms_users');
|
|
159
223
|
await assertTargetManageable(this.database, id, this.accountability);
|